fix(workspace): stop a metadata-less tile leaf from freezing autosave forever - #631
Merged
Conversation
… forever A single tile leaf whose session id had no row in `sessions` permanently disabled autosave on a real workspace for three weeks. Every launch journalled `expectedCount 4, resolvedCount 3, ok false`, rendered the "Autosave off" banner, and left workspace.json frozen at the moment the corruption was written. The failure is self-sealing, which is what makes it worth a targeted fix rather than waiting for #545: - `collectLiveProcessIds` counted every tile leaf, so the orphan entered `expectedSessions`. - rehydrate's respawn loop iterates `persisted.sessions`, so the orphan could never be claimed, spawned, or given an outcome. - `complete = resolvedIds.size === expectedSessions` was therefore unsatisfiable, pinning `partial-restore` and holding autosave off. - autosave is the ONLY writer of workspace.json, so the corrupt tree could never be rewritten. Restarting could not help, despite being exactly what the banner advised. Fixed on both sides of the boundary: - Read side: a leaf with no SessionMeta has no cwd and no kind, so nothing can be spawned for it. It no longer counts toward the restore gate, which makes an already-corrupt file self-heal instead of latching off forever. - Write side: `pruneOrphanTileLeaves` collapses such leaves out of the tree via the same `closeLeaf` primitive a normal pane close uses, and repoints a tab's `focusedSessionId` when it pointed at the dead id. `pruneSessionOwnership` already scrubbed every pointer aimed AT a session; tile trees were the one owner class written verbatim, and that asymmetry is how the torn state became durable. Ownership is derived FROM leaves, so the orphan is itself an owner — pruning `sessions` against owners can never remove it, and the tree has to be rewritten. Also names the unresolved ids in `rehydrate.complete`. The event previously said one pane was missing but never which, so diagnosing this meant diffing tile leaves against the sessions map by hand. Regression tests reproduce the recorded workspace.json shape: a vertical split whose `b` leaf is orphaned and whose tab focus points at it. Refs #545 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up. The first commit built `collectOwnedSessionIds` on
`collectLiveProcessIds`, so the two questions "whose SessionMeta must
survive a save?" and "which sessions need a backend process?" shared one
answer. They are not the same question, and collapsing them means any
future narrowing of "needs a process" silently narrows ownership too —
which deletes user data.
That is not hypothetical. The in-flight extension-view work adds panes
that are real tile leaves with real metadata but deliberately spawn no
process, by skipping them in `collectLiveProcessIds`. Built on the old
shape, that skip also removed them from the owned set, so
`pickOwnedSessions` dropped their metadata on the next autosave — which
manufactured exactly the orphan leaf this branch repairs, and then handed
the repair guard a live pane to collapse out of the user's tree. A loud
freeze would have become a silent pane deletion. Ownership now flows
through `collectTileLeafIds`, so the two guards compose.
Other review fixes:
- `hasSessionMeta` replaces bare `sessions[id]` truthiness. A plain
index read walks the prototype chain, so a leaf id of `toString` or
`constructor` read as "has metadata" — inverting the check and
reproducing the freeze while looking healthy.
- Tab focus is repaired against tree membership, not the sessions map.
The invariant a tab owes is "focus names a leaf I contain"; the old
predicate left focus pointing at a real session in another tab, which
rehydrate does not repair either.
- `activeTabId` and `tileTabs` repair moved into the same pure function
(now `repairPersistedTabs`). The tab-drop branch is the only
destructive path in the change and it had no test, because
`useAutoSave` has no test harness anywhere in the repo; making the
whole path pure makes it testable without React.
- `unresolvedSessionIds` is emitted only on failure and capped at 8 ids.
The journal sanitizer truncates at 300 chars, which would have sliced
the last UUID in half — a half-id reads like a real one.
- `summarize-lifecycle.mts` renders the new key, so the ids reach the
human-facing report they were added for.
- Corrected a comment that claimed the guard stops the user from seeing
an impossible pane. It does not: repair applies to the serialized
copy, not `state.tabs`, so the orphan still renders until relaunch.
A wrong comment is worse than none.
Replaced the "closes the loop" test, which asserted 1 === 1 and passed
against any implementation, with a direct assertion of the restored
invariant (`liveProcessIds` is a subset of the keys of `sessions`).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
The failure
A single tile leaf whose session id had no row in
sessionspermanently disabled autosave on a real workspace.~/.config/agent-code/workspace.jsonstopped being written on 2026-07-28 22:23 and stayed frozen for three weeks, with theAutosave offbanner on every launch.Every retained boot journal shows the same signature:
Exactly one visible pane, every time. Reading the per-session ladders, only three sessions ever received a
recover.claim— the fourth was never attempted.The cause was tab
agent-code, holding a vertical split whosebleaf pointed at a session id with no entry insessions. The tab'sfocusedSessionIdpointed at the same dead id.Why it could never recover
collectLiveProcessIdswalked tile leaves and counted every one, so the orphan enteredexpectedSessions.Object.entries(persisted.sessions). The orphan is not in there, so it was never claimed, never spawned, never given an outcome.projectSessionRecoverylikewise iteratespersistedSessions, so the orphan never enteredresolvedIds.complete = resolvedIds.size === expectedSessionswas therefore unsatisfiable, latchingpartial-restoreand holding autosave off.useAutoSaveis the only writer ofworkspace.json, so the corrupt tree could never be rewritten.Restarting could not help — which is exactly what the banner told the user to do.
I dated the corruption using the leftover atomic-write temp files, which are full content snapshots: every one through
2026-07-28 22:20is clean, and the file written at22:23is the first containing the orphan. The last autosave before the gate closed serialized a tile leaf whose metadata had already been removed fromstate.sessions.The fix
Both sides of the persistence boundary, matching the philosophy
pruneSessionOwnershipalready states — "A corrupted workspace can fail both directions."Read side (
collectLiveProcessIds) — a leaf with noSessionMetahas no cwd and no kind, so there is nothing to spawn for it. It no longer counts toward the restore gate. This is what makes an already-corrupt file self-heal: the gate becomes satisfiable, autosave unlocks, and the write-side guard then persists a repaired tree. Deliberately not "spawn a fresh session for the orphan" — we do not know its cwd or provider, and inventing one would resurrect a pane the user never asked for, pointed at the wrong directory.Write side (
pruneOrphanTileLeaves) — collapses orphaned leaves out of the tree using the samecloseLeafprimitive a normal pane close uses, so a repaired tree has exactly the shape it would have had if the pane were closed normally. RepointsfocusedSessionIdwhen it pointed at the dead id, and drops a tab that loses every leaf (its root would be empty, whichTileNodecannot represent).There is a self-reference that makes the write side the only place this repair can happen: ownership is derived from tile leaves, so an orphan leaf is an owner. Pruning
sessionsagainst owners can never remove it — the tree itself has to be rewritten. That is the gap that let the torn state become durable, becauseuseAutoSaveserializedstate.tabsverbatim while scrubbing every other pointer.Diagnostics —
rehydrate.completenow names the unresolved ids. It previously said one pane was missing but never which, so diagnosing this meant diffing tile leaves against thesessionsmap by hand. The key is added to the lifecycle allowlist and joined into a string, since payload values must stay flat for the sanitizer to be total. No new category of data: ids are already first-class in this stream.Tests
Regression tests reproduce the shape recorded on disk — a vertical split whose
bleaf is orphaned and whose tab focus points at it — and cover the gate denominator, the collapse, focus repointing, whole-tab drop, the healthy-tree no-op (asserted by identity, so a healthy save does not churn the tree), and the end-to-end invariant that a pruned tree makes restore completion satisfiable again.Verification
npx tsc -b --pretty false— cleanvitest run --project unit --project renderer— 1762 passed, 1 failedThe single failure is
imageAttachment.test.ts, which asserts a local Claude transcript JSONL still exists on this machine. It fails identically on unmodifiedmain(verified by stashing) — environment-dependent, unrelated to this change.Scope
This is a targeted slice of #545, which already lists the invariant "Autosave resumes after every pane has a resolved outcome, including retained failures." This PR does not attempt the atomic main-owned recovery that issue describes; it fixes the specific case where a pane can never have an outcome at all.
Refs #545
🤖 Generated with Claude Code