Skip to content

Commit 2530e04

Browse files
authored
Merge pull request #31 from nickroci/feat/daemon-restart-on-update
fix(daemon): restart on version change so updates actually go live
2 parents f520c78 + 63c27d0 commit 2530e04

7 files changed

Lines changed: 321 additions & 12 deletions

File tree

daemon/agent_mem_daemon/__main__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,20 @@ def release_pid_file(path: Path) -> None:
118118
def write_daemon_state(phase: str) -> None:
119119
"""Publish the lifecycle flag other processes read (see
120120
``paths.daemon_state_path``). Best-effort — a failed write must never
121-
block startup; consumers fall back to pid/socket inference."""
121+
block startup; consumers fall back to pid/socket inference.
122+
123+
Records the running code's ``version`` (read from on-disk metadata at this
124+
process's startup, where it equals the code actually loaded). The ``ultan``
125+
spawn path compares it against the live installed version to detect a daemon
126+
left running OLD code after an ``uv tool install`` update, and restarts it."""
122127
import json # noqa: PLC0415 — tiny, cold path
123128
from datetime import datetime, timezone # noqa: PLC0415
129+
from importlib.metadata import PackageNotFoundError, version # noqa: PLC0415
130+
131+
try:
132+
running_version: str | None = version("agent-mem-daemon")
133+
except PackageNotFoundError:
134+
running_version = None
124135

125136
try:
126137
daemon_state_path().write_text(
@@ -129,6 +140,7 @@ def write_daemon_state(phase: str) -> None:
129140
"phase": phase,
130141
"pid": os.getpid(),
131142
"since": datetime.now(timezone.utc).isoformat(timespec="seconds"),
143+
"version": running_version,
132144
}
133145
),
134146
encoding="utf-8",

daemon/tests/test_daemon_state.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""The daemon stamps the version of the code it is running into daemon.state, so
2+
the `ultan` spawn path can detect a daemon left on old code after an update and
3+
restart it. ``agent_mem_home`` isolates AGENT_MEM_HOME (opt-in, per conftest) so
4+
the write never touches the user's real ~/.agent-mem."""
5+
6+
from __future__ import annotations
7+
8+
import importlib.metadata as md
9+
import json
10+
import os
11+
from pathlib import Path
12+
13+
from agent_mem_daemon import __main__ as dm
14+
15+
16+
def test_write_daemon_state_records_running_version(agent_mem_home: Path) -> None:
17+
dm.write_daemon_state("ready")
18+
state = json.loads((agent_mem_home / "daemon.state").read_text(encoding="utf-8"))
19+
assert state["phase"] == "ready"
20+
assert state["pid"] == os.getpid()
21+
# The version of the code this process is running, read from on-disk metadata
22+
# at startup (where it equals the loaded code).
23+
assert state["version"] == md.version("agent-mem-daemon")

