Skip to content

v2.35.0

Latest

Choose a tag to compare

@github-actions github-actions released this 16 Oct 15:52
· 3 commits to main since this release
c6770f3

Upgrade notes

  • Windows users: upgrade. levh serve could stay running while refusing every connection after one client dropped mid-accept (WinError 64), so every agent saw ECONNREFUSED until a manual restart. Fixed below; uvicorn>=0.36 is now required.
  • Removed: the demo-data feature — levh seed-demo, levh remove-demo, setup --demo / --real, POST /api/seed-demo and POST /api/onboarding/remove-demo. Scripts that call them must drop those calls; levh setup now always prepares an empty store.
  • New, opt-in: python -m server.supervisor keeps a git-checkout install running and fast-forwards it to main every 6 hours (LEVH_AUTO_UPDATE_HOURS, 0 turns it off). Nothing changes unless you start it.

feat: levh update and a supervisor that keeps the server up and current

  • levh update [--check] [--remote origin] [--branch main] [--json] fast-forwards the git checkout LEVH runs from. It refuses a tree with local edits to tracked files, another branch, a history upstream does not contain, or a directory that is not the checkout root (a wheel install nested in a versioned home directory is never "updated"). When pyproject.toml or uv.lock changed it reinstalls the checkout (pip, or uv pip in a uv environment without pip) and rolls back with git reset --keep if that fails. Git runs with prompts disabled and every call has a deadline that kills the whole process tree, so an unattended run cannot hang on a credential prompt.
  • python -m server.supervisor runs levh serve as its child and restarts it when it exits (with crash-loop backoff), when the port actively refuses while the process lives (the Windows listener loss), or after ten failed health probes past a ten-minute boot grace. It adopts, and never kills, a server it did not start. A lock file keeps one copy, so a logon task can launch it every few minutes. Every LEVH_AUTO_UPDATE_HOURS (default 6, 0 off) it checks for an update and, only when behind, stops the server, updates, and starts it again. Health probes go to loopback for wildcard binds and bypass any HTTP proxy. See docs/installation.md.

fix: keep the Windows listener alive after a client drops mid-accept

  • On Windows levh serve ran on uvicorn's default ProactorEventLoop, whose accept loop closes the listening socket on the first OSError from AcceptEx. WinError 64 ("the specified network name is no longer available") is raised whenever a client resets its connection before it is accepted — routine for MCP clients that time out and retry — so after one such drop the process kept running but refused every connection, and every agent saw ECONNREFUSED until someone restarted it. levh serve now passes uvicorn a SelectorEventLoop factory on Windows (server/core/event_loop.py); other platforms keep uvicorn's default. Observed on CPython 3.12.10 in production (Accept failed on a socket ... WinError 64 in the server log, then no listener) and reproduced on 3.13.13: 20 reset connections kill the proactor listener, the selector listener keeps serving. tests/test_serve_event_loop.py replays that scenario on both loops (the proactor half only on Windows, as a canary for a CPython fix).
  • The uvicorn floor rises from >=0.30 to >=0.36: custom loop factories (Config.get_loop_factory) arrived in 0.36, and an older uvicorn would fail to start levh serve on Windows with this change.
  • Running uvicorn server.api:app by hand on Windows still gets the proactor loop; pass --loop server.core.event_loop:selector_event_loop.

feat: remove the demo-data feature

  • Dropped the demo corpus and its whole surface: server/core/demo_data.py, MemoryEngine.seed_demo, MemoryOnboardingMixin's demo helpers, POST /api/seed-demo, POST /api/onboarding/remove-demo, the levh seed-demo / levh remove-demo commands, setup --demo / --real (setup now always prepares an empty store), the seed_demo_completed dogfood event, and the dashboard's "Load demo data" / "Remove demo data" onboarding buttons. Onboarding readiness is now a single path: store a real memory and connect an MCP client.
  • The read-only public-instance boundary (LEVH_PUBLIC_DEMO) is unrelated and unchanged.

