Fix agent restore after relaunch: liveness at quit, admission starvation, fork attribution
Summary
Fixes #20186. After a relaunch with many agent tabs, cmux resumed stale or missing sessions, left tabs at a shell, and forgot that agents had been running. I reproduced these failures in isolated tagged fleet builds, using 24 Claude tabs across 8 workspaces plus a Codex tab and three forks, and fixed each one with a red/green test pair.
1. A quit saved running agents as stopped (regression in 0.65.1, #18644). The quit-time process census got a 750 ms deadline. When it misses that deadline, the save falls back to the cached shared agent index. A tab whose session the cache hadn't seen was saved with wasAgentRunning=false. On the baseline build every quit hit this, and all 25 Claude tabs were saved as false.
The cached fallback is now marked incomplete for every agent kind. In that case both snapshot paths (workspace and Dock) save unknown (nil) liveness instead of false, and restore treats nil as running. A tab whose shell is back at its prompt still saves false.
2. Restored tabs never got their agent back. Each restored pane runs cmux restore, which asks the app for an ownership decision (agent.restore.admit) before launching. On the baseline relaunch all 24 restores stayed blocked there for over 10 minutes, with 6,359 recovering answers. Three things combined:
- When a request's 10 s deadline expired, it cancelled the shared index scan and failed every other waiter (
abandonOwnershipRefreshTasks, from #9266). Now a deadline ends only its own request. - The loader shared that 10 s budget, so a longer scan was retired and its result discarded. The loader now has its own 180 s bound, which only guards against a loader that never returns.
- Every CLI retry demanded a scan that started after the retry itself. The census, which reads every process's arguments and environment, took 39–139 s on the dogfood Mac (debug build, about 2,900 processes), so no retry could ever be satisfied. Restore admission and deferred admission now accept any scan that started after the operation's first request. That scan has read every hook record written before the restore asked, so retries converge however slow the census is.
An admission that times out waiting for the index answers immediately, so it stays inside the CLI's 30 s response timeout. I found no change between v0.65.0 and v0.65.1 in this path; how many tabs are affected depends on process count and census time.
3. Forked Claude sessions bound the wrong session or the wrong tab.
- A fork's
SessionStartbound the tab to the fork before Claude had written the fork's transcript, which happens with its first message. Quitting before that message produced "No conversation found" on relaunch and left the tab at a shell. This reproduced in the dogfood and matches "binding ended on a session id with no transcript". The tab now keeps its binding until the transcript exists, and the fork's first prompt or stop binds it. - Queued hook admission routed by an exported
CMUX_CLAUDE_PID. A Claude started outside the wrapper inherits that from another agent, so itsSessionStartrebound that agent's tab. When the exported PID is alive on a different controlling terminal than the agent that ran the hook, the hook's agent now wins. On the same terminal, as with a launcher shim, or without terminal evidence, the exported PID is kept. The CLI hook path infers the hook's agent from its process tree; the spool path records$PPID. The policy (AgentHookDeliveryPolicy.hookAgentPID) is shared by every agent's queued hooks. Both standard fork paths (/branch,--fork-session) were already attributed correctly, and I couldn't reproduce the inherited-PID case without the reporter's environment.
DEBUG-only breadcrumbs (agentRestore.admit.indexTimedOut, …indexIncomplete, liveAgentIndex.unavailable, liveAgentIndex.loaded ms=) record why admission waits.
Testing
| Failure | Test | Red | Fix |
|---|---|---|---|
| quit liveness | ClaudeHookSessionLivenessTests/quitSaveFromStaleCachedIndexKeepsLiveClaudeResumable() | fa7807f, failed with wasAgentRunning → false (run) | d0a2e2e |
| shared scan cancelled | LiveAgentIndexRelevantChurnTests/timedOutOwnershipRequestKeepsSharedScanForLaterRequests() | c4d2daa (run) | d4da2cc, cc9e715 |
| slow-scan retries | LiveAgentIndexRelevantChurnTests/retryAcceptsSlowScanStartedAfterOperationBegan() | 266efb8 (run) | 853b3d1 |
| fork without transcript | CLINotifyProcessIntegrationRegressionTests/testClaudeForkSessionStartWithoutTranscriptKeepsPaneBinding | 3c6e338 (run) | 4783ed6 |
| inherited agent PID | AgentHookDeliveryPolicyTests (3 cases, package tests in PR CI) | 73cb78f (seam with today's behavior) | bc94bce, 8093d21 |
Green at head 4783ed6: CLINotifyProcessIntegrationRegressionTests ran 383 tests with 0 failures (run). The liveness, churn and anchor suites ran 11 tests and passed (run). scripts/verify-local.py passed 17/17 and tests/test_claude_wrapper_hooks.py passed locally. No local Xcode tests were run. The build host was unreachable from this machine, so package tests run in PR CI.
Dogfood, all in isolated tagged fleet builds, never the user's cmux:
- Baseline
i20186-baseline(1606734): the quit saved 25/25 tabs asfalse, and on relaunch all 24 restores hung in admission. - Final
i20186-fix7(4783ed6): a fresh scenario with 24 Claude tabs, a Codex tab, a/branchfork, a--fork-sessionfork with a message, and one without a message. The quit saved 23 tabstrueand 5 unknown, nonefalse, although the census again fell back to the cached index. After relaunch every tab returned to the same session it had before the quit (28/28 by position). Each admission was granted within onerecoveringround, even though one census took 139 s. - OpenCode has no AI provider and Muse's backend isn't available on this machine, and pi, amp and grok aren't installed. Their autoresume paths are covered by the existing unit suites, not this dogfood. Every change here is agent-agnostic, except the fork-transcript check, which is Claude-only.
Changelog
- Fixed: Relaunching cmux with many agent tabs resumes every tab that was running, keeps forked Claude sessions bound to the right session and tab, and no longer saves running agents as stopped when quitting takes a while.
Proof
Terminal-only behavior. The evidence is the CI runs and the tagged-build session files and logs above.
🤖 Generated with Claude Code