tests/test_daemon_restart.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""A daemon left running OLD code after an `ultan` tool update must be detected
2+
and restarted — a long-running process keeps the code it loaded at spawn, so
3+
`uv tool install` swapping the venv underneath it doesn't take effect until the
4+
process is replaced. The version-aware restart lives in the session-start path
5+
(NOT the per-turn hot path) and the mismatch is surfaced by `ultan doctor`.
6+
"""
7+
8+
import json
9+
import os
10+
from pathlib import Path
11+
from typing import Any
12+
13+
import pytest
14+
15+
from ultan import _daemon, _doctor, _hooks
16+
17+
18+
@pytest.fixture()
19+
def home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
20+
h = tmp_path / "agent-mem"
21+
h.mkdir()
22+
monkeypatch.setenv("AGENT_MEM_HOME", str(h))
23+
return h
24+
25+
26+
def _write_state(home: Path, *, pid: int, version: Any) -> None:
27+
state: dict[str, Any] = {"phase": "ready", "pid": pid, "since": "x"}
28+
if version is not None:
29+
state["version"] = version
30+
(home / "daemon.state").write_text(json.dumps(state), encoding="utf-8")
31+
32+
33+
# ── restart_if_stale: only restarts on a genuine version mismatch ──────
34+
35+
36+
def test_no_restart_when_no_state(home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
37+
monkeypatch.setattr(_daemon, "_installed_daemon_version", lambda: "0.3.0")
38+
assert _daemon.restart_if_stale() is False
39+
40+
41+
def test_no_restart_when_pid_dead(home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
42+
_write_state(home, pid=99999999, version="0.2.0")
43+
monkeypatch.setattr(_daemon, "_installed_daemon_version", lambda: "0.3.0")
44+
assert _daemon.restart_if_stale() is False
45+
46+
47+
def test_no_restart_when_versions_match(home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
48+
_write_state(home, pid=os.getpid(), version="0.3.0")
49+
monkeypatch.setattr(_daemon, "_installed_daemon_version", lambda: "0.3.0")
50+
stopped: list[int] = []
51+
monkeypatch.setattr(_daemon, "_stop_daemon", lambda pid, **k: stopped.append(pid))
52+
assert _daemon.restart_if_stale() is False
53+
assert stopped == []
54+
55+
56+
def test_no_restart_when_installed_version_unknown(
57+
home: Path, monkeypatch: pytest.MonkeyPatch
58+
) -> None:
59+
_write_state(home, pid=os.getpid(), version="0.2.0")
60+
monkeypatch.setattr(_daemon, "_installed_daemon_version", lambda: None)
61+
stopped: list[int] = []
62+
monkeypatch.setattr(_daemon, "_stop_daemon", lambda pid, **k: stopped.append(pid))
63+
assert _daemon.restart_if_stale() is False
64+
assert stopped == []
65+
66+
67+
def test_restart_legacy_daemon_with_no_version_stamp(
68+
home: Path, monkeypatch: pytest.MonkeyPatch
69+
) -> None:
70+
# A legacy daemon (pre-this-feature) wrote no version. Anchored on a KNOWN
71+
# installed version, that's unambiguously old code → restart. This is the
72+
# migration case: the daemon a user already has running when they first adopt
73+
# the feature has no stamp, and must still be replaced.
74+
_write_state(home, pid=os.getpid(), version=None)
75+
monkeypatch.setattr(_daemon, "_installed_daemon_version", lambda: "0.3.0")
76+
stopped: list[int] = []
77+
monkeypatch.setattr(_daemon, "_stop_daemon", lambda pid, **k: stopped.append(pid))
78+
assert _daemon.restart_if_stale() is True
79+
assert stopped == [os.getpid()]
80+
81+
82+
def test_restart_when_stale_stops_and_clears_backoff(
83+
home: Path, monkeypatch: pytest.MonkeyPatch
84+
) -> None:
85+
_write_state(home, pid=os.getpid(), version="0.2.0")
86+
(home / ".daemon-spawn-attempt").write_text("x", encoding="utf-8")
87+
monkeypatch.setattr(_daemon, "_installed_daemon_version", lambda: "0.3.0")
88+
stopped: list[int] = []
89+
monkeypatch.setattr(_daemon, "_stop_daemon", lambda pid, **k: stopped.append(pid))
90+
91+
assert _daemon.restart_if_stale() is True
92+
assert stopped == [os.getpid()] # stopped the stale daemon
93+
# backoff stamp cleared so ensure_running() respawns immediately, not throttled
94+
assert not (home / ".daemon-spawn-attempt").exists()
95+
96+
97+
# ── _installed_daemon_version ─────────────────────────────────────────
98+
99+
100+
def test_installed_version_matches_metadata_or_none() -> None:
101+
import importlib.metadata as md
102+
103+
# Env-agnostic: in a full `ultan[retrieval]` install the daemon dist resolves;
104+
# in the thin root/CI venv (no extras) it doesn't and the helper returns None.
105+
# Either way it must agree with importlib.metadata — never invent a version.
106+
try:
107+
expected: str | None = md.version("agent-mem-daemon")
108+
except md.PackageNotFoundError:
109+
expected = None
110+
assert _daemon._installed_daemon_version() == expected
111+
112+
113+
def test_installed_version_none_when_dist_absent(monkeypatch: pytest.MonkeyPatch) -> None:
114+
import importlib.metadata as md
115+
116+
def _raise(_name: str) -> str:
117+
raise md.PackageNotFoundError
118+
119+
monkeypatch.setattr(md, "version", _raise)
120+
assert _daemon._installed_daemon_version() is None
121+
122+
123+
# ── session-start wires the restart in (before warming) ───────────────
124+
125+
126+
def test_session_start_calls_restart_if_stale(monkeypatch: pytest.MonkeyPatch) -> None:
127+
order: list[str] = []
128+
monkeypatch.setattr(
129+
_hooks._daemon, "restart_if_stale", lambda: order.append("restart") or False
130+
)
131+
monkeypatch.setattr(_hooks._daemon, "ensure_running", lambda: order.append("ensure") or True)
132+
monkeypatch.setattr(_hooks._events, "append_event", lambda *a, **k: None)
133+
134+
rc = _hooks.dispatch("session-start", {"session_id": "s", "source": "startup"})
135+
136+
assert rc == 0
137+
assert order == ["restart", "ensure"] # stale-check BEFORE warming
138+
139+
140+
# ── doctor surfaces the mismatch even before the restart lands ─────────
141+
142+
143+
def test_doctor_flags_stale_daemon(
144+
home: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
145+
) -> None:
146+
monkeypatch.setattr(_doctor, "_report_runtime", lambda home: False)
147+
(home / "daemon.pid").write_text(f"{os.getpid()}\n", encoding="utf-8")
148+
_write_state(home, pid=os.getpid(), version="0.2.0")
149+
monkeypatch.setattr(_daemon, "_installed_daemon_version", lambda: "0.3.0")
150+
151+
_doctor.run()
152+
out = capsys.readouterr().out
153+
154+
assert "daemon code: 0.2.0 — STALE (installed 0.3.0)" in out
155+
156+
157+
def test_doctor_no_stale_line_when_versions_match(
158+
home: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
159+
) -> None:
160+
monkeypatch.setattr(_doctor, "_report_runtime", lambda home: False)
161+
(home / "daemon.pid").write_text(f"{os.getpid()}\n", encoding="utf-8")
162+
_write_state(home, pid=os.getpid(), version="0.3.0")
163+
monkeypatch.setattr(_daemon, "_installed_daemon_version", lambda: "0.3.0")
164+
165+
_doctor.run()
166+
out = capsys.readouterr().out
167+
168+
assert "daemon code: 0.3.0" in out
169+
assert "STALE" not in out

ultan/_daemon.py

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,34 @@ def _pid_alive(pid: object) -> bool:
7373
return True
7474

7575

76+
def _read_daemon_state() -> dict[str, Any] | None:
77+
"""The daemon's lifecycle flag ``{phase, pid, since, version}`` (written
78+
between pid-acquire and exit), or ``None`` when absent/unreadable."""
79+
try:
80+
raw: Any = json.loads((_home() / "daemon.state").read_text(encoding="utf-8"))
81+
except (OSError, ValueError):
82+
return None
83+
return cast("dict[str, Any]", raw) if isinstance(raw, dict) else None
84+
85+
86+
def _installed_daemon_version() -> str | None:
87+
"""The agent-mem-daemon version installed in THIS venv right now, read live
88+
from on-disk metadata — so it reflects an ``uv tool install`` update even
89+
while an older daemon process keeps serving the previous code. ``None`` when
90+
the daemon dist isn't installed (a thin/uvx env) or metadata is unreadable.
91+
92+
Cold path only (session-start / doctor): reading dist metadata is stdlib but
93+
does I/O, so it must never land on the per-turn hook hot path."""
94+
from importlib.metadata import PackageNotFoundError, version # noqa: PLC0415 — cold path
95+
96+
try:
97+
return version(_DAEMON_ENTRYPOINT) # dist name == console-script name here
98+
except PackageNotFoundError:
99+
return None
100+
except Exception: # noqa: BLE001 — a broken install must not crash the caller
101+
return None
102+
103+
76104
def status() -> str:
77105
"""Coarse daemon state for consumers that must phrase fallbacks honestly:
78106
``"ready"`` (socket answering), ``"warming"`` (daemon alive or freshly
@@ -82,12 +110,9 @@ def status() -> str:
82110
return "ready"
83111
home = _home()
84112
# The daemon's own lifecycle flag (written between pid-acquire and exit).
85-
try:
86-
raw: Any = json.loads((home / "daemon.state").read_text(encoding="utf-8"))
87-
if isinstance(raw, dict) and _pid_alive(cast("dict[str, Any]", raw).get("pid")):
88-
return "warming"
89-
except (OSError, ValueError):
90-
pass
113+
state = _read_daemon_state()
114+
if state is not None and _pid_alive(state.get("pid")):
115+
return "warming"
91116
# A spawn was attempted moments ago (pre-flag window, or older daemon).
92117
try:
93118
if (time.time() - (home / ".daemon-spawn-attempt").stat().st_mtime) < 300.0:
@@ -97,6 +122,68 @@ def status() -> str:
97122
return "down"
98123

99124

125+
def _stop_daemon(pid: int, *, timeout_s: float = 3.0) -> None:
126+
"""Stop a running daemon: SIGTERM, wait briefly for a clean exit, SIGKILL as
127+
a last resort. The daemon installs a SIGTERM handler that flips its stop
128+
event and shuts down gracefully (releasing the socket), so the common case is
129+
a clean stop well under ``timeout_s``."""
130+
import signal # noqa: PLC0415 — cold path (session-start restart only)
131+
132+
try:
133+
os.kill(pid, signal.SIGTERM)
134+
except OSError:
135+
return # already gone
136+
deadline = time.monotonic() + timeout_s
137+
while _pid_alive(pid) and time.monotonic() < deadline:
138+
time.sleep(0.1)
139+
if _pid_alive(pid):
140+
try:
141+
os.kill(pid, signal.SIGKILL)
142+
except OSError:
143+
pass
144+
145+
146+
def restart_if_stale() -> bool:
147+
"""If a daemon is running OLDER code than what's now installed in the venv,
148+
stop it so a fresh daemon starts on current code. Returns True when a stale
149+
daemon was stopped — the caller's :func:`ensure_running` then spawns the new
150+
one (the stopped daemon no longer answers the socket).
151+
152+
SESSION-START PATH ONLY — never the per-turn hook hot path: it reads package
153+
metadata (cold-cheap, hot-needless). The running version is what the daemon
154+
stamped into ``daemon.state`` at ITS startup; the installed version is read
155+
live, so an ``uv tool install`` update is detected even though the old
156+
process keeps serving the previous code.
157+
158+
Anchored on the INSTALLED version: if we can't read it (a thin/uvx env with
159+
no daemon dist, or unreadable metadata) we never restart — there's nothing to
160+
compare against. Given a readable installed version, a daemon whose recorded
161+
version differs is stale; so is one with NO recorded version, because that is
162+
a legacy daemon from before this stamp existed (a current daemon always
163+
stamps a version when the metadata it reads is itself readable). Restarting
164+
that legacy daemon is the whole point — it's exactly the process left on old
165+
code that this feature exists to replace."""
166+
state = _read_daemon_state()
167+
if not state:
168+
return False
169+
pid = state.get("pid")
170+
if not _pid_alive(pid):
171+
return False
172+
installed = _installed_daemon_version()
173+
if not installed:
174+
return False # can't tell what's installed — never restart blindly
175+
if state.get("version") == installed:
176+
return False # up to date
177+
_stop_daemon(cast("int", pid))
178+
# Clear the spawn backoff so ensure_running() respawns immediately rather
179+
# than throttling this intentional restart as if it were a crash loop.
180+
try:
181+
(_home() / ".daemon-spawn-attempt").unlink()
182+
except OSError:
183+
pass
184+
return True
185+
186+
100187
def run_foreground(extra_args: list[str]) -> int:
101188
"""`ultan daemon` — run the installed agent-mem-daemon in the foreground."""
102189
daemon = _daemon_bin()

ultan/_doctor.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,19 @@ def _report_daemon(home: Path) -> tuple[bool, bool]:
9090
if phase is not None and alive and phase.get("pid") == pid:
9191
print(f"daemon phase: {phase.get('phase')} (since {phase.get('since')})")
9292

93+
# Running daemon code vs what's installed now — surfaces a daemon left on old
94+
# code after an update (it restarts on the next session; see _daemon).
95+
running_ver = phase.get("version") if phase is not None else None
96+
installed_ver = _daemon._installed_daemon_version() # pyright: ignore[reportPrivateUsage]
97+
if alive and running_ver:
98+
if installed_ver and running_ver != installed_ver:
99+
print(
100+
f"daemon code: {running_ver} — STALE (installed {installed_ver}); "
101+
"restarts on the next session"
102+
)
103+
else:
104+
print(f"daemon code: {running_ver}")
105+
93106
socket_ok = _daemon._socket_answering() # pyright: ignore[reportPrivateUsage] # intra-package
94107
if socket_ok:
95108
print("priming socket: answering")

ultan/_hooks.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ def _user_prompt_submit(payload: dict[str, Any]) -> int:
8787

8888

8989
def _session_start(payload: dict[str, Any]) -> int:
90+
# If a daemon is still running OLDER code than what's now installed (e.g.
91+
# right after an `ultan` tool update), stop it so a fresh one starts on the
92+
# current code — a long-running process keeps the code it loaded at spawn.
93+
# Session-start, not the per-turn hot path, so the version check is fine here.
94+
_daemon.restart_if_stale()
9095
# Warm the daemon at session start so the first prompt is already hot.
9196
_daemon.ensure_running()
9297
# Capture the session boundary (legacy parity: src/hooks/session_start.py

0 commit comments

Comments
 (0)