diff --git a/AGENTS.md b/AGENTS.md index 7f65a6e..6e9cd0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -259,7 +259,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 f7b1a54..1e45357 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()