fix: keep the memory drawer and the quick-add FAB clickable

  • The memory detail drawer rendered inside <main class="relative z-10">, a stacking context that sits below the sticky header (z-30). Its own z-50 could not escape it, so the header — and its Quick-capture link — painted over the drawer and swallowed clicks on Pin, Delete and Close. It is now portalled to document.body, which is what makes those controls reachable; memory-detail-drawer.test.tsx asserts the portal so a future edit cannot quietly drop it.
  • The librarian widget's FAB is position: fixed with z-index: 99999 and was pinned to the same corner as the dashboard's quick-add FAB. Every click on "Add new memory" landed on the librarian button and opened its chat instead, so the quick-capture dialog never appeared. The widget FAB now sits above the quick-add one (bottom: 96px, 80px on narrow screens), and buttons.spec.ts fails if the two overlap again.

perf: stop the model-free interference scan from freezing the event loop (#505)

  • In hash embedder mode _interference_candidates scores a new memory against every stored one, and each comparison scanned the full content vocabulary of both texts with _stem_matches. On a store of ~500 source documents (avg 16 KB) one store() spent 47 s on the event loop, so an auto-feed ingest pinned a CPU core and the dashboard stopped answering. similarity_expanded now indexes the content words by their _MIN_STEM_LENGTH opening prefix, so a term only compares against the words that can actually share its stem. The filter is exact — every comparison the flat scan would have made still runs — and a full scan drops from ~4.2 s to 0.09 s (~43×). Scores are bit-identical: differential fuzzing over 10 000 random pairs (Turkish text, short tokens, random synonym expansions) found no difference.

fix: serve the complete dashboard export, not a stale bundle-less one (#507)

  • _dashboard_dir accepted any candidate with an index.html and an _next/static directory. A checkout that built the export once keeps that _next beside newer committed HTML, so the referenced content hashes are gone: / answered 200 while the chunks 404'd and React never hydrated, and routes whose chunk was missing rendered "Something went wrong" — the dashboard's buttons did nothing because the page never came up. _missing_dashboard_assets now reads every HTML file in a candidate and confirms each referenced _next/static bundle is present; only a complete export is chosen, and the packaged server/dashboard (which always ships its bundles) is served instead of the partial one. frontend/e2e/start-server.sh runs the same check before deciding whether to rebuild.

fix: stop the conflict list from 500ing on a concurrent rebuild

  • insert_conflict_if_absent now uses INSERT OR IGNORE instead of a read-then-insert. Two freshness reads can each trigger a derived-state rebuild at once (only the background pass is coalesced), and the check-then-insert race let both see a candidate pair as absent and then collide on its primary key. The loser raised UNIQUE constraint failed: memory_conflict_candidates.id, turning /api/conflicts into a 500 that the dashboard's Conflicts page hit on load. The observable contract is unchanged: an existing row — any status — is left untouched.

fix: make the dashboard's Copy buttons work outside localhost

  • Added copyText (frontend/src/lib/clipboard.ts): it prefers navigator.clipboard.writeText and falls back to a hidden-textarea execCommand("copy") when that API is missing (a plain-HTTP LAN origin) or rejects the write (NotAllowedError). The Settings, Projects and Onboarding copy buttons now report success truthfully instead of throwing and silently doing nothing.

fix: serialize explicit write transactions against a derived-state rebuild

  • Every write path shares one aiosqlite connection, and a derived-state rebuild pass holds an implicit transaction open between its first DELETE and its commit. A hard-delete or a snapshot restore issuing BEGIN IMMEDIATE in that window raised sqlite3.OperationalError: cannot start a transaction within a transaction. The startup warm task added in #503 made a background pass overlap a request's transaction routine, so the failure moved from rare to about one run in three. Database.transaction now holds a write_lock across an explicit transaction, and the rebuild holds the same lock for its whole pass, turning the interleave from a race into an invariant. The retry backoff sleep stays outside the lock, so a failing pass cannot hold writers off for the backoff duration.

fix: stop a pre-restore safety backup from deadlocking a derived-state rebuild

  • create_safety_backup now takes write_lock for the duration of the SQLite online backup. Connection.backup is queued on the shared connection like any other statement, but its worker-thread call retries in 250 ms sleeps until SQLite reports it done, so it can outlive the statements queued around it. A backup queued while a rebuild held write_lock sat ahead of the rebuild's own queued commit, and neither could run: /api/restore wedged forever with no error and no timeout. A replace restore takes the backup on its way into the same transaction, so the two are exactly the paths that meet.