Skip to content

fix(workspace): hibernate detached sessions on rehydrate - #258

Merged
Juliusolsson05 merged 2 commits into
mainfrom
fix/lazy-spawn-detached-sessions
May 21, 2026
Merged

Juliusolsson05 merged 2 commits into
mainfrom
fix/lazy-spawn-detached-sessions

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented May 21, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Bug: every restart fork-bombed the machine. Field data: 49 sessions persisted, 9 visible, 40 in detachedSessions — rehydrate spawned all of them, producing 40 claude --resume <uuid> + 40 mitmdump processes in ~3 seconds and a load average of 906 on an 8-core M3.
  • Cause: collectOwnedSessionIds lumped tile leaves + detached + buried into one set, and rehydrate used it as the spawn filter. Parking a dispatch agent for later use ("detach") permanently added a backend process to every future launch.
  • Fix: split the concept. collectOwnedSessionIds keeps its current meaning (what metadata to preserve across save/load). New collectLiveProcessIds answers the strictly smaller question rehydrate actually needs: "what tile leaves is the user currently exposed to?" Rehydrate's Promise.all is now filtered by the live set. Hibernated session metadata still round-trips through state so the dispatch list keeps rendering and a future wake-on-attach action can resume by providerSessionId.

Commits

  1. fix(workspace): hibernate detached sessions on rehydrate — the core split + the three structural side-fixes (sanitizeRemappedNode now keys on spawnedIds, detached/buried remappers fall back to original id, expectedSessions switches to liveProcessIds.size so autosave-gating stays correct).
  2. fix(workspace): address PR review — hibernated link/pin/reload paths — three sibling instances of the same "hibernated session silently disappears" pattern caught in review:
    • remapSessionMetaRelationships takes a knownSessionIds set; hibernated→hibernated parent/root links survive instead of being silently dropped.
    • buildRemappedPinnedSessionIds falls back to the original id when the target is in freshSessions (parked-agent pins survive).
    • reloadAgentSessions filters by collectLiveProcessIds instead of collectOwnedSessionIds — toggling dangerous mode no longer resurrects parked agents.

Telemetry note

The workspace.rehydrate.complete.expectedSessions perf-mark semantic narrows from "all owners" to "live-process count only". The previous total is recoverable as expectedSessions + hibernatedSessions (the new field added in the same perf mark). Dashboards reading the metric should be migrated to the sum if they care about absolute totals.

Files changed

File What
src/renderer/src/workspace/sessionOwnership.ts Add collectLiveProcessIds. Rewrite module header to explain the live-vs-owned split.
src/renderer/src/workspace/hook/persistence/rehydrate.ts Filter spawn loop by liveProcessIds. Seed hibernated metadata into freshSessions under original sessionId. Add spawnedIds set so sanitizeRemappedNode distinguishes "process started" from "metadata exists". Fall back to original sessionId in detached/buried/pinned remap. expectedSessions = liveProcessIds.size. remapSessionMetaRelationships gains optional knownSessionIds for hibernated→hibernated link preservation.
src/renderer/src/workspace/hook/actions/session.ts reloadAgentSessions filters by liveProcessIds instead of ownedIds (sibling bug).
scripts/test-session-ownership.ts New assertions: collectLiveProcessIds returns only tile leaves; relationship remap preserves hibernated→hibernated links via knownSessionIds; orphaned endpoints still drop; back-compat without knownSessionIds stays strict.

Pushback on review item #1 (hibernated runtime status)

The review claimed the runtimes seed loop "iterates all freshSessions keys" and would mis-state hibernated sessions as started/inputReady: true. The actual loop at rehydrate.ts:344 iterates idMap.entries(), not freshSessions. Hibernated sessions never get added to idMap (the seed loop only writes to freshSessions), so they get no runtime entry at all. UI lookups fall back to emptyRuntime() which returns processStatus: 'idle', inputReady: false (workspaceState.ts:594, 596) — the correct defaults for a non-running session. The wake-on-attach PR can rely on this without a separate 'hibernated' enum value.

What this is NOT (deliberate non-goals — separate PRs)

  • Wake-on-attach UI for hibernated sessions.
  • Orchestration-MCP parent→child reaping. Diagnosis turned up 7 entries in workspace.json with duplicate providerSessionIds (two sessionId keys pointing at one provider UUID). That's a separate spawn/cleanup bug and warrants its own fix.
  • TTL / LRU eviction of stale detached records. With this fix the cost of stale detached records collapses to a few bytes of JSON per record, so eviction is no longer urgent. Worth doing eventually.

Test plan

  • npm run test:session-ownership — passes with new assertions (live-process contract + relationship remap fallback)
  • npm run test:review-fixes — all green except test:claude-proxy-api-error-release which fails on main too (unrelated; submodule not initialized in this worktree)
  • npx tsc --noEmit -p tsconfig.web.json — error count unchanged from main; zero new errors and zero in edited files
  • Manual: restart the app with the current 40-entry detachedSessions pool; confirm only the visible-tab sessions get a PTY + mitmdump, and that detached entries remain visible in the dispatch list (just sleeping).
  • Manual: detach a fresh dispatch agent, restart, confirm it appears as parked without consuming process slots.
  • Manual: toggle dangerous mode while parked agents exist; confirm hibernated agents stay parked (no new processes).

🤖 Generated with Claude Code

Juliusolsson05 and others added 2 commits May 21, 2026 11:04
Previously every entry in `ownedIds` got a full PTY + mitmdump spawn on
app startup via rehydrate's Promise.all. `ownedIds` lumps three things
together — visible tile leaves, detached dispatch parking, and buried
panes — and treats them all as live. For users who park dispatch agents
("I'll come back to this later"), this meant every restart resurrected
the entire detached pool as live processes. Field data: 49 sessions
persisted, 9 visible in the UI, 40 in detachedSessions — last launch
fork-bombed the machine with 40 claude + 40 mitmdump processes, loadavg
peaked at 906 on an 8-core M3.

Split the conflated concept into two sets in sessionOwnership.ts:

  collectOwnedSessionIds  → metadata-preservation set (unchanged).
                            Tile leaves + detached + buried. Used by
                            pruneSessionOwnership to decide what
                            survives a save cycle.

  collectLiveProcessIds   → rehydrate-spawn set (new).
                            Tile leaves ONLY. The strict subset the
                            user is actually going to be exposed to on
                            launch.

rehydrate.ts now filters the Promise.all by liveProcessIds. Hibernated
sessions still round-trip through state — their metadata is seeded into
freshSessions under the original persisted sessionId before the spawn
loop runs, so the dispatch list and buried-panes UI keep rendering and
a future wake-on-attach action can resume them by providerSessionId.

Side fixes pulled along by the new contract:

  - `sanitizeRemappedNode` now checks the new `spawnedIds` set instead
    of `freshSessions` membership. freshSessions includes hibernated
    metadata seeds now, so the old check would have promoted hibernated
    ids into visible tile leaves with no backing process.

  - `buildRemappedDetachedSessions` / `buildRemappedBuried` fall back
    to the original sessionId when idMap has no entry, instead of
    silently dropping the record. Hibernated sessions never go through
    the spawn loop, so they never get an idMap entry — without this
    fallback the dispatch parking pool would have emptied on every
    restart.

  - `expectedSessions` for the autosave-gating `complete` flag is now
    `liveProcessIds.size` (not `ownedIds.size`). Otherwise `complete`
    would be permanently false for anyone with parked agents and
    autosave would silently disable for them forever.

Tests in scripts/test-session-ownership.ts pin the contract:
  - tile leaves ARE live processes
  - detached are NOT
  - buried are NOT
  - empty tabs ⇒ no live processes even with detached/buried present

Non-goals (separate PRs):
  - Wake-on-attach UI affordance for hibernated sessions
  - Orchestration MCP parent→child reaping (duplicate providerSessionId
    bug, found during diagnosis)
  - TTL/LRU eviction of stale detached records

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three sibling instances of the same "hibernated session silently
disappears" bug, plus a back-compat-safe shape on
remapSessionMetaRelationships.

1. remapSessionMetaRelationships now takes a `knownSessionIds` set in
   addition to idMap, and falls back to the original id when a
   relationship endpoint is in the known set but not in idMap. Without
   this, hibernated→hibernated links (parked orchestration parent and
   child) silently lost their linkedParentId/orchestrationParentId/
   orchestrationRootId on rehydrate even though both endpoints survived
   in freshSessions. buildRemappedSessions passes the freshSessions key
   set as knownSessionIds; the argument is optional so any future
   caller outside rehydrate keeps the strict idMap-only behavior.

2. buildRemappedPinnedSessionIds applies the same fallback shape used
   in buildRemappedDetachedSessions / buildRemappedBuried. A pin
   pointing at a hibernated agent now round-trips through rehydrate
   instead of disappearing.

3. reloadAgentSessions in src/renderer/.../hook/actions/session.ts now
   filters by collectLiveProcessIds, not collectOwnedSessionIds. This
   is the sibling code path the reviewer caught: toggling dangerous
   mode used to call killSession + spawnSession on every owned
   claude/codex session, which would have re-introduced the original
   fork-bomb for users with parked dispatch agents — every mode toggle
   would resurrect N hibernated agents as live processes. The
   pickOwnedSessions call further down (state-prune use, not
   spawn-filter use) deliberately keeps collectOwnedSessionIds.

Test extensions in scripts/test-session-ownership.ts pin the new
relationship-remap contract: spawned->spawned remap via idMap;
hibernated->hibernated preserved via knownSessionIds; orphaned
endpoint dropped; back-compat call shape (no knownSessionIds) keeps
the strict pre-PR behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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