Tell a Mailroom sidecar apart from a damaged graph (#315) - #317
Conversation
`graphcode reap` has aborted on every workspace since #308 shipped in 0.1.64-beta3. `OrphanedSessionReaper.liveSessionIDs` took every `.json` under `projects/` for a `LoopGraph` and refused to reap if any one of them failed to decode. That held until #307 moved the Mailroom into `<project>.mailroom.json` beside the graph: the room is a JSON array, so it can never decode as a graph, and every reap bailed with "refusing to guess which sessions it owns" — a message that reads as caution rather than breakage, on the tool people reach for when they are out of PTYs. Refusing to guess stays right for a graph that is genuinely damaged: it still owns sessions nobody can enumerate, and reaping them would be a sweep. What was wrong is that a sidecar is not a damaged graph. `ProjectPersistence` now answers which files it writes beside a graph, next to where it mints their names, and both readers of `projects/` ask it — the reaper and `Workspace.contents`, which had the same assumption and only escaped notice by skipping rather than bailing. The rule is answered from the name alone: a *corrupt* room is still a room, it never owned a session, and treating it as a damaged graph would disable reap for exactly the reason being fixed here. Two things the same file turned up, both from state that outlives a delete: - A save still queued when a project is deleted could drain afterwards and put the graph file back, and `GraphWriter.load` would keep answering from the queue for a project that no longer exists. `GraphWriter.forget(path:)` drops it, and the delete calls it before removing the file. - The room digest cache was keyed by project path and outlived the file. One path is the same project in every workspace but a different file in each, so two workspaces shared an entry and a room could be judged unchanged against a digest taken from someone else's file. Keyed by the room file now, and cleared on delete. Two registry tests only passed because `/tmp/project-f` and `/tmp/project-g` happened to exist on the machine that wrote them: `routing` refuses a path with no folder at it, so on a clean checkout no loop was created and `deletingAClosedProjectsLoopsStillEndsTheirSessions` passed for having found nothing. Both now make the folder, and that test asserts it created something before asserting what was killed. Closes #315. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gvj8H68LzmfgKg4cjEz1nx
… the drain A project path ending in `.mailroom` mints a graph file that carries the sidecar suffix, so skipping by name alone would drop its live sessions out of a reap's set — the fail-open direction, the opposite of the one this fix trades in. Both readers now decode first and let the name decide only the failure path: a sidecar, corrupt or not, never decodes as a graph and is skipped; a sidecar-named file that decodes as a graph is a graph. Workspace.contents drops its pre-decode filter — a room never decodes as a graph, so the decode already skipped it, and the filter is what dropped a sidecar-named project from the listing. `GraphWriter.forget` syncs against the drain queue: a drain that had already popped a save still had the write ahead of it, and that write — made outside the lock — could land after `deleteGraph` removed the file and resurrect it. Observed once under full-suite load with the lock-only version; the queue is the one place that ordering lives. Tests: the sidecar-named graph at the reaper and in the workspace listing, a corrupt room still not stopping a reap, and the room digest judged against its own file rather than the project path — the re-keying fix had no test of its own.
Independent review — merged with two amendments (pushed as
|
| Attack | Result |
|---|---|
e2e reap --dry-run, shipped beta5 vs this branch |
shipped aborts (bug live on this machine); branch lists orphans — both claims reproduced |
| Unregistered sidecar beside a graph | probe: an unregistered *.cache.json written by saveGraph → everyFileWrittenBesideAGraphIsRecognisedAsASidecar fails loudly |
| Unregistered sidecar vs the reaper | fails closed — liveSessionIDs → nil, a loud abort, not a mis-reap |
| Corrupt graph | aCorruptGraphStillStopsAReap passes; fail-closed preserved |
| Drive-by #1 — queued save resurrects a deleted graph | aDeletedProjectIsNotHandedBackFromTheQueue fails deterministically with forget neutralised |
| Drive-by #2 — digest keyed by project path | probe with the old keying: workspace A's room judged unchanged against B's digest, never written — fix matters, but had no test of its own; one added |
Drive-by #3 — the two /tmp/project-* tests |
correction below |
Correction to the body: the suite init() that mints /tmp/project-a…i predates this PR (it is on the merge base 8dbf548), so the folders are not machine-dependent leftovers — init() creates them on every run, including CI, and deletingAProjectsLoopsEndsEverySessionFirst was not touched by this PR (still /tmp/project-f; it was already non-vacuous via #expect(everyLoop.count == 3)). Only the closed-project test was made hermetic. Harmless, but "Both now create the folder" and "would not [exist] on a clean checkout or in CI" overstate.
Amendment 1 — the name-only rule was fail-OPEN for one project shape
A project path ending in .mailroom mints a graph file that carries the sidecar suffix (_tmp_x.mailroom.json). Skipping by name dropped its live sessions from the reap's set — a real reap would kill them, the opposite direction of the safety property being traded on. Probe on the name-only version: liveSessionIDs → [] for a project with one live loop.
Fix (pushed): both readers decode first and let the name decide only the failure path. A sidecar — corrupt or not — never decodes as a graph, so the corrupt-room property is untouched; a sidecar-named file that decodes as a graph is a graph. Workspace.contents drops its pre-decode filter entirely: a room never decodes as a graph, so the decode already skipped it, and the filter was the only thing that could hide a sidecar-named project from the listing. Tests added at both readers, plus aCorruptRoomFileStillDoesNotStopAReap pinning the corrupt-room claim, which had no test.
Amendment 2 — forget narrowed the resurrection window, it did not close it
A drain that had already popped a save still had the write ahead of it — made outside the lock — and that write could land after deleteGraph removed the file and put the graph back. Observed, not hypothetical: aDeletedProjectIsNotHandedBackFromTheQueue failed with the lock-only version under full-suite load (gate log on my machine, once in two full runs). Fix: forget syncs against the drain queue, so the in-flight write completes before the delete's removeItem; the queue is the one place that ordering lives. Deterministic across repeated runs since.
Also filed #318 for aTickThatChangesNothingTellsNobody (PresencePollingTests, flaky ~1 in 5, unrelated to this PR) — it cost two full-suite gates while this review was in flight.
On deferring the sidecar-directory rule
Agreed. Sidecars in their own directory would make "every .json in projects/ is a graph" true by construction, but it needs a migration for rooms already on disk from beta3–beta5 and a downgrade story — exactly the scope that turns a same-day blocker into a week. Suffix list + save-through-persistence tripwire is a sound interim, now that the name cannot misfire.
Gate
Gate on 19028d20 (= pushed head, private DerivedData): xcodebuild test exit 0 — 1672 tests / 175 suites / 0 failures (+4 vs the branch's 1668); graphcode-cli build exit 0; graphcoded build exit 0; swiftlint 0 errors; swift-format strict clean; scripts/cli-smoke.sh exit 0 (5 verbs, throwaway daemon). Linux: pass on the same head (run 34079611052).
Closes #315. Regression from #308 (
11e33a3), shipped in 0.1.64-beta3 today.What was broken
OrphanedSessionReaper.liveSessionIDstreated every.jsonunder a workspace'sprojects/directory as aLoopGraphand returnednil— refusing to reap — if any one failed to decode. True until #307 moved the Mailroom into<project>.mailroom.jsonbeside the graph. The room is a JSON array, so it can never decode as a graph, and every reap on every workspace bailed.It fails closed, so nothing was destroyed. That is also why it would not have been reported:
reapis the PTY-exhaustion recovery tool (#197), it is broken exactly when someone needs it, and the message reads as caution rather than breakage.The rule I chose, and why not a filename blocklist
Refusing to guess stays right for a graph that is genuinely damaged — it still owns sessions nobody can enumerate, and reaping them would be a sweep rather than a reap. The defect is that a sidecar is not a damaged graph.
So the question
liveSessionIDsis really asking is "which of these files are graphs?", and the type that can answer it is the one that mints both names.ProjectPersistencenow owns the answer, besidemailroomURL:Both readers of
projects/ask it — the reaper, andWorkspace.contents, which carried the same assumption and escaped notice only because itcontinues instead of bailing.Three things about the shape:
mailroomURLbuilds the name fromroomFileSuffix, so the rule and the file it describes cannot drift apart.reapfor precisely the reason being fixed here.reap, soeveryFileWrittenBesideAGraphIsRecognisedAsASidecarsaves a graph with a room through the realProjectPersistenceand asserts every file that lands beside it is claimed by a suffix. Add a sidecar without registering it and that test fails, instead ofreapquietly dying months later.The stronger positive rule — sidecars in their own subdirectory, so "every
.jsoninprojects/is a graph" is true by construction — needs a migration for rooms already written by beta3–beta5 and a downgrade story. Not worth carrying on a same-day regression fix; noted as the follow-up.Two more findings from the same file
Both are state that outlives a delete.
deleteGraphremoved the file but left the writer'spendingentry, so a drain landing afterwards would rewrite it, andGraphWriter.loadkept answering from the queue for a project that no longer existed.GraphWriter.forget(path:)drops it and the delete calls it before removing the file. Structural — I could not reproduce it in 30 attempts against a live daemon, the window is microseconds — butaDeletedProjectIsNotHandedBackFromTheQueuefails deterministically without the fix.Two tests that were passing for the wrong reason
routingrefuses a path with no folder at it.deletingAProjectsLoopsEndsEverySessionFirstanddeletingAClosedProjectsLoopsStillEndsTheirSessionsused/tmp/project-fand/tmp/project-g, which exist on the machine that wrote them (dated Sep 2) and would not on a clean checkout or in CI. Without them no loop is created, and the closed-project test's three expectations all hold for having found nothing. Both now create the folder, and that test asserts it created something before asserting what was killed.Fail without / pass with
aRoomFileBesideItsGraphDoesNotStopAReapOrphanedSessionReaper.swifttoorigin/mainliveSessionIDs(...) → nilaCorruptGraphStillStopsAReapaDeletedProjectIsNotHandedBackFromTheQueueGraphWriter.forgetremovedloadreturns the deleted graphEnd to end on this machine, both binaries against the same four live workspaces:
Gate
Gated on
6bf3a69, which equals the pushed head offix/reap-mailroom-sidecar. Private DerivedData at<worktree>/.derived.xcodebuild -scheme graphcode test** TEST SUCCEEDED **— 1668 tests / 175 suites / 0 failures (+5 vs main)xcodebuild -scheme graphcode-cli buildxcodebuild -scheme graphcoded buildswiftlint lintswift format lint --strictscripts/cli-smoke.shrm .buildsymlink +swift buildLinux to be confirmed from
gh pr checksrather than inferred.Not merging on my own gate — this needs a reader.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Gvj8H68LzmfgKg4cjEz1nx