Skip to content

Commit c6a0bae

Browse files
authored
Merge pull request #25 from nickroci/fix/startup-ux
Startup UX: lifecycle flag + warming-aware skill/doctor
2 parents c3a0c9e + 869f427 commit c6a0bae

11 files changed

Lines changed: 466 additions & 74 deletions

File tree

README.md

Lines changed: 66 additions & 66 deletions
Large diffs are not rendered by default.

daemon/agent_mem_daemon/__main__.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from .ingest import DEFAULT_POLL_INTERVAL, JsonlTailer
3030
from .logging_setup import configure as configure_logging
3131
from .paths import (
32+
daemon_state_path,
3233
ensure_home,
3334
events_path,
3435
knowledge_dir,
@@ -114,6 +115,42 @@ def release_pid_file(path: Path) -> None:
114115
pass
115116

116117

118+
def write_daemon_state(phase: str) -> None:
119+
"""Publish the lifecycle flag other processes read (see
120+
``paths.daemon_state_path``). Best-effort — a failed write must never
121+
block startup; consumers fall back to pid/socket inference."""
122+
import json # noqa: PLC0415 — tiny, cold path
123+
from datetime import datetime, timezone # noqa: PLC0415
124+
125+
try:
126+
daemon_state_path().write_text(
127+
json.dumps(
128+
{
129+
"phase": phase,
130+
"pid": os.getpid(),
131+
"since": datetime.now(timezone.utc).isoformat(timespec="seconds"),
132+
}
133+
),
134+
encoding="utf-8",
135+
)
136+
except OSError:
137+
pass
138+
139+
140+
def clear_daemon_state() -> None:
141+
"""Best-effort remove of the lifecycle flag. Never raises. Only clears a
142+
flag this process wrote — a duplicate daemon exiting must not erase the
143+
live daemon's state."""
144+
import json # noqa: PLC0415
145+
146+
try:
147+
raw = json.loads(daemon_state_path().read_text(encoding="utf-8"))
148+
if raw.get("pid") == os.getpid():
149+
daemon_state_path().unlink()
150+
except (OSError, ValueError):
151+
pass
152+
153+
117154
# ---- args -----------------------------------------------------------
118155

119156

@@ -341,6 +378,9 @@ def run(args: argparse.Namespace) -> int:
341378
# files on its way out. acquire_pid_file writes only to raw stderr, which
342379
# the spawner tees to daemon-spawn.log (see ultan/_daemon.py).
343380
acquire_pid_file(pidfile)
381+
# We own the lifecycle now — flag "warming" so doctor/the search skill
382+
# can say "starting up" instead of "broken" during the model load.
383+
write_daemon_state("warming")
344384

345385
log = configure_logging(
346386
logfile,
@@ -405,6 +445,9 @@ def run(args: argparse.Namespace) -> int:
405445
sched.start()
406446
tailer_thread.start()
407447
rpc_thread.start()
448+
# Socket is being served — flip the lifecycle flag to ready.
449+
write_daemon_state("ready")
450+
log.info("daemon ready: priming socket serving")
408451
try:
409452
while not stop_event.is_set():
410453
stop_event.wait(timeout=1.0)
@@ -435,6 +478,7 @@ def run(args: argparse.Namespace) -> int:
435478
sched.stats.queue_high_water,
436479
sched.stats.scholar_skipped_backpressure,
437480
)
481+
clear_daemon_state()
438482
release_pid_file(pidfile)
439483

440484
return 0

daemon/agent_mem_daemon/paths.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ def pid_path() -> Path:
3838
return home() / "daemon.pid"
3939

4040

41+
def daemon_state_path() -> Path:
42+
"""Lifecycle flag the daemon maintains for OTHER processes to read:
43+
``{"phase": "warming"|"ready", "pid": N, "since": <iso>}``. Written right
44+
after PID acquisition (warming), updated when the priming socket is
45+
serving (ready), removed on shutdown. Consumers — `ultan doctor` and the
46+
ultan-search skill — use it to say "starting up, retry shortly" instead
47+
of "broken" while the first start loads models for minutes."""
48+
return home() / "daemon.state"
49+
50+
4151
def offset_state_path() -> Path:
4252
"""Where the JSONL tailer persists its last-read offset across daemon
4353
restarts. See ``ingest._load_offset_state`` / ``_save_offset_state``.

daemon/tests/test_main.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from __future__ import annotations
1717

18+
import json
1819
import logging
1920
import os
2021
import signal
@@ -343,3 +344,39 @@ def _killer():
343344
root = logging.getLogger()
344345
for h in list(root.handlers):
345346
root.removeHandler(h)
347+
348+
349+
# ── daemon.state lifecycle flag ───────────────────────────────────────
350+
351+
352+
def test_write_daemon_state_publishes_phase(
353+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
354+
) -> None:
355+
monkeypatch.setenv("AGENT_MEM_HOME", str(tmp_path))
356+
daemon_main.write_daemon_state("warming")
357+
raw = json.loads((tmp_path / "daemon.state").read_text(encoding="utf-8"))
358+
assert raw["phase"] == "warming"
359+
assert raw["pid"] == os.getpid()
360+
assert raw["since"]
361+
362+
363+
def test_clear_daemon_state_removes_own_flag(
364+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
365+
) -> None:
366+
monkeypatch.setenv("AGENT_MEM_HOME", str(tmp_path))
367+
daemon_main.write_daemon_state("ready")
368+
daemon_main.clear_daemon_state()
369+
assert not (tmp_path / "daemon.state").exists()
370+
371+
372+
def test_clear_daemon_state_leaves_other_daemons_flag(
373+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
374+
) -> None:
375+
"""A rejected duplicate exiting must not erase the live daemon's flag."""
376+
monkeypatch.setenv("AGENT_MEM_HOME", str(tmp_path))
377+
(tmp_path / "daemon.state").write_text(
378+
json.dumps({"phase": "ready", "pid": os.getpid() + 99999, "since": "x"}),
379+
encoding="utf-8",
380+
)
381+
daemon_main.clear_daemon_state()
382+
assert (tmp_path / "daemon.state").exists()

skills/ultan-search/search.py

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,77 @@
1313
import socket
1414
import struct
1515
import sys
16+
import time
1617
from pathlib import Path
1718

1819
_HOME = Path(os.environ.get("AGENT_MEM_HOME") or (Path.home() / ".agent-mem"))
1920
SOCKET_PATH = _HOME / "priming.sock"
21+
# Default plugin data dir ("ultan" plugin @ "ultan" marketplace) — where the
22+
# background installer leaves its lock/pid while provisioning.
23+
_PLUGIN_DATA = Path(
24+
os.environ.get("CLAUDE_PLUGIN_DATA")
25+
or (Path.home() / ".claude" / "plugins" / "data" / "ultan-ultan")
26+
)
2027
PATH_CHARS_RE = re.compile(r"^[a-zA-Z0-9_./-]+$")
2128
RECV_TIMEOUT_S = 5.0
2229

30+
_MSG_WARMING = (
31+
"# Ultan is starting up — not broken\n\n"
32+
"The daemon is warming (first start loads the retrieval models; allow 1-3\n"
33+
"minutes). Retry this search shortly. Live status: run `ultan doctor`.\n\n"
34+
"_Priming still works during warmup via the lexical fallback, so the\n"
35+
"session is not memory-blind meanwhile._\n"
36+
)
37+
_MSG_INSTALLING = (
38+
"# Ultan is still installing — not broken\n\n"
39+
"The plugin's background installer is provisioning the runtime (torch +\n"
40+
"models; minutes on a cold cache). Retry once it finishes. Progress:\n"
41+
"run `ultan doctor`.\n"
42+
)
43+
_MSG_DOWN = (
44+
"# Ultan daemon not running\n\n"
45+
f"No socket at `{SOCKET_PATH}` and no startup in progress. With the\n"
46+
"plugin installed the daemon lazy-starts on the next prompt — send any\n"
47+
"message and retry. Details: run `ultan doctor`. (From a source\n"
48+
"checkout: `uv run agent-mem-daemon -v`.)\n"
49+
)
50+
51+
52+
def _pid_alive(pid: object) -> bool:
53+
try:
54+
os.kill(int(pid), 0) # type: ignore[arg-type]
55+
except (TypeError, ValueError, ProcessLookupError):
56+
return False
57+
except PermissionError:
58+
return True
59+
except OSError:
60+
return False
61+
return True
62+
63+
64+
def _startup_message() -> str | None:
65+
"""A friendly explanation when the socket isn't answering, or None when
66+
the daemon is genuinely down (not installing, not warming). Checks the
67+
daemon's lifecycle flag first, then spawn/install breadcrumbs."""
68+
# 1. The daemon's own phase flag (written between pid-acquire and exit).
69+
try:
70+
state = json.loads((_HOME / "daemon.state").read_text(encoding="utf-8"))
71+
if _pid_alive(state.get("pid")):
72+
return _MSG_WARMING # alive but socket not serving yet (or restarting)
73+
except (OSError, ValueError):
74+
pass
75+
# 2. A spawn was attempted moments ago (pre-flag window, or older daemon).
76+
try:
77+
age = time.time() - (_HOME / ".daemon-spawn-attempt").stat().st_mtime
78+
if age < 300:
79+
return _MSG_WARMING
80+
except OSError:
81+
pass
82+
# 3. The plugin's background install is still running.
83+
if (_PLUGIN_DATA / ".install.pid").exists() or (_PLUGIN_DATA / ".install.lock").exists():
84+
return _MSG_INSTALLING
85+
return None
86+
2387

2488
def _looks_like_path(s: str) -> bool:
2589
s = s.strip()
@@ -119,12 +183,7 @@ def main() -> int:
119183
mode = "search"
120184

121185
if not SOCKET_PATH.exists():
122-
print(
123-
"# Ultan daemon not running\n\n"
124-
f"Socket missing at `{SOCKET_PATH}`. Start the daemon:\n\n"
125-
"```\nuv run agent-mem-daemon -v\n```\n",
126-
file=sys.stderr,
127-
)
186+
print(_startup_message() or _MSG_DOWN, file=sys.stderr)
128187
return 1
129188

130189
try:
@@ -134,6 +193,11 @@ def main() -> int:
134193
else:
135194
resp = _send_request({"op": "bm25_search", "query": arg, "k": 8})
136195
print(_render_search(resp, arg))
196+
except (ConnectionError, OSError):
197+
# Socket file present but not answering — stale socket or a daemon
198+
# mid-restart. Same friendly triage as the missing-socket path.
199+
print(_startup_message() or _MSG_DOWN, file=sys.stderr)
200+
return 1
137201
except Exception as e:
138202
print(f"# Ultan skill failed\n\nError: {e!r}\n", file=sys.stderr)
139203
return 1

tests/test_search_skill_status.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""The ultan-search skill must triage a missing socket honestly: "starting
2+
up" / "still installing" / "down" — never a bare failure with from-source
3+
advice while the first start is loading models for minutes."""
4+
5+
import importlib.util
6+
import json
7+
import os
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
_SKILL = Path(__file__).resolve().parent.parent / "skills" / "ultan-search" / "search.py"
13+
14+
15+
@pytest.fixture()
16+
def skill(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
17+
spec = importlib.util.spec_from_file_location("ultan_search_skill", _SKILL)
18+
assert spec is not None and spec.loader is not None
19+
mod = importlib.util.module_from_spec(spec)
20+
spec.loader.exec_module(mod)
21+
home = tmp_path / "agent-mem"
22+
home.mkdir()
23+
data = tmp_path / "plugin-data"
24+
data.mkdir()
25+
monkeypatch.setattr(mod, "_HOME", home)
26+
monkeypatch.setattr(mod, "_PLUGIN_DATA", data)
27+
return mod
28+
29+
30+
def test_warming_when_state_flag_pid_alive(skill) -> None:
31+
(skill._HOME / "daemon.state").write_text(
32+
json.dumps({"phase": "warming", "pid": os.getpid(), "since": "x"}),
33+
encoding="utf-8",
34+
)
35+
msg = skill._startup_message()
36+
assert msg is not None
37+
assert "starting up" in msg
38+
assert "ultan doctor" in msg
39+
40+
41+
def test_warming_when_spawn_stamp_is_fresh(skill) -> None:
42+
(skill._HOME / ".daemon-spawn-attempt").write_text("x", encoding="utf-8")
43+
msg = skill._startup_message()
44+
assert msg is not None
45+
assert "starting up" in msg
46+
47+
48+
def test_installing_when_install_lock_present(skill) -> None:
49+
(skill._PLUGIN_DATA / ".install.lock").write_text("token", encoding="utf-8")
50+
msg = skill._startup_message()
51+
assert msg is not None
52+
assert "still installing" in msg
53+
54+
55+
def test_none_when_no_startup_signals(skill) -> None:
56+
assert skill._startup_message() is None
57+
58+
59+
def test_dead_state_flag_does_not_report_warming(skill) -> None:
60+
"""A flag left by a crashed daemon (dead pid) must not claim warming."""
61+
(skill._HOME / "daemon.state").write_text(
62+
json.dumps({"phase": "warming", "pid": 99999999, "since": "x"}),
63+
encoding="utf-8",
64+
)
65+
assert skill._startup_message() is None

0 commit comments

Comments
 (0)