sessions: persist who a session was, so a replayed run is not "You"

"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/<id>.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 · <id>" 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 <noreply@anthropic.com>
This commit is contained in:
istos
2026-07-31 16:35:19 +02:00
co-authored by Claude Opus 5
parent 789261d2f5
commit f428ee0468
5 changed files with 446 additions and 6 deletions
+13 -1
View File
@@ -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');
}
+59 -4
View File
@@ -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"),
}
+48
View File
@@ -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 <sid>.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: