Repository navigation
Fix Agent Hibernation lifecycle for non-codex agents - #5500
lawrencecchen wants to merge 32 commits into
Conversation
Agent Hibernation only hibernates sessions whose lifecycle resolves to `idle`, but in practice only codex reached that state. Three layers caused every other agent to be stuck non-eligible; all three are hibernation-only (the lifecycle pipeline has a single consumer, AgentHibernationController), so none of this changes notification or sidebar `needsInput` behavior. 1. Reader priority (Workspace.agentHibernationLifecycleState): `unknown` outranked `idle`, so a real idle was permanently masked by any agent that also reported an indeterminate status. Now `running > needsInput > idle > unknown`. 2. Index completion fallback (RestorableAgentSession): plugin/no-emit agents (e.g. opencode) never emit a live lifecycle but do record a completion notification; treat `lastNotificationStatus == idle` as `idle` so they become hibernation-eligible like codex. 3. Claude notification clobber (CLI claude hook): Claude fires a "waiting for your input" notification after every turn, which was mapped to `needsInput` and overwrote the Stop hook's idle on every turn, so Claude never hibernated. Now only a permission/approval prompt keeps it `needsInput`; the plain "ready/waiting" notification resolves to `idle`. The user-facing notification and sidebar status are unchanged. The decision logic is consolidated into pure, tested static helpers on AgentHibernationLifecycleState (resolved/effective/notificationIndicatesBlocked). Verified on a tagged dev build with live agents: Claude and grok now hibernate (previously never did), codex still works. Known remaining per-agent gaps to be fixed separately: gemini emits no completion signal, and opencode's plugin does not always register a restorable session. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds deterministic lifecycle-merge/resolution helpers and notification-based blocked detection, wires them into CLI persistence and live updates, uses an effective fallback in session indexing, delegates workspace aggregation to the new resolver, and adds unit and integration tests validating resolution, persistence, and planner selection. ChangesAgent Hibernation Lifecycle Resolution
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (1 error, 1 warning, 1 inconclusive)
✅ Passed checks (18 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0f8cbcf950
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let hibernationLifecycle: AgentHibernationLifecycleState = | ||
| AgentHibernationLifecycleState.notificationIndicatesBlocked( | ||
| subtitle: summary.subtitle, | ||
| body: summary.body | ||
| ) ? .needsInput : .idle |
There was a problem hiding this comment.
Preserve needsInput for Claude questions
When Claude is waiting in AskUserQuestion, the pre-tool-use path records .needsInput and saves the question text for the following notification; that notification can then have subtitle Waiting with the actual question body, which this permission-only classifier converts to .idle unless the question happens to contain “permission”/“approval”. This overwrites the live lifecycle and can let hibernation terminate the panel while Claude is still blocked for a user answer.
Useful? React with 👍 / 👎.
Greptile SummaryFixes agent hibernation so claude, opencode, and other non-codex agents become hibernation-eligible when idle, not only codex. Three root causes are addressed: the live lifecycle resolver now ranks
Confidence Score: 3/5The core hibernation logic is sound and well-tested, but the durable terminal-input safety flush added to stop() is never called at process exit, leaving the guard it was designed to provide inactive on normal quits and crashes. The three root causes are correctly fixed and unit/integration test coverage is thorough. One real defect: AgentHibernationController.stop() contains the only synchronous flush of the new durable input store but is never called from applicationWillTerminate. Input timestamps recorded within the debounce window before a normal quit or crash will not reach disk, making hasUnconfirmedTerminalInput silently false on the next restart. Sources/App/AgentHibernationController.swift — the synchronous flush in stop() needs to be reachable from applicationWillTerminate. Important Files Changed
Sequence DiagramsequenceDiagram
participant CLI as CLI Hook
participant Store as ClaudeHookSessionStore
participant Socket as ControlSocket
participant WS as Workspace
participant Planner as AgentHibernationController
Note over CLI,Planner: Turn completion (Stop hook)
CLI->>Store: "upsert agentLifecycle:.idle lastNotificationStatus:.idle lifecycleUpdatedAt=now"
CLI->>Socket: set_agent_lifecycle idle
Socket->>WS: setAgentLifecycle lifecycle:.idle recordAgentLifecycleChange T_stop
Note over CLI,Planner: App restart
Planner->>Store: "loadIndex lifecycleUpdatedAt=T_stop"
Planner->>Planner: "durableLifecycleChangeAt=T_stop terminalInputAt lt T_stop hasUnconfirmedTerminalInput=false"
Note over CLI,Planner: Resume from hibernation
WS->>WS: "setAgentLifecycle lifecycle:.idle seed lifecycleChangeAt=T_resume"
WS->>Planner: recordTerminalInput durable:false if queuedStartupInput
CLI->>Socket: set_agent_lifecycle unknown --preserve-idle
Socket->>WS: preservingDefinitive keeps .idle no timestamp advance
Note over CLI,Planner: Claude notification classification
CLI->>CLI: notificationIndicatesBlocked?
alt permission or approval keyword
CLI->>Store: agentLifecycle:.needsInput
else benign waiting
CLI->>Store: agentLifecycle:.idle lastNotificationStatus:.idle
else Attention with specific body
CLI->>Store: skip lifecycle update
end
Reviews (20): Last reviewed commit: "Refresh Swift file-length budget for hib..." | Re-trigger Greptile |
| static func notificationIndicatesBlocked(subtitle: String, body: String) -> Bool { | ||
| let haystacks = [subtitle, body] | ||
| for keyword in ["permission", "approve", "approval"] where | ||
| haystacks.contains(where: { $0.localizedCaseInsensitiveContains(keyword) }) { | ||
| return true | ||
| } | ||
| return false | ||
| } |
There was a problem hiding this comment.
Narrow keyword list may miss Claude permission-prompt variants
The set ["permission", "approve", "approval"] won't match Claude Code notification text like "Allow bash?", "Grant file access", or "Confirm tool use?". Any unmatched permission prompt is classified as idle (notificationIndicatesBlocked → false → .idle), so the agent would be hibernated mid-tool-call and the in-flight prompt state dropped. The PR says the keywords mirror classifyAgentHookNotification — are the two keyword sets kept in sync? Does classifyAgentHookNotification use the same three keywords, and has Claude Code's full set of permission/approval prompt wording been verified to always contain at least one of them?
| static func resolved( | ||
| from states: some Collection<AgentHibernationLifecycleState>, | ||
| fallback: AgentHibernationLifecycleState? | ||
| ) -> AgentHibernationLifecycleState { | ||
| guard !states.isEmpty else { return fallback ?? .unknown } | ||
| if states.contains(.running) { return .running } | ||
| if states.contains(.needsInput) { return .needsInput } | ||
| if states.contains(.idle) { return .idle } | ||
| if states.contains(.unknown) { return .unknown } | ||
| return fallback ?? .unknown | ||
| } |
There was a problem hiding this comment.
Unreachable final return in
resolved
AgentHibernationLifecycleState has exactly four cases and all four are checked exhaustively above. Given the non-empty guard at the top, states must contain at least one case, so one of the four contains branches always returns before this line. The trailing return fallback ?? .unknown is dead code and may create the false impression that there is a fifth state or an unhandled path.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resumed/restored agents were stuck at lifecycle=unknown and never re-hibernated. Two clobber paths: - SessionStart (on resume/relaunch) wrote agentLifecycle=unknown, destructively overwriting a previously-proven idle in the persisted store. - The live per-panel lifecycle is cleared at hibernation time (clearAgentPID), so no positive evidence survives to resume; and a resumed agent can relaunch under a brand-new session id, so the per-session persisted record cannot carry it either. Fixes (all hibernation-only; allowsHibernation stays == .idle): - AgentHibernationLifecycleState.preservingDefinitive: an indeterminate `unknown` never erases a proven idle/running/needsInput. - Apply it at the persisted-store chokepoint (ClaudeHookSessionStore.update) and skip the masking live-layer `unknown` write on SessionStart. - Apply it in Workspace.setAgentLifecycle so an incoming `unknown` never downgrades a panel's proven live state. - Seed idle on resume (resumeAgentHibernation): a hibernated agent is idle by construction, so restore that idle for the panel; the resumed process's SessionStart `unknown` is then suppressed by preservingDefinitive. - Claude stop persists lastNotificationStatus=idle for parity with generic agents. Verified on a tagged dev build: claude hibernates; grok hibernates AND re-hibernates after resume (previously stuck unknown); codex unchanged. Tests: unit tests for preservingDefinitive + resolver/effective/notification helpers; CLI-subprocess persistence test that a SessionStart preserves a prior idle in the store. Designed via a multi-agent workflow (3 independent designs + adversarial review). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up: fixed the hibernation "one-shot" bug (resume/restart)Live dogfood after the initial fix surfaced (and the adversarial review predicted) that hibernation was effectively one-shot: once an agent was resumed (focus a hibernated agent, or app relaunch/session-restore), it got stuck at
Fix (designed via a multi-agent workflow + adversarial review, all hibernation-only so notifications/sidebar are untouched):
Verified live (tagged build, aggressive demo thresholds): claude hibernates; grok hibernates AND re-hibernates after resume (the exact case that failed before); codex unchanged. Added a CLI-subprocess persistence test (a SessionStart preserves a prior idle in the store) + unit tests for Remaining narrow follow-up (low severity): app-relaunch + new-session-id agents rely on the per-session persisted record (no in-app resume event to seed idle); fully covering that needs per-surface lifecycle persistence. Tracked separately. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CLI/cmux.swift`:
- Around line 27858-27873: The SessionStart live write is currently gated only
by mapped?.agentLifecycle which misses records that are effectively idle via
lastNotificationStatus, causing an undesired live `.unknown` to overwrite the
persisted fallback; update the guard before calling setAgentLifecycle (the call
with client, def.statusKey, lifecycle: .unknown, workspaceId, surfaceId) to use
the same effective-lifecycle logic used elsewhere (i.e., consider
mapped.agentLifecycle and mapped.lastNotificationStatus together) — only call
setAgentLifecycle when the computed effective lifecycle is not already
definitive (fold lastNotificationStatus into the guard or compute
effectiveLifecycle and check it) so you skip writing `.unknown` when the
persisted/preserved state already proves a definitive lifecycle.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 91408ec7-d49a-4afe-80f4-6a3dc25839f7
📒 Files selected for processing (6)
CLI/cmux.swiftSources/AgentHibernation/AgentHibernationLifecycleState.swiftSources/Workspace.swiftcmuxTests/AgentHibernationLifecycleResolutionTests.swiftcmuxTests/AgentHibernationTests.swiftcmuxTests/CLIGenericHookPersistenceTests.swift
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dismissed: CodeRabbit now posts non-blocking comment reviews (request_changes_workflow=false, #5538).
Adds testGeminiAfterAgentPersistsIdleLifecycle verifying the same four invariants as the Codex test on the generic runGenericAgentHook path: 1. AfterAgent (stop) persists agentLifecycle=idle + lastNotificationStatus=idle 2. SessionStart preserves idle (the hibernation-one-shot fix) 3. Running lifecycle survives SessionStart 4. NeedsInput (permission notification) lifecycle survives SessionStart Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… bugs P1: testClaudeAskUserQuestionNotificationPreservesNeedsInput A Claude Notification with a generic body (no "permission/approve" keywords) currently overwrites the needsInput lifecycle set by a prior AskUserQuestion PreToolUse, making the still-blocked agent hibernation-eligible. This test exercises the session-start → pre-tool-use → notification sequence and asserts that needsInput is preserved after the notification. P2: testGenericSessionStartSkipsLiveUnknownWhenEffectiveLifecycleIsIdle A generic agent session record with lastNotificationStatus=idle but no explicit agentLifecycle should not receive a live set_agent_lifecycle unknown on SessionStart. The guard currently checks only the raw agentLifecycle field (nil → push unknown), ignoring the effective() fallback that would return .idle. This test pre-seeds a notification-status-only idle session and asserts that SessionStart does not push unknown to the live map. Both tests fail without the corresponding fixes. CI goes red first. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… agents
P1: Claude Notification must not downgrade needsInput set by AskUserQuestion
The notification handler classified a turn as .idle when the notification body
had no "permission/approve" keywords, then overwrote the .needsInput lifecycle
unconditionally. If AskUserQuestion's PreToolUse had just set .needsInput, the
agent was blocked waiting for the user's answer, but the subsequent Notification
made it hibernation-eligible. Fix: skip the downgrade when the classified
lifecycle is .idle but the stored lifecycle is already .needsInput.
P2: Generic SessionStart must not push live .unknown when effective lifecycle is idle
The guard `!(mapped?.agentLifecycle.map { $0 != .unknown } ?? false)` only
checked the raw persisted agentLifecycle. For plugin/wrapper agents that record
idle via lastNotificationStatus but never emit agentLifecycle directly (e.g.
opencode), the raw value is nil → the guard evaluated to true → SessionStart
pushed set_agent_lifecycle <key> unknown to the live map. The live .unknown then
masked the effective().idle fallback that Workspace.agentHibernationLifecycleState
uses to classify such records, so the agent could never be hibernated after restart.
Fix: use effective() to compute the proven lifecycle before applying the guard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8af0503b59
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| agentLifecycle: mapped?.agentLifecycle, | ||
| lastNotificationStatus: mapped?.lastNotificationStatus?.rawValue | ||
| ) | ||
| if !(mappedEffectiveLifecycle.map { $0 != .unknown } ?? false) { |
There was a problem hiding this comment.
Avoid masking idle fallback for new session ids
When a resumed/relaunched agent reports a brand-new session_id, mapped is nil, so this guard still sends a live .unknown. Workspace.agentHibernationLifecycleState then resolves a non-empty live state before the persisted index fallback (resolved(from: [.unknown], fallback: .idle) returns .unknown), so the previous idle completion stored for that panel is masked and the quiescent agent remains ineligible for re-hibernation. The same preservation needs to account for the panel's persisted/effective fallback, not just records found by the incoming session id.
Useful? React with 👍 / 👎.
… transient Two fixes for autoreview P1s from run 8: P1-1 (startup window guard is durable): recordTerminalInput gains durable: Bool = true. The startup-window call in resumeAgentHibernation passes durable: false so a crash-on-resume doesn't leave a stale durableTerminalInputAt that permanently blocks re-hibernation on the next app launch. In-memory guard still fires during the startup window within the session. P1-2 (new-session resumes clobber seeded idle): Workspace.setAgentLifecycle gains a preserveIdle parameter that scopes preservingDefinitive to callers that explicitly opt in. The socket command set_agent_lifecycle gains --preserve-idle; the CLI passes it only on SessionStart hooks. This fixes the hibernation-is-one-shot regression for agents that relaunch under a new session ID on resume while preserving the API contract for direct callers (no flag = incoming value wins unconditionally). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Converts XCTest → @suite struct / @test / #expect / #require per the Swift Testing policy for touched non-UI test files. CLIGenericHookPersistenceTests.swift is an extension on the 8500-line XCTestCase parent class CLINotifyProcessIntegrationRegressionTests; migrating it independently requires extracting all shared self.* helpers or migrating the parent, so it is deferred with an inline comment explaining the constraint. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cc0e5c9073
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // and allow premature hibernation mid-turn. Definitive updates (including | ||
| // repeated `.idle`) still advance lifecycleChangeAt so hasUnconfirmedTerminalInput | ||
| // clears normally after the resumed agent completes its first turn. | ||
| if resolved != .unknown { |
There was a problem hiding this comment.
Avoid confirming preserved idle on unknown SessionStart
When --preserve-idle handles a resumed agent's SessionStart, an incoming .unknown can resolve to the existing seeded .idle, so this condition records a lifecycle change even though the event carried no definitive lifecycle. In the resume path with queued startup input, recordTerminalInput(... durable: false) is intended to keep terminalInputAt > lifecycleChangeAt until the agent emits a real .idle/.running/.needsInput; an immediate unknown SessionStart after that advances lifecycleChangeAt past the startup input and clears hasUnconfirmedTerminalInput, allowing the hibernation planner to re-hibernate the panel during the startup command window. This check needs to be based on the incoming lifecycle being definitive, not the preserved resolved value.
Useful? React with 👍 / 👎.
| if resolved != .unknown { | ||
| recordAgentLifecycleChange(panelId: targetPanelId) | ||
| } |
There was a problem hiding this comment.
The guard uses
resolved != .unknown rather than the incoming lifecycle != .unknown, so when --preserve-idle preserves an existing .idle from a .unknown SessionStart, lifecycleChangeAt advances to T_sessionStart. This contradicts the comment immediately above ("An .unknown SessionStart must not push the timestamp past a recent terminalInputAt").
Concretely: no-emit agents (opencode, etc.) with a new session ID on resume trigger this path. The resume seeds .idle at T_resume, records a non-durable startup input at T_resume, then the SessionStart fires at T_sessionStart > T_resume with --preserve-idle. resolved = .idle (preserved), so recordAgentLifecycleChange advances lifecycleChangeAt = T_sessionStart. Now hasUnconfirmedTerminalInput = T_resume > T_sessionStart = false. If the agent's first response takes longer than idleSeconds, the planner sees lifecycle=.idle, hasUnconfirmedTerminalInput=false, and hibernates a mid-turn agent — exactly the scenario the startup guard was added to prevent.
| if resolved != .unknown { | |
| recordAgentLifecycleChange(panelId: targetPanelId) | |
| } | |
| // Use the original incoming `lifecycle` rather than `resolved`: when | |
| // --preserve-idle preserves an existing `.idle` from a `.unknown` SessionStart | |
| // the resolved value is `.idle` (non-unknown), but this is semantically a | |
| // SessionStart event — the agent has NOT completed a turn. Advancing | |
| // lifecycleChangeAt here would push the baseline past the startup-window | |
| // terminalInputAt recorded in resumeAgentHibernation, silently clearing the | |
| // hasUnconfirmedTerminalInput guard and letting a slow-starting no-emit agent | |
| // be hibernated mid-turn. | |
| if lifecycle != .unknown { | |
| recordAgentLifecycleChange(panelId: targetPanelId) | |
| } |
…hangeAt When preservingDefinitive keeps .idle over an incoming .unknown SessionStart, the old guard (if resolved != .unknown) incorrectly advanced lifecycleChangeAt because `resolved` is .idle. This pushed the timestamp past mid-turn terminal input events and cleared hasUnconfirmedTerminalInput, allowing no-emit agents to hibernate while actively processing a turn. Fix: gate timestamp advancement on `lifecycle != .unknown` (the caller's intent) rather than `resolved != .unknown` (the post-preservation value). Add test preserveIdleSessionStartDoesNotAdvanceInMemoryLifecycleChangeAt to pin this. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…l semantics Swift Testing runs suites concurrently by default. Tests that call TerminalController.shared and TerminalMutationBus.shared must run serially to avoid nondeterministic failures, matching the serial execution guarantee that XCTestCase provided before the migration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a0edb86fa2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| writeTimer.schedule(deadline: .now() + 1, repeating: .never) | ||
| writeTimer.setEventHandler { [weak self] in self?.flushDurableInputStore() } |
There was a problem hiding this comment.
Flush pending input before restart
When a user types a new prompt and then quits/reloads cmux before this one-second timer fires, the new durable terminal-input timestamp is never written (and stop() cancels the pending timer without flushing). After restart, the planner falls back to the old persisted idle lifecycle with no durable terminalInputAt > lifecycleUpdatedAt guard, so the no-emit/plugin agent path this store is meant to protect can still be hibernated mid-turn.
Useful? React with 👍 / 👎.
…mal quit The debounce timer fires 1s after the last keystroke. If the app quits during that window, stop() cancelled the timer without flushing, discarding the in-memory durable terminal-input timestamp. On next launch the planner saw the old persisted .idle lifecycle with no durable input, allowing premature hibernation of no-emit agents mid-turn. Fix: flushDurableInputStore(synchronous: true) via timerQueue.sync in stop() when a write is pending, ensuring normal-quit paths complete the write before the process exits. The debounced async path is unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
timerQueue is used exclusively for file I/O; @mainactor protects all mutable state. The .sync call is a termination-safety block (block until write done), not a synchronization primitive over shared state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ents # Conflicts: # Sources/TerminalController.swift # cmux.xcodeproj/project.pbxproj
…utionTests vs RendererRealizationController) The merge of origin/main surfaced a latent pbxproj UUID collision: this PR assigned D36A00040000000000000001/0002 to AgentHibernationLifecycleResolutionTests.swift, and main's offscreen-renderer work (#5857) independently used the same IDs for Sources/App/RendererRealizationController.swift. After the merge both objects shared one UUID, so the cmuxTests group resolved D36A0004...0002 to App/RendererRealizationController.swift -> cmuxTests/App/RendererRealizationController.swift (a nonexistent path), failing the build. Re-ID the test file to the unused D36A00070000000000000001/0002 so each UUID defines exactly one object. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merging origin/main stacked this PR's growth on top of main's, pushing six files the hibernation work touches past their tracked budgets. Refresh the budget to the post-merge actual lengths (the sanctioned "accept known debt" update): CLI/cmux.swift, Sources/Workspace.swift, Sources/RestorableAgentSession.swift, Sources/App/AgentHibernationController.swift (newly tracked), and the two hibernation test files. No unrelated entries change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 7eac4ce. Configure here.
| cwd: parsedInput.cwd, | ||
| transcriptPath: parsedInput.transcriptPath, | ||
| agentLifecycle: .needsInput, | ||
| agentLifecycle: hibernationLifecycle, |
There was a problem hiding this comment.
Claude idle skips running guard
Medium Severity
Claude hibernation lifecycle treats completion-style notifications as idle but only refuses to downgrade an existing stored needsInput. If the hook store still has running (e.g. notification before Stop), the upsert can persist idle and push live idle, making a mid-turn agent hibernation-eligible.
Reviewed by Cursor Bugbot for commit 7eac4ce. Configure here.
| lifecycle: hibernationLifecycle, | ||
| workspaceId: workspaceId, | ||
| surfaceId: surfaceId | ||
| ) |
There was a problem hiding this comment.
Claude lacks newer-session guard
Medium Severity
When Claude’s notification handler sets hibernation lifecycle to idle, it does not use the generic hook’s hasNewerRunningSession checks. A stale idle-classified notification for an older session can persist and publish idle while another session on the same surface is still running, incorrectly allowing hibernation.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 7eac4ce. Configure here.
| @@ -114,11 +189,17 @@ final class AgentHibernationController { | |||
| } | |||
| } | |||
There was a problem hiding this comment.
stop() synchronous flush is never called at process exit
flushDurableInputStore(synchronous: true) was added here explicitly so "normal-quit paths write the safety timestamp before the process exits," but AgentHibernationController.shared.stop() is never called from applicationWillTerminate — only TerminalController.shared.stop() is (line 1910 of AppDelegate.swift). The synchronous flush is unreachable in production.
The practical consequence: if the app exits or crashes within the 1-second debounce window after terminal input, the input timestamp is not persisted to disk. On restart durableTerminalInputByPanelId won't reflect that input, so hasUnconfirmedTerminalInput evaluates false, and a stale-idle agent could be re-hibernated during the very next planner tick — the exact scenario the durable store was introduced to prevent.
Either wire AgentHibernationController.shared.stop() into applicationWillTerminate, or register the flush directly from applicationWillTerminate via a NotificationCenter observer in start().


Problem
Agent Hibernation only hibernates sessions whose lifecycle resolves to
idle. In practice only codex ever reachedidle— claude, opencode, and the other hook agents stayed live forever even when idle off-screen. Reported during dogfood ("max live agent terminals is 1 but only codex is hibernating").Runtime evidence (instrumented build, live agents): claude's live lifecycle oscillated
idle → needsInput(Stop set idle, then a notification clobbered it); plugin agents resolved tounknownwith an empty live dict.Root causes (three layers, all hibernation-only)
The agent-lifecycle pipeline (
agentLifecycleStatesByPanelId+ the persistedagentLifecycle) has a single consumer:AgentHibernationController. The sidebar/notification badges use separate fields. So these changes do not affect notification orneedsInputUX.Workspace.agentHibernationLifecycleState):unknownoutrankedidle, so a real idle was permanently masked by any agent that also reported an indeterminate status. Nowrunning > needsInput > idle > unknown.RestorableAgentSession): plugin/no-emit agents (e.g. opencode) never emit a live lifecycle but do record a completion notification; treatlastNotificationStatus == idleasidleso they become eligible like codex.CLI/cmux.swiftclaude hook): Claude fires a "waiting for your input" notification after every turn, which was hard-coded toneedsInputand overwrote the Stop hook's idle. Now only a permission/approval prompt staysneedsInput; the plain ready/waiting notification resolves toidle. The user notification + sidebar status are unchanged.The decision logic is consolidated into pure, unit-tested statics on
AgentHibernationLifecycleState(resolved/effective/notificationIndicatesBlocked).Verification
Dogfooded on a tagged dev build with live agents (idle window/cap lowered for the test):
read-screenreturns "Failed to read terminal text" = surface freed). Previously never hibernated.needsInput).Unit tests in
cmuxTests/AgentHibernationLifecycleResolutionTests.swiftcover the priority resolution (idle not masked by unknown), the completion fallback, and the permission-vs-ready notification classification.Known remaining per-agent gaps (separate follow-ups)
These are agent-integration gaps the centralized lifecycle fix can't reach; filing separately:
agentLifecycle=unknown, nolastNotificationStatus) — its stop hook needs to emit idle.Agents not installed on the dev machine (copilot, factory, codebuddy, qoder, kiro, rovodev, antigravity) are covered by the centralized fallback to the extent they record a completion signal; the mapping is unit-tested, but their runtime behavior is not verified here.
🤖 Generated with Claude Code
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.Note
Medium Risk
Touches hibernation eligibility, persisted hook state, and process-kill timing across many agent integrations; misclassification could hibernate mid-turn or leave agents live, though scope is hibernation-only (not notification UX).
Overview
Fixes agent hibernation so idle off-screen sessions can hibernate for Claude, plugin/no-emit agents, and after resume—not only Codex on the first pass.
Lifecycle resolution is centralized on
AgentHibernationLifecycleState(resolved,effective,preservingDefinitive,notificationIndicatesBlocked). Panel state prefers running / needsInput over idle over unknown; persisted index entries can treatlastNotificationStatus == idleas idle when no definitive lifecycle was emitted.CLI hooks merge lifecycle with
preservingDefinitive, tracklifecycleUpdatedAt(definitive updates only), and advance it on idle notifications for agents that never callset_agent_lifecycle. Claude stop persists idle notification status; notifications classify permission/error vs turn-complete (no longer blanketneedsInput). Generic SessionStart skips live.unknownwhen effective lifecycle is already idle and usesset_agent_lifecycle … --preserve-idlewhen needed.App side:
Workspaceapplies--preserve-idle, only advances in-memory lifecycle timestamps on definitive incoming values, and reseeds.idleon hibernation resume (with optional non-durable startup input guard).AgentHibernationControllerpersists terminal-input times to disk and compares input againstlifecycleUpdatedAt, not genericupdatedAt.Tests: new lifecycle resolution suite, expanded hibernation/planner tests, and CLI integration coverage for SessionStart survival and Claude notification edge cases.
Reviewed by Cursor Bugbot for commit 7eac4ce. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit
New Features
Bug Fixes
Refactor
Tests