Skip to content

Commit ebf4d7f

Browse files
wlritchiclaude
andcommitted
feat(notifications): relay-side identity bookkeeping + SUPERSEDED verdict
The relay persists its last-assumed identity (name, generation, session id) to NOTIFICATIONS_IDENTITY_FILE (default ~/.claude/agent-identity.json, pod-lifecycle storage) on every effective registration. Two detections: - Normal succession: registering a name whose stored record shows a lower generation under a different session notes the succession in the reply (the successor already inherited the name's threads and backlog via name-keyed membership). - Stand-down: a register that fails "already taken" while the stored identity proves THIS session held the name returns an explicit SUPERSEDED verdict telling the agent not to retry and how to hand off. Durable across daemon restarts, unlike the in-memory heir notice. Bookkeeping never fails a registration; the file is written atomically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1f321b9 commit ebf4d7f

3 files changed

Lines changed: 172 additions & 3 deletions

File tree

‎notifications/mcp/notifications-server.py‎

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,47 @@ def _format_last_seen(last_seen: float) -> str:
749749
return f"~{int(round(hours / 24.0))}d ago"
750750

751751

752+
def _identity_path() -> Path:
753+
"""Where this host persists its last-assumed agent identity (name, generation,
754+
session id) — pod-lifecycle state that survives daemon restarts, so a session
755+
can durably tell 'my name moved on without me' (SUPERSEDED) from 'the daemon
756+
forgot me' (docs/specs/2026-07-09 slice b, relay side)."""
757+
override = os.environ.get("NOTIFICATIONS_IDENTITY_FILE")
758+
if override:
759+
return Path(override)
760+
return Path.home() / ".claude" / "agent-identity.json"
761+
762+
763+
def _load_identity() -> dict | None:
764+
try:
765+
data = json.loads(_identity_path().read_text())
766+
except (OSError, ValueError):
767+
return None
768+
return data if isinstance(data, dict) else None
769+
770+
771+
def _store_identity(name: str, generation: int, session_id: str) -> None:
772+
path = _identity_path()
773+
try:
774+
path.parent.mkdir(parents=True, exist_ok=True)
775+
tmp = path.with_name(path.name + ".tmp")
776+
tmp.write_text(
777+
json.dumps(
778+
{
779+
"name": name,
780+
"generation": generation,
781+
"session_id": session_id,
782+
"updated_at": time.time(),
783+
}
784+
)
785+
)
786+
tmp.replace(path)
787+
except OSError as exc: # bookkeeping must never fail a registration
788+
print(
789+
f"notifications: could not persist agent identity: {exc}", file=sys.stderr
790+
)
791+
792+
752793
@mcp.tool()
753794
async def register_agent(
754795
name: str,
@@ -821,12 +862,64 @@ async def register_agent(
821862
if isinstance(reply, str):
822863
return reply
823864
if reply.get("type") == wsproto.ERROR:
824-
return f"Could not register as '{name}': {reply.get('error')}"
865+
error = str(reply.get("error") or "")
866+
stored = _load_identity()
867+
if (
868+
"already taken" in error
869+
and stored is not None
870+
and stored.get("name") == name
871+
and stored.get("session_id") == session_id
872+
):
873+
# The durable half of supersession detection: our own bookkeeping says
874+
# this session held the name, but another session holds it now. The
875+
# daemon's heir notice covers the fresh case; this file survives daemon
876+
# restarts. Tell the agent to stand down rather than retry-register.
877+
held_gen = stored.get("generation")
878+
print(
879+
f"notifications: SUPERSEDED — '{name}' is held by another session; "
880+
f"this session held gen {held_gen}",
881+
file=sys.stderr,
882+
)
883+
return (
884+
f"SUPERSEDED: this session previously held '{name}' (gen {held_gen}), "
885+
"but the name is now held by another live session. Do not retry — "
886+
"your successor has inherited the name's threads and messages. To "
887+
f"hand off in-flight work, register under a different name and dm "
888+
f"'{name}'."
889+
)
890+
return f"Could not register as '{name}': {error}"
825891
agent = reply.get("agent") or {}
826892
threshold = agent.get("default_threshold", "direct")
893+
reg_name = str(agent.get("name") or name)
894+
generation = int(agent.get("generation") or 1)
895+
stored = _load_identity()
896+
succession_note = ""
897+
if (
898+
stored is not None
899+
and stored.get("name") == reg_name
900+
and stored.get("session_id") != session_id
901+
and int(stored.get("generation") or 1) < generation
902+
):
903+
# Normal succession on this host: a predecessor session registered here
904+
# before us. Membership is name-keyed, so its threads and any unsurfaced
905+
# backlog are already ours.
906+
succession_note = (
907+
f" You are gen {generation}, succeeding a previous session of this name."
908+
)
909+
print(
910+
f"notifications: assumed '{reg_name}' gen {generation}, superseding "
911+
f"session {stored.get('session_id')}",
912+
file=sys.stderr,
913+
)
914+
if stored is None or (
915+
stored.get("name"),
916+
stored.get("generation"),
917+
stored.get("session_id"),
918+
) != (reg_name, generation, session_id):
919+
_store_identity(reg_name, generation, session_id)
827920
return (
828-
f"Registered as '{agent.get('name', name)}' (wake threshold: {threshold}). "
829-
"Other agents can find you with list_agents."
921+
f"Registered as '{reg_name}' (wake threshold: {threshold})."
922+
f"{succession_note} Other agents can find you with list_agents."
830923
)
831924

832925

‎notifications/tests/_harness.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,12 @@ def relay_env(
123123
env["NOTIFICATIONS_MCP_LOG_CACHE_DIR"] = str(xdg_dir)
124124
if project_dir is not None:
125125
env["CLAUDE_PROJECT_DIR"] = project_dir
126+
# Per-session identity file (in prod: one per pod at ~/.claude/). Keyed by
127+
# session id so each harness session models its own pod's persistent storage,
128+
# and a re-opened session with the same id sees its earlier registrations.
129+
env["NOTIFICATIONS_IDENTITY_FILE"] = str(
130+
Path(xdg_dir) / f"agent-identity-{session_id}.json"
131+
)
126132
return env
127133

128134

‎notifications/tests/test_e2e_agents.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,3 +573,73 @@ async def scenario():
573573
assert "'worker' is now gen 2" in notice.params["content"]
574574

575575
anyio.run(scenario)
576+
577+
578+
def test_superseded_session_told_to_stand_down(tmp_path):
579+
"""Relay-side supersession detection (docs/specs/2026-07-09, relay half): the
580+
relay persists (name, generation, session_id) on successful registration; when
581+
a register later fails with 'already taken' and the stored identity proves THIS
582+
session held the name, the agent gets an explicit SUPERSEDED verdict instead of
583+
a generic failure — durable across daemon restarts, unlike the heir notice."""
584+
store, xdg = tmp_path / "store", tmp_path / "xdg"
585+
store.mkdir()
586+
xdg.mkdir()
587+
ws = h.free_port()
588+
589+
with h.daemon_process(h.daemon_env(ws, store, settle="0")):
590+
591+
async def scenario():
592+
async with h.agent_session(tmp_path, ws, store, xdg, "sid-old") as (
593+
read_a,
594+
write_a,
595+
):
596+
text, _ = await h.mcp_call(
597+
read_a,
598+
write_a,
599+
2,
600+
"register_agent",
601+
{"name": "worker", "reclaim_key": "pod-1"},
602+
)
603+
assert "Registered as 'worker'" in text
604+
# sid-old's identity file now records it held 'worker' gen 1.
605+
import json as _json
606+
607+
identity = _json.loads((xdg / "agent-identity-sid-old.json").read_text())
608+
assert identity["name"] == "worker"
609+
assert identity["generation"] == 1
610+
assert identity["session_id"] == "sid-old"
611+
612+
async with h.agent_session(tmp_path, ws, store, xdg, "sid-new") as (
613+
read_b,
614+
write_b,
615+
):
616+
text, next_id = await _list_until(
617+
read_b, write_b, 2, lambda t: "offline" in t
618+
)
619+
text, _ = await h.mcp_call(
620+
read_b,
621+
write_b,
622+
next_id,
623+
"register_agent",
624+
{"name": "worker", "reclaim_key": "pod-1"},
625+
)
626+
assert "Registered as 'worker'" in text
627+
628+
# The displaced session comes back while the successor is LIVE and
629+
# tries to retake its name: it must get the stand-down verdict.
630+
async with h.agent_session(tmp_path, ws, store, xdg, "sid-old") as (
631+
read_back,
632+
write_back,
633+
):
634+
text, _ = await h.mcp_call(
635+
read_back,
636+
write_back,
637+
2,
638+
"register_agent",
639+
{"name": "worker", "reclaim_key": "pod-1"},
640+
)
641+
assert "SUPERSEDED" in text
642+
assert "gen 1" in text
643+
assert "dm 'worker'" in text
644+
645+
anyio.run(scenario)

0 commit comments

Comments
 (0)