Skip to content

fix(daemon): restart on version change so updates actually go live#31

Merged
nickroci merged 3 commits into
mainfrom
feat/daemon-restart-on-update
Jun 12, 2026
Merged

fix(daemon): restart on version change so updates actually go live#31
nickroci merged 3 commits into
mainfrom
feat/daemon-restart-on-update

Conversation

@nickroci

Copy link
Copy Markdown
Owner

The bug

A long-running process keeps the code it loaded at spawn. So when uv tool install swaps the venv to a new version, the already-running daemon keeps serving the old code — and nothing replaced it:

  • the post-install ultan hook session-start only warms a daemon (no-op when one's already up),
  • ensure_running() early-returns the instant the socket answers, and
  • the spawn path had no version check.

Observed: a daemon up since June 10 still serving 0.2.0 after the 0.3.0 release. The doctor couldn't even surface it (it read a hardcoded version — fixed separately in #30).

The fix

  1. The daemon stamps its running version into daemon.state at startup (write_daemon_state) — read from on-disk metadata at spawn, where it equals the loaded code.
  2. 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 lets ensure_running() bring up the new one.
  3. Wired into _session_start — the once-per-session path ensure-ultan.sh fires right after an update — so the loop closes tightly: update → session-start → stale detected → restart onto new code.
  4. ultan doctor now prints daemon code: X and flags STALE (installed Y); restarts on the next session.

Design guarantees

  • Off the hot path. The version check reads package metadata (cold-cheap, hot-needless), so it lives in session-start, never the per-turn ensure_running(). test_hook_import still passes (no metadata import leaks into the hook path).
  • Fail-safe. No daemon / dead pid / unknown version on either side → no restart. It never kills a daemon it can't prove is stale, and a thin/uvx env (no daemon dist) returns None → no-op.
  • Graceful + safe respawn. SIGTERM → wait → SIGKILL fallback; the spawn-backoff stamp is cleared so the intentional restart isn't throttled as a crash loop; concurrency is handled by ensure_running's existing flock.

Verification

  • New tests: tests/test_daemon_restart.py (11 — restart matrix, metadata helper, session-start wiring, doctor staleness line) and daemon/tests/test_daemon_state.py (version recorded in state).
  • Root suite 44 passed; daemon suite 663 passed (coverage 90.42%); ruff/pyright clean; hot-path guard intact.

Independent of #30 (no file overlap); both are "version correctness."

🤖 Generated with Claude Code

nickroci and others added 3 commits June 12, 2026 11:33
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>
@nickroci nickroci merged commit 2530e04 into main Jun 12, 2026
7 checks passed
@nickroci nickroci deleted the feat/daemon-restart-on-update branch June 12, 2026 10:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant