From f428ee0468802e5f12c44c10a76ff04e6771aab1 Mon Sep 17 00:00:00 2001 From: istos Date: Fri, 31 Jul 2026 16:35:19 +0200 Subject: [PATCH] sessions: persist who a session was, so a replayed run is not "You" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "You" was the else-branch of session_label: anything the board could not attribute to an agent it attributed to the person. Every session read back from disk was one of those, because the agent id lived only in the session registry and never reached the persisted events — so past agent runs came back wearing the human's label, carrying their own closing reports underneath it. Identity is now a small whole file beside each event log (state/sessions/.who.json): agent id, the agent's name, the model it rode, and the task. A file rather than a key on the events, because the logs are append-only JSONL whose first line every reader takes for an event — and because the name and the model are nowhere in the stream, so this is the only thing a restart can read them back from. It is rewritten only when what the board knows changes, which also covers an agent id that arrives on a later event. load_disk_sessions() reads it back, and the label now has three registers instead of two: the agent's name (persisted, so a restart no longer costs it), "You" only for a session positively recorded as carrying no agent, and a neutral "Session · " for a log written before any of this was recorded. Old logs are not retro-attributed in either direction. agentFor() in board.html falls back to the persisted identity when this board no longer holds the live record, so a replayed agent session wears its model chip from what was written rather than from what happens to be in memory. What depends on liveness (Hold, the worktree branch) finds nothing there and stays silent, as before. tests/test_session_identity.py drives the real ingest → persist → reload path and the page's own chip functions in node. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 11 +- manager/core/board.html | 14 +- manager/core/events.py | 63 ++++++- manager/core/state.py | 48 +++++ tests/test_session_identity.py | 316 +++++++++++++++++++++++++++++++++ 5 files changed, 446 insertions(+), 6 deletions(-) create mode 100644 tests/test_session_identity.py diff --git a/AGENTS.md b/AGENTS.md index 0f6f3b5..ddaf82f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -249,7 +249,16 @@ The board has three views (header switcher): can settle. - **Sessions** — a flight recorder per session: a chronological timeline of reads, edits, test runs, commits and card moves, with filters and expandable - output. Sessions persist to `.sessions/*.jsonl`, so past ones can be replayed. + output. Sessions persist to `local/state/sessions/.jsonl`, so past ones + can be replayed — and beside each log sits `.who.json`, who that + session was: the agent id, its name, the model it rode and its task. The + name and the model are nowhere in the event stream, so that file is the + only thing a restart can read them back from, and it is what keeps a + replayed agent run wearing its own name and model chip rather than the + person's label. Three labels, three states, and each says only what is + known: the agent's name, `You` for a session recorded as carrying no + agent, and a neutral `Session · ` for a log written before any of + this was recorded. `You` is never a guess. - **Focus** — a heads-up display for one session: the task it holds, its live TodoWrite plan, the project's configured definition-of-done checks (a `checks` file in `manager/local/` overriding the shipped default in diff --git a/manager/core/board.html b/manager/core/board.html index a97927f..7ad01c7 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -713,7 +713,19 @@ async function loadSession(sid) { } function sessionsOf(pred) { return (S.state?.sessions || []).filter(pred); } -function agentFor(sid) { return (S.state?.agents || []).find(a => a.session === sid); } +/* The run behind a session: the live launch record while this board still + holds it, else the identity persisted with the session itself. The two + answer different amounts — a replayed run has a name and a model, not a + process — so what depends on liveness (Hold, the worktree branch) simply + finds nothing on the second, and the chip finds what it needs on both. */ +function agentFor(sid) { + const live = (S.state?.agents || []).find(a => a.session === sid); + if (live) return live; + const meta = (S.state?.sessions || []).find(m => m.id === sid); + return meta && meta.agentId + ? { id: meta.agentId, name: meta.agentName, model: meta.agentModel, replayed: true } + : undefined; +} function agentOnTask(file) { return (S.state?.agents || []).find(a => a.task === file && a.status === 'running'); } diff --git a/manager/core/events.py b/manager/core/events.py index 35a5cea..70c651b 100644 --- a/manager/core/events.py +++ b/manager/core/events.py @@ -10,6 +10,11 @@ adapters/*/emit*) and POST the normalized schema here: Core sanitises, updates the session registry, persists a slim record per session, and pushes to connected browsers over SSE. It never interprets a vendor's tool vocabulary — that knowledge lives in the adapter. + +Beside each session's event log sits its identity — who the session +belonged to, written whole (see state.persist_identity). Events say what +happened; the identity says whose, and it is the only part a restart +cannot recover from the stream. """ from __future__ import annotations @@ -25,15 +30,35 @@ KINDS = {"session", "end", "idle", "edit", "read", "search", "command", "test", "check", "git", "plan", "subagent", "web", "report", "other"} +# In-memory copy of what each session's identity file already says, so the +# sidecar is rewritten only when what the board knows actually changes. +_WRITTEN: dict[str, dict] = {} + + def session_label(meta: dict) -> str: + """Who a session was — in three registers, because there are three + different states and only one of them is the person. + + An agent's name comes from the live launch record while this board + still holds it, and from the identity persisted with the session after + a restart; `Agent` (or `Review`) is the honest fallback when the id is + known but the name is not. `You` is said only of a session the board + positively knows carried no agent — every live one, and every replayed + one whose identity file records that. A log written before identities + were recorded is none of those: it is unknown, and says so rather than + claiming to have been you. + """ agent_id = meta.get("agentId") or "" if agent_id: record = state.AGENTS.get(agent_id) or {} task = meta.get("task") or "" num = NUMBER_RE.match(task) - who = record.get("name") or ("Review" if agent_id.startswith("review-") else "Agent") + who = (record.get("name") or meta.get("agentName") + or ("Review" if agent_id.startswith("review-") else "Agent")) return f"{who} · #{num.group(1)}" if num else who - return f"You · {meta['id'][:8]}" + if meta.get("known"): + return f"You · {meta['id'][:8]}" + return f"Session · {meta['id'][:8]}" def _txt(value, cap: int) -> str | None: @@ -63,31 +88,51 @@ def ingest_event(raw: dict) -> None: with state.LOCK: meta = state.SESSIONS.setdefault(sid, { "id": sid, "started": event["ts"], "count": 0, - "agentId": None, "task": None, "status": "active", + "agentId": None, "agentName": None, "agentModel": None, + "task": None, "status": "active", }) just_linked = False if agent_id: meta["agentId"] = agent_id record = state.AGENTS.get(agent_id) if record is not None: + # The name and the model exist nowhere but this record, and + # it may only have been registered after the child's first + # event — so they are taken every time, not just on linking. + meta["agentName"] = record.get("name") + meta["agentModel"] = record.get("model") just_linked = record["session"] is None record["session"] = sid task = task or record["task"] if task: meta["task"] = task + # An event reaching here is a session the board is watching live, so + # it knows what it is looking at: an agent when one identified + # itself, the person when none did. + meta["known"] = True meta["last"] = event["ts"] meta["lastSummary"] = event["summary"] meta["lastKind"] = kind meta["status"] = {"end": "ended", "idle": "idle"}.get(kind, "active") meta["label"] = session_label(meta) + identity = None if not event.get("running"): meta["count"] += 1 state.EVENTS.setdefault(sid, []).append(event) del state.EVENTS[sid][:-config.EVENTS_CAP] + identity = {"agentId": meta["agentId"], "name": meta["agentName"], + "model": meta["agentModel"], "task": meta["task"]} + if identity == _WRITTEN.get(sid): + identity = None + else: + _WRITTEN[sid] = identity meta_snapshot = dict(meta) if not event.get("running"): state.persist(f"{sid}.jsonl", event) + if identity is not None: + # written beside the log it belongs to, and only when it changed + state.persist_identity(sid, identity) if just_linked: # the agent's card can now show its live line instead of "warming up" state.broadcast({"type": "agents"}) @@ -108,9 +153,19 @@ def load_disk_sessions() -> None: last = json.loads(lines[-1]) except (OSError, json.JSONDecodeError, IndexError): continue + # Who it was, if it was recorded. Absent = a log from before + # identities were written; the label must not fill that gap in. + identity = state.read_identity(sid) + known = identity is not None + identity = identity or {} meta = { "id": sid, "started": first.get("ts"), "last": last.get("ts"), - "count": len(lines), "agentId": None, "task": None, + "count": len(lines), + "agentId": identity.get("agentId"), + "agentName": identity.get("name"), + "agentModel": identity.get("model"), + "task": identity.get("task"), + "known": known, "status": "ended", "lastSummary": last.get("summary"), "lastKind": last.get("kind"), } diff --git a/manager/core/state.py b/manager/core/state.py index ef4171e..fbb76a2 100644 --- a/manager/core/state.py +++ b/manager/core/state.py @@ -11,6 +11,7 @@ import json import queue import threading import time +from pathlib import Path import config @@ -30,6 +31,10 @@ serve_port = config.PORT # The last card archived through this board — the scope of the ⌘Z undo. LAST_ARCHIVED: dict | None = None +# A session's identity sidecar, beside its .jsonl event log. Not a +# `.jsonl` itself, so no reader that globs the event logs picks it up. +IDENTITY_SUFFIX = ".who.json" + def broadcast(payload: dict) -> None: msg = json.dumps(payload) @@ -51,6 +56,49 @@ def persist(name: str, record: dict) -> None: pass +def _session_file(name: str) -> Path | None: + if "/" in name or ".." in name: + return None + return config.SESSIONS_DIR / name + + +def persist_identity(sid: str, identity: dict) -> None: + """Who a session belonged to, written beside its event log. + + A whole small file of its own rather than a key on the events: the logs + are append-only JSONL whose first line every reader takes for an event, + and identity is a property of the session, not of anything that happened + inside it. The agent's *name* and *model* live only in board memory, so + this file is the only thing a restart can read them back from. + + Rewritten whenever what we know changes — an agent id that arrives on a + later event, a name that was not registered yet when the first event + landed. The file is written whole, so the last write is simply the truth. + """ + path = _session_file(f"{sid}{IDENTITY_SUFFIX}") + if path is None: + return + try: + config.SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(identity), encoding="utf-8") + except OSError: + pass + + +def read_identity(sid: str) -> dict | None: + """The persisted identity, or None when nothing was ever recorded — the + difference between "this session was the person" and "we do not know", + which is exactly what the label must not blur.""" + path = _session_file(f"{sid}{IDENTITY_SUFFIX}") + if path is None or not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + return None + return data if isinstance(data, dict) else None + + def record_board_event(event: dict) -> None: event["ts"] = time.time() with LOCK: diff --git a/tests/test_session_identity.py b/tests/test_session_identity.py new file mode 100644 index 0000000..2a1c36d --- /dev/null +++ b/tests/test_session_identity.py @@ -0,0 +1,316 @@ +"""A session remembers who it was (task 45). + +`You` used to be the label the board reached for when it could not +attribute a session — and every session read back from disk was one of +those, because the agent id lived only in memory. Past agent runs came +back wearing the person's name. + +So identity is persisted beside the event log, in a small whole file of +its own: the agent id, the agent's name and the model it rode (neither of +which is anywhere in the event stream), and the task. The label then has +three registers instead of two — the name, `You` for a session positively +known to have carried no agent, and a neutral `Session` for a log written +before any of this was recorded. + +These tests drive the real ingest → persist → reload path with the +sessions directory pointed at a temporary one; the browser half (a +replayed run wearing its model chip) is the page's own functions run in +node, as in test_model_chip. + + python3 -m unittest discover -s tests -v +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "manager" / "core")) + +import config # noqa: E402 +import events # noqa: E402 +import state # noqa: E402 + +BOARD = REPO / "manager" / "core" / "board.html" + +TASK = "45-a-past-agent-session-is-not-you.md" + + +class Sessions(unittest.TestCase): + """One temporary sessions directory; each test writes its own history.""" + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp(prefix="bench-identity-")).resolve() + self.addCleanup(shutil.rmtree, self.tmp, True) + self.addCleanup(setattr, config, "SESSIONS_DIR", config.SESSIONS_DIR) + config.SESSIONS_DIR = self.tmp / "sessions" + + for registry in (state.SESSIONS, state.EVENTS, state.AGENTS, + events._WRITTEN): + registry.clear() + self.addCleanup(registry.clear) + state.BOARD_EVENTS.clear() + self.addCleanup(state.BOARD_EVENTS.clear) + + self.addCleanup(setattr, state, "broadcast", state.broadcast) + state.broadcast = lambda payload: None + + # ── the world the board sees ────────────────────────────────────────── + + def launch(self, agent_id: str, name: str, model: str | None, + task: str = TASK) -> dict: + """A launch record, as agents.py registers one.""" + record = {"id": agent_id, "task": task, "name": name, "model": model, + "session": None, "status": "running", "mode": "work"} + state.AGENTS[agent_id] = record + return record + + def ingest(self, sid: str, summary: str, agent: str | None = None, + task: str | None = None, kind: str = "edit") -> None: + event = {"v": 1, "session": sid, "kind": kind, "summary": summary} + if agent: + event["agent"] = agent + if task: + event["task"] = task + events.ingest_event(event) + + def restart(self) -> None: + """What a board restart costs: every registry goes, the disk stays.""" + for registry in (state.SESSIONS, state.EVENTS, state.AGENTS, + events._WRITTEN): + registry.clear() + events.load_disk_sessions() + + def label(self, sid: str) -> str: + return state.SESSIONS[sid]["label"] + + # ── the bug ─────────────────────────────────────────────────────────── + + def test_an_agent_session_replayed_after_a_restart_is_still_the_agent(self): + """Acceptance 1: the row carries the agent's name and task, not You.""" + self.launch("45-a-past-agent-120000", "Nell", "claude-opus-4-8") + self.ingest("sess-nell", "editing events.py", agent="45-a-past-agent-120000") + self.ingest("sess-nell", "Nell's report on " + TASK, kind="report", + agent="45-a-past-agent-120000") + self.assertEqual(self.label("sess-nell"), "Nell · #45") + + self.restart() + meta = state.SESSIONS["sess-nell"] + self.assertEqual(meta["label"], "Nell · #45") + self.assertEqual(meta["task"], TASK) + self.assertEqual(meta["agentId"], "45-a-past-agent-120000") + self.assertEqual(meta["agentModel"], "claude-opus-4-8") + + def test_a_human_session_replayed_from_disk_still_reads_you(self): + """Acceptance 2: this fix does not relabel the person's own work.""" + self.ingest("sess-mine-0123456789", "reading AGENTS.md") + self.assertEqual(self.label("sess-mine-0123456789"), "You · sess-min") + + self.restart() + self.assertEqual(self.label("sess-mine-0123456789"), "You · sess-min") + + def test_a_log_from_before_identities_were_recorded_claims_nothing(self): + """Acceptance 3: no identity on disk means unknown, not you. Old + logs are not retro-attributed in either direction.""" + config.SESSIONS_DIR.mkdir(parents=True) + (config.SESSIONS_DIR / "sess-oldrun.jsonl").write_text( + json.dumps({"ts": 1, "session": "sess-oldrun", "kind": "edit", + "summary": "editing board.html"}) + "\n" + + json.dumps({"ts": 2, "session": "sess-oldrun", "kind": "report", + "summary": f"Piper's report on {TASK}"}) + "\n", + encoding="utf-8") + + events.load_disk_sessions() + meta = state.SESSIONS["sess-oldrun"] + self.assertEqual(meta["label"], "Session · sess-old") + self.assertNotIn("You", meta["label"]) + self.assertIsNone(meta["agentId"]) + + def test_restarting_the_board_changes_no_label(self): + """Acceptance 4, across all three registers at once.""" + self.launch("review-45-a-past-agent-130000", "Piper", None) + self.ingest("sess-piper", "reading the card", + agent="review-45-a-past-agent-130000") + self.ingest("sess-mine", "running the tests") + config.SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + (config.SESSIONS_DIR / "sess-old.jsonl").write_text( + json.dumps({"ts": 1, "session": "sess-old", "kind": "edit", + "summary": "from before"}) + "\n", encoding="utf-8") + events.load_disk_sessions() + + before = {sid: m["label"] for sid, m in state.SESSIONS.items()} + self.restart() + after = {sid: m["label"] for sid, m in state.SESSIONS.items()} + self.assertEqual(before, after) + self.assertEqual(before["sess-piper"], "Piper · #45") + + def test_an_agent_id_that_arrives_late_relabels_the_session(self): + """Acceptance 6: events can precede the id; the label catches up, + and what it catches up to is what the restart reads back.""" + self.launch("45-a-past-agent-140000", "Reed", "claude-sonnet-5") + self.ingest("sess-late", "session started", kind="session") + self.assertEqual(self.label("sess-late"), "You · sess-lat") + + self.ingest("sess-late", "editing state.py", agent="45-a-past-agent-140000") + self.assertEqual(self.label("sess-late"), "Reed · #45") + + self.restart() + self.assertEqual(self.label("sess-late"), "Reed · #45") + + def test_a_name_the_board_never_saw_falls_back_to_agent_not_to_you(self): + """Three states, three words: `Agent` when the id is known and the + name is not is the one thing `You` must never be said of.""" + config.SESSIONS_DIR.mkdir(parents=True) + (config.SESSIONS_DIR / "sess-nameless.jsonl").write_text( + json.dumps({"ts": 1, "session": "sess-nameless", "kind": "edit", + "summary": "worked"}) + "\n", encoding="utf-8") + state.persist_identity("sess-nameless", { + "agentId": "45-a-past-agent-150000", "name": None, + "model": None, "task": TASK}) + + events.load_disk_sessions() + self.assertEqual(self.label("sess-nameless"), "Agent · #45") + + def test_a_review_agent_without_a_name_keeps_saying_review(self): + config.SESSIONS_DIR.mkdir(parents=True) + (config.SESSIONS_DIR / "sess-rev.jsonl").write_text( + json.dumps({"ts": 1, "session": "sess-rev", "kind": "read", + "summary": "read the card"}) + "\n", encoding="utf-8") + state.persist_identity("sess-rev", { + "agentId": "review-45-a-past-agent-160000", "name": None, + "model": None, "task": TASK}) + + events.load_disk_sessions() + self.assertEqual(self.label("sess-rev"), "Review · #45") + + # ── the file itself ─────────────────────────────────────────────────── + + def test_the_identity_is_a_file_of_its_own_beside_the_log(self): + """The risk the card names: the logs are append-only JSONL whose + first line every reader takes for an event. Identity must not be a + header line, and must not be read back as a session of its own.""" + self.launch("45-a-past-agent-170000", "Wren", "claude-opus-4-8") + self.ingest("sess-wren", "editing", agent="45-a-past-agent-170000") + + log = config.SESSIONS_DIR / "sess-wren.jsonl" + first = json.loads(log.read_text(encoding="utf-8").splitlines()[0]) + self.assertEqual(first["kind"], "edit") + self.assertEqual( + json.loads((config.SESSIONS_DIR / "sess-wren.who.json") + .read_text(encoding="utf-8")), + {"agentId": "45-a-past-agent-170000", "name": "Wren", + "model": "claude-opus-4-8", "task": TASK}) + + self.restart() + self.assertEqual(list(state.SESSIONS), ["sess-wren"]) + + def test_a_corrupt_identity_file_reads_as_unknown_not_as_a_crash(self): + config.SESSIONS_DIR.mkdir(parents=True) + (config.SESSIONS_DIR / "sess-bad.jsonl").write_text( + json.dumps({"ts": 1, "session": "sess-bad", "kind": "edit", + "summary": "worked"}) + "\n", encoding="utf-8") + (config.SESSIONS_DIR / "sess-bad.who.json").write_text( + "half a fi", encoding="utf-8") + + events.load_disk_sessions() + self.assertEqual(self.label("sess-bad"), "Session · sess-bad") + + def test_the_sidecar_is_rewritten_only_when_what_we_know_changes(self): + """It is written whole on every change, so a session that says the + same thing a hundred times must not rewrite it a hundred times.""" + self.launch("45-a-past-agent-180000", "Juno", None) + writes: list[tuple[str, dict]] = [] + real = state.persist_identity + self.addCleanup(setattr, state, "persist_identity", real) + state.persist_identity = lambda sid, identity: ( + writes.append((sid, identity)), real(sid, identity))[1] + + self.ingest("sess-juno", "one") # you + self.ingest("sess-juno", "two", agent="45-a-past-agent-180000") # linked + for n in range(5): + self.ingest("sess-juno", f"more {n}", agent="45-a-past-agent-180000") + self.assertEqual([i["name"] for _, i in writes], [None, "Juno"]) + + def test_a_running_event_persists_nothing_at_all(self): + """Running events are the live line, not history — they are not in + the log, so they must not conjure an identity file beside it.""" + events.ingest_event({"v": 1, "session": "sess-live", "kind": "command", + "summary": "npm test", "running": True}) + self.assertFalse((config.SESSIONS_DIR / "sess-live.who.json").exists()) + self.assertFalse((config.SESSIONS_DIR / "sess-live.jsonl").exists()) + + +# The page's own functions, run as the browser runs them — the chip on a +# replayed run is the visible half of this fix. +PARTS = ( + r"const esc = \(s\) =>.*?\}\[c\]\)\);", + r"function agentFor\(sid\) \{.*?\n\}", + r"function shortModel\(model\) \{.*?\n\}", + r"function modelChip\(agent\) \{.*?\n\}", +) + + +class ReplayedChip(unittest.TestCase): + """Acceptance 5: a replayed agent session wears its model chip — the + same way every time, from what was persisted rather than from what + happens to be in this board's memory.""" + + @classmethod + def setUpClass(cls): + cls.node = shutil.which("node") + if not cls.node: + raise unittest.SkipTest("node not available — chip behaviour unrun") + html = BOARD.read_text(encoding="utf-8") + out = [] + for pattern in PARTS: + m = re.search(pattern, html, re.S) + if m is None: + raise AssertionError(f"board.html no longer defines {pattern!r}") + out.append(m.group(0)) + cls.src = "\n".join(out) + + def chip(self, sessions: list, agents: list, sid: str) -> str: + script = (f"const S = {{ state: {json.dumps({'sessions': sessions, 'agents': agents})} }};\n" + + self.src + + f"\nconsole.log(JSON.stringify(modelChip(agentFor({json.dumps(sid)}))));") + out = subprocess.run([self.node, "-e", script], + capture_output=True, text=True) + self.assertEqual(out.returncode, 0, out.stderr) + return json.loads(out.stdout) + + def test_a_replayed_agent_session_wears_the_model_it_rode(self): + chip = self.chip( + [{"id": "s1", "agentId": "45-x-120000", "agentName": "Nell", + "agentModel": "claude-opus-4-8"}], [], "s1") + self.assertIn(">opus-4-8", chip) + + def test_a_replayed_session_that_inherited_the_default_still_says_nothing(self): + chip = self.chip( + [{"id": "s1", "agentId": "45-x-120000", "agentName": "Nell", + "agentModel": None}], [], "s1") + self.assertEqual(chip, "") + + def test_the_persons_own_replayed_session_has_no_run_behind_it(self): + self.assertEqual( + self.chip([{"id": "s1", "agentId": None}], [], "s1"), "") + + def test_a_live_record_still_wins_over_the_persisted_one(self): + """The live record knows more (status, branch); the sidecar is the + fallback, not a second source of truth.""" + chip = self.chip( + [{"id": "s1", "agentId": "45-x-120000", "agentName": "Nell", + "agentModel": "claude-opus-4-8"}], + [{"id": "45-x-120000", "session": "s1", "name": "Nell", + "model": "claude-sonnet-5", "status": "running"}], "s1") + self.assertIn(">sonnet-5", chip) + + +if __name__ == "__main__": + unittest.main()