fix(sessions): move the supervisor's observations out of sessions.json - #302
Merged
Conversation
sessions.json mixed INTENT (name, agent, cwd, prompts, stopped) with the supervisor's own OBSERVATIONS (hasRun, boxSessionId), in the one file every human writer and the web UI edits. That is what made the lost update in #254 expensive: reverting the observations turned a long-running session into a "first spawn" — new id, kickoff prompt re-fired, previous transcript orphaned. - The launch id moves to a supervisor-owned side file, ~/.local/state/agent-box/session/<name>.json, read first with the registry copy as a migration fallback. Both are written this release; the next one drops the registry copy and the fallback read. - hasRun is DERIVED for claude from claude_has_transcript, which is the same check the resume arm already made. Other harnesses keep answering from the launch record. - The path is spelled once per program (session_state_file in the supervisor and the CLI, session_state_path in the daemon), so re-keying it on a harness-minted id (#284) is a small change. - Reclaimed by a sweep keyed on the registry, run on every reconcile tick — no delete path is guaranteed to run. Both delete paths prune their own as an optimisation, which closes the window in which a re-used name inherits the previous holder's launch id. - boxSessionId is documented as what it is: the id this session was last LAUNCHED with, one SEGMENT of the conversation. Where the code said "/clear rotation" it now names the three observed triggers: clear, compact, resume. Closes #282 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN
lionello
approved these changes
Aug 19, 2026
lionello
approved these changes
Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
sessions.jsonmixed two kinds of data: intent — what the operator asked for (name, agent, working directory, prompts,stopped) — and the supervisor's own observations (hasRun,boxSessionId). The observations lived in the file every human writer, the CLI and the web UI edit, which is what made the lost update in #254 expensive: reverting them turned a long-running session into a "first spawn" (new id, kickoff prompt re-fired, previous transcript orphaned).And
boxSessionIdwas documented as "the STABLE id WE own across respawns" while the supervisor rewrote it on every adopted rotation. It names a segment, not a conversation — the root of #274 and #277.Closes #282.
1. The comment now says what the field is
The launch id is the id this session was last LAUNCHED with. A clear, a compact or a resume mints a new segment and the supervisor adopts it, so a consumer that wants "the conversation" has to follow the rotation record rather than trust the value. Reworded in
supervisor.sh,session-cli.sh,settings-daemon.pyandagent-box.nix.in.2. Migration shape: side file first, registry copy as fallback, both written this release
The launch id moves to
~/.local/state/agent-box/session/<name>.json—{"launchSessionId": "<uuid>"}— which only the supervisor writes.sessions.json'sboxSessionIdis the fallback, which covers a session that last spawned before this release and the idagent-box-session addmints up front.record_launchwrites the side file whenever the value changes (first spawn, adopted rotation, or the one respawn that migrates an existing session).mark_startedstill mirrorshasRun/boxSessionId/ the clearedinitialPromptinto the registry, on a first-ever spawn or an adopted rotation only.mark_startedand the fallback read; that is the point at which the supervisor becomes a read-only consumer ofsessions.json.Writing only on a change matters: a session that cannot start (a claude with no credentials) is respawned every couple of seconds, and neither file should be rewritten on that loop.
3.
hasRunis derived, not storedFor
claudeit isclaude_has_transcript "$bid"— the same check the resume arm already made, so a stored copy could only ever disagree with the disk, and did in both directions (a reverted registry promised a resume of a transcript that was there; a deleted transcript promised one that was not). The other harnesses have nothing equivalent to read — a codex rollout is found by a marker only a kickoff prompt carries, ashellsession has no transcript at all — so they answer from the launch record itself (launched).The rotation block is now gated on
launchedrather than onhasrun: what makes a live-id record meaningful is that this session has spawned before, and an adoption is its own proof of a resumable transcript.4. "/clear rotation" → the triggers actually observed
Clear, compact and resume, in
supervisor.sh,claude-session-start-hook.sh,settings-daemon.py,agent-box.nix.inand the test's subtest name.One accessor per program (#284)
The key is the session name for now, and scope is deliberately not widened here — but the path is spelled in exactly one function per program, so re-keying it on a harness-minted id (tmux
#{session_id}/AGENT_BOX_SESSION_ID, the transcript uuid) is a change to those functions rather than a migration:modules/src/supervisor.shsession_state_filemodules/src/session-cli.shsession_state_filemodules/src/settings-daemon.pysession_state_pathtests/sessions.nixstate_fileReclaimed without anyone running
rmsweep_session_statein the supervisor is keyed on the registry, not on a delist path being taken, and runs on every reconcile tick (2s) as well as at startup — nothing makes a hook agent callagent-box-session rm, a session can be delisted while the unit is down, and a box that upgrades into this file has no delete path to have taken. Onejqfor the whole sweep, and a registryjqcannot answer for leaves everything alone; an empty registry is a different answer from an unreadable one and does sweep, because the last session deleted is exactly when the last file has to go.agent-box-session rmand the settings page's delete do prune their own, as an optimisation only: it closes the window in whichrm foo; add foowould hand the new session the dead one's launch id, and with it its transcript. Neither is what makes the state reclaimable. No secondlive-session-id/-shaped directory is added (#281 is untouched).EAFP
Every read of the new state attempts and handles the miss —
session_launch_id(jq,2>/dev/null),read_launch_id(try/except OSError, ValueError, AttributeError), and the live-id record, whose[ -r ]check-then-act is now a plain read with a fallback.record_launchusesmktemp(O_EXCL on a name nobody can guess) + rename: not create-if-absent, because a rotation legitimately changes the value, so there is no existence test to lose a race with. The sweep skips an unmatched glob by the name charset check rather than by[ -e ].Tests
tests/sessions.nixrotation subtest — the new second hop. Rotate, respawn, rotate again from the id the supervisor adopted, and assert it resumes the newest segment. One hop cannot tell a chain from a coincidence: a supervisor that only ever looks under its original launch id passes hop 1 and fails hop 2.sessions.json. After hop 2 the registry copy is corrupted exactly as a lost update did (stale id,hasRunback tofalse) and the session is bounced once more — it still resumes the newest segment.rmprunes it.rm; a listed session's file survives being stopped.tests/webhook.nixis unchanged — itshasRun == true and initialPrompt == nullassertion still holds against the migration mirror. Deliberate: fix(webhook): cap dispatch on running hook sessions, not registry keys #286 was rebasing that file concurrently.agent-box-agent-sessions.json) are byte-identical: only a comment abovesessionsSeedFilechanged, not the JSON. The rendered supervisor / session CLI / settings daemon / hook payloads did change, hence thetests/golden/diff.Checks
Ran natively (aarch64):
nix build .#checks.aarch64-linux.module-generated-up-to-date— greennix build .#checks.aarch64-linux.golden-snapshot— green (regenerated withnix run .#update-golden)nix build .#checks.aarch64-linux.module-single-file— greennix build .#checks.aarch64-linux.{multi-user,download-route,webhook-route}— green/home/agent/bin/check-testscript.sh tests/sessions.nix—tyandruffboth cleannix-instantiate --parse tests/sessions.nix— clean$HOME(miss, hit, corrupt JSON, non-string value, path traversal in the recorded id, unreadable registry, empty registry, unmatched glob).Only EVALUATED (VM tests are x86_64-only; this box is aarch64):
nix eval .#checks.x86_64-linux.sessions.drvPath— evaluatesnix eval .#checks.x86_64-linux.webhook.drvPath— evaluatesnix eval .#checks.x86_64-linux.settings-page.drvPath— evaluatesCI runs the VM tests for real.
Migration impact
None for an operator: a live session keeps its launch id through the fallback read, and the first respawn after the upgrade writes the side file. A rollback to the previous module still finds its ids in
sessions.json, which is what the mirror is for. The new directory is agent-owned under~/.local/state/, one small file per session, swept against the registry.🤖 Generated with Claude Code
https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN