Skip to content

fix(workspace): stop a metadata-less tile leaf from freezing autosave forever - #631

Merged
Juliusolsson05 merged 2 commits into
mainfrom
fix/autosave-orphan-tile-leaf
Aug 18, 2026
Merged

fix(workspace): stop a metadata-less tile leaf from freezing autosave forever#631
Juliusolsson05 merged 2 commits into
mainfrom
fix/autosave-orphan-tile-leaf

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

The failure

A single tile leaf whose session id had no row in sessions permanently disabled autosave on a real workspace. ~/.config/agent-code/workspace.json stopped being written on 2026-07-28 22:23 and stayed frozen for three weeks, with the Autosave off banner on every launch.

Every retained boot journal shows the same signature:

rehydrate.complete  {"expectedCount":4,"resolvedCount":3,"ok":false}

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 whose b leaf pointed at a session id with no entry in sessions. The tab's focusedSessionId pointed at the same dead id.

Why it could never recover

  1. collectLiveProcessIds walked tile leaves and counted every one, so the orphan entered expectedSessions.
  2. rehydrate's respawn loop iterates Object.entries(persisted.sessions). The orphan is not in there, so it was never claimed, never spawned, never given an outcome.
  3. projectSessionRecovery likewise iterates persistedSessions, so the orphan never entered resolvedIds.
  4. complete = resolvedIds.size === expectedSessions was therefore unsatisfiable, latching partial-restore and holding autosave off.
  5. useAutoSave is the only writer of workspace.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:20 is clean, and the file written at 22:23 is the first containing the orphan. The last autosave before the gate closed serialized a tile leaf whose metadata had already been removed from state.sessions.

The fix

Both sides of the persistence boundary, matching the philosophy pruneSessionOwnership already states — "A corrupted workspace can fail both directions."

Read side (collectLiveProcessIds) — a leaf with no SessionMeta has 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 same closeLeaf primitive a normal pane close uses, so a repaired tree has exactly the shape it would have had if the pane were closed normally. Repoints focusedSessionId when it pointed at the dead id, and drops a tab that loses every leaf (its root would be empty, which TileNode cannot 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 sessions against owners can never remove it — the tree itself has to be rewritten. That is the gap that let the torn state become durable, because useAutoSave serialized state.tabs verbatim while scrubbing every other pointer.

Diagnosticsrehydrate.complete now names the unresolved ids. It previously said one pane was missing but never which, so diagnosing this meant diffing tile leaves against the sessions map 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 b leaf 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 — clean
  • vitest run --project unit --project renderer — 1762 passed, 1 failed

The single failure is imageAttachment.test.ts, which asserts a local Claude transcript JSONL still exists on this machine. It fails identically on unmodified main (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

Juliusolsson05 and others added 2 commits August 18, 2026 12:01
… 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>
@Juliusolsson05
Juliusolsson05 merged commit c8c9989 into main Aug 18, 2026
1 check passed
@Juliusolsson05
Juliusolsson05 deleted the fix/autosave-orphan-tile-leaf branch August 18, 2026 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant