fix(daemon): restart on version change so updates actually go live#31
Merged
Conversation
A long-running daemon keeps the code it loaded at spawn, so an `uv tool install` that swaps the venv underneath it never takes effect: the post-install session-start only WARMED an already-running daemon (a no-op when it's up) and the spawn path had no version check — so a daemon could serve old code indefinitely (observed: pid up since June 10 still serving 0.2.0 after the 0.3.0 release). - The daemon stamps the version of the code it is running into daemon.state at startup (write_daemon_state). - ultan/_daemon.restart_if_stale() compares that against the live installed version (read from on-disk metadata, so an update is visible even while the old process runs the previous code) and, on a genuine mismatch, SIGTERMs the old daemon (graceful — it has a stop-event handler), clears the spawn backoff, and lets ensure_running() spawn the new one. Fail-safe: no daemon / dead pid / unknown version on either side -> leave it alone. - Wired into _session_start — the once-per-session path ensure-ultan.sh fires right after an update — NOT the per-turn hook hot path (it reads metadata; test_hook_import guards that). - `ultan doctor` now prints "daemon code: X" and flags "STALE (installed Y); restarts on the next session", closing the visibility gap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Anchor the stale check on the INSTALLED version, not on both sides being present. A running daemon with no recorded version is a legacy process from before the stamp existed — exactly the already-running daemon a user has when they first adopt this feature — so it must restart, not be left alone. Still fail-safe on the installed side: an unreadable installed version (thin/uvx env) never triggers a restart. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test wrote to the real ~/.agent-mem (CI has no such dir -> FileNotFoundError; locally it silently clobbered the user's daemon.state). conftest's home isolation is opt-in (agent_mem_home), not autouse as a stale docstring implied, so request it explicitly and assert against the isolated tmp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
A long-running process keeps the code it loaded at spawn. So when
uv tool installswaps the venv to a new version, the already-running daemon keeps serving the old code — and nothing replaced it:ultan hook session-startonly warms a daemon (no-op when one's already up),ensure_running()early-returns the instant the socket answers, andObserved: a daemon up since June 10 still serving
0.2.0after the0.3.0release. The doctor couldn't even surface it (it read a hardcoded version — fixed separately in #30).The fix
daemon.stateat startup (write_daemon_state) — read from on-disk metadata at spawn, where it equals the loaded code.restart_if_stale()(ultan/_daemon.py) compares that against the live installed version (read fresh, so an update is visible even while the old process runs). On a genuine mismatch it SIGTERMs the old daemon (graceful — it has a stop-event handler), clears the spawn backoff, and letsensure_running()bring up the new one._session_start— the once-per-session pathensure-ultan.shfires right after an update — so the loop closes tightly: update → session-start → stale detected → restart onto new code.ultan doctornow printsdaemon code: Xand flagsSTALE (installed Y); restarts on the next session.Design guarantees
ensure_running().test_hook_importstill passes (no metadata import leaks into the hook path).None→ no-op.ensure_running's existing flock.Verification
tests/test_daemon_restart.py(11 — restart matrix, metadata helper, session-start wiring, doctor staleness line) anddaemon/tests/test_daemon_state.py(version recorded in state).Independent of #30 (no file overlap); both are "version correctness."
🤖 Generated with Claude Code