Skip to content

Commit 702b3c0

Browse files
nickrociclaude
andcommitted
feat(startup-ux): warming note on priming + recall fallbacks
The fallback already worked during warmup — silently. Now every consumer phrases it honestly via the new _daemon.status() ('ready'/'warming'/'down', one socket probe + the lifecycle flag + spawn-stamp breadcrumbs): - Hook priming appends "*daemon still warming — lexical-fallback results; ranked recall returns within a minute or two*" to the bullets. - MCP ultan_recall prefixes the same note (a warming "no match" may just mean the ranked index isn't serving yet). - A stale state flag with a dead pid never claims warming. Tests: 5 status-classification cases (incl. a real AF_UNIX listener for 'ready') + note-present/note-absent on the hook path. 30 root tests green in both the full dev venv and the simulated thin CI env. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e12c37e commit 702b3c0

4 files changed

Lines changed: 160 additions & 2 deletions

File tree

tests/test_warming_status.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""During daemon warmup the fallback must keep working WITH a note — the
2+
agent should know priming/recall results are provisional, and `_daemon.status`
3+
is the single source of that truth for every consumer."""
4+
5+
import json
6+
import os
7+
import socket
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
from ultan import _daemon, _hooks
13+
14+
15+
@pytest.fixture()
16+
def home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
17+
h = tmp_path / "agent-mem"
18+
h.mkdir()
19+
monkeypatch.setenv("AGENT_MEM_HOME", str(h))
20+
return h
21+
22+
23+
# ── _daemon.status() ──────────────────────────────────────────────────
24+
25+
26+
def test_status_down_with_no_signals(home: Path) -> None:
27+
assert _daemon.status() == "down"
28+
29+
30+
def test_status_warming_from_state_flag_with_live_pid(home: Path) -> None:
31+
(home / "daemon.state").write_text(
32+
json.dumps({"phase": "warming", "pid": os.getpid(), "since": "x"}),
33+
encoding="utf-8",
34+
)
35+
assert _daemon.status() == "warming"
36+
37+
38+
def test_status_ignores_stale_flag_with_dead_pid(home: Path) -> None:
39+
(home / "daemon.state").write_text(
40+
json.dumps({"phase": "warming", "pid": 99999999, "since": "x"}),
41+
encoding="utf-8",
42+
)
43+
assert _daemon.status() == "down"
44+
45+
46+
def test_status_warming_from_fresh_spawn_stamp(home: Path) -> None:
47+
(home / ".daemon-spawn-attempt").write_text("x", encoding="utf-8")
48+
assert _daemon.status() == "warming"
49+
50+
51+
def test_status_ready_when_socket_answers(monkeypatch: pytest.MonkeyPatch) -> None:
52+
# AF_UNIX paths cap at ~104 bytes on macOS; pytest tmp dirs are too deep,
53+
# so bind in a short mkdtemp under /tmp instead.
54+
import shutil
55+
import tempfile
56+
57+
short_home = Path(tempfile.mkdtemp(prefix="ultan-t-", dir="/tmp"))
58+
monkeypatch.setenv("AGENT_MEM_HOME", str(short_home))
59+
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
60+
try:
61+
srv.bind(str(short_home / "priming.sock"))
62+
srv.listen(1)
63+
assert _daemon.status() == "ready"
64+
finally:
65+
srv.close()
66+
shutil.rmtree(short_home, ignore_errors=True)
67+
68+
69+
# ── hook priming carries the warming note ─────────────────────────────
70+
71+
72+
def _dispatch_priming(
73+
monkeypatch: pytest.MonkeyPatch,
74+
capsys: pytest.CaptureFixture[str],
75+
status: str,
76+
) -> str:
77+
monkeypatch.setattr(_hooks._daemon, "ensure_running", lambda: True)
78+
monkeypatch.setattr(_hooks._daemon, "status", lambda: status)
79+
monkeypatch.setattr(_hooks._priming, "get_priming", lambda *a, **k: "## bullets\n")
80+
rc = _hooks.dispatch("user-prompt-submit", {"session_id": "s", "prompt": "q"})
81+
assert rc == 0
82+
out = capsys.readouterr().out
83+
data = json.loads(out)
84+
context: str = data["hookSpecificOutput"]["additionalContext"]
85+
return context
86+
87+
88+
def test_priming_notes_lexical_fallback_while_warming(
89+
home: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
90+
) -> None:
91+
context = _dispatch_priming(monkeypatch, capsys, "warming")
92+
assert "## bullets" in context # the fallback still delivers
93+
assert "warming up" in context # ...but says so
94+
95+
96+
def test_priming_carries_no_note_when_ready(
97+
home: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
98+
) -> None:
99+
context = _dispatch_priming(monkeypatch, capsys, "ready")
100+
assert "## bullets" in context
101+
assert "warming" not in context

ultan/_daemon.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
from __future__ import annotations
1717

1818
import fcntl
19+
import json
1920
import os
2021
import socket
2122
import subprocess
2223
import sys
2324
import time
2425
from pathlib import Path
26+
from typing import Any, cast
2527

2628
# The daemon console script ships in the same venv as this `ultan` install, so
2729
# it lives next to the running interpreter (sys.executable).
@@ -57,6 +59,44 @@ def _socket_answering() -> bool:
5759
return True
5860

5961

62+
def _pid_alive(pid: object) -> bool:
63+
if not isinstance(pid, int) or pid <= 0:
64+
return False
65+
try:
66+
os.kill(pid, 0)
67+
except ProcessLookupError:
68+
return False
69+
except PermissionError:
70+
return True # exists, owned by someone else
71+
except OSError:
72+
return False
73+
return True
74+
75+
76+
def status() -> str:
77+
"""Coarse daemon state for consumers that must phrase fallbacks honestly:
78+
``"ready"`` (socket answering), ``"warming"`` (daemon alive or freshly
79+
spawned but not serving yet), or ``"down"``. One socket probe plus cheap
80+
file stats — fine on the hook hot path."""
81+
if _socket_answering():
82+
return "ready"
83+
home = _home()
84+
# 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
91+
# A spawn was attempted moments ago (pre-flag window, or older daemon).
92+
try:
93+
if (time.time() - (home / ".daemon-spawn-attempt").stat().st_mtime) < 300.0:
94+
return "warming"
95+
except OSError:
96+
pass
97+
return "down"
98+
99+
60100
def run_foreground(extra_args: list[str]) -> int:
61101
"""`ultan daemon` — run the installed agent-mem-daemon in the foreground."""
62102
daemon = _daemon_bin()

ultan/_hooks.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ def _user_prompt_submit(payload: dict[str, Any]) -> int:
7373
prompt if isinstance(prompt, str) else "",
7474
session_id=session_id if isinstance(session_id, str) else None,
7575
)
76+
# Fallback honesty: while the daemon warms (first start loads models for
77+
# minutes), priming comes from the crude lexical scan. Say so, so the
78+
# agent treats the bullets as provisional rather than the library's best.
79+
if md and _daemon.status() == "warming":
80+
md += (
81+
"\n*Ultan's daemon is still warming up — the bullets above are "
82+
"lexical-fallback results; full ranked recall returns within a "
83+
"minute or two.*\n"
84+
)
7685
_emit_additional_context("UserPromptSubmit", md)
7786
return 0
7887

ultan/_mcp.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def build_server() -> "FastMCP":
2525
daemon spawn, no I/O — so it's unit-testable."""
2626
from mcp.server.fastmcp import FastMCP
2727

28-
from . import _priming
28+
from . import _daemon, _priming
2929

3030
server = FastMCP("ultan")
3131

@@ -34,7 +34,15 @@ def ultan_recall(query: str) -> str: # pyright: ignore[reportUnusedFunction] #
3434
"""Recall relevant lessons, preferences, and conventions from the
3535
user's Ultan memory library for the given query. Returns markdown
3636
wikilink bullets (open them with the Read tool) or a no-match note."""
37-
return _priming.get_priming(query, k=5) or "(no relevant Ultan memory for this query)"
37+
result = _priming.get_priming(query, k=5) or "(no relevant Ultan memory for this query)"
38+
# Fallback honesty: during warmup results come from the lexical scan,
39+
# and a "no match" may just mean the ranked index isn't serving yet.
40+
if _daemon.status() == "warming":
41+
return (
42+
"*(Ultan daemon is warming up — lexical-fallback results; "
43+
"retry in a minute or two for full ranked recall.)*\n\n" + result
44+
)
45+
return result
3846

3947
return server
4048

0 commit comments

Comments
 (0)