Skip to content

Fix Agent Hibernation lifecycle for non-codex agents - #5500

Closed
lawrencecchen wants to merge 32 commits into
mainfrom
feat-hibernate-all-agents
Closed

lawrencecchen wants to merge 32 commits into
mainfrom
feat-hibernate-all-agents

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 6, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Agent Hibernation only hibernates sessions whose lifecycle resolves to idle. In practice only codex ever reached idle — 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 to unknown with an empty live dict.

Root causes (three layers, all hibernation-only)

The agent-lifecycle pipeline (agentLifecycleStatesByPanelId + the persisted agentLifecycle) has a single consumer: AgentHibernationController. The sidebar/notification badges use separate fields. So these changes do not affect notification or needsInput UX.

  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 eligible like codex.
  3. Claude notification clobber (CLI/cmux.swift claude hook): Claude fires a "waiting for your input" notification after every turn, which was hard-coded to needsInput and overwrote the Stop hook's idle. Now only a permission/approval prompt stays needsInput; the plain ready/waiting notification resolves to idle. 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):

  • claude: now hibernates (read-screen returns "Failed to read terminal text" = surface freed). Previously never hibernated.
  • grok: now hibernates.
  • codex: still hibernates (baseline).
  • gemini: correctly stays live while mid-prompt (genuinely needsInput).

Unit tests in cmuxTests/AgentHibernationLifecycleResolutionTests.swift cover 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:

  • gemini emits no completion signal at all (agentLifecycle=unknown, no lastNotificationStatus) — its stop hook needs to emit idle.
  • opencode (and other plugin agents) don't always register a restorable session, so they're skipped before lifecycle is even consulted.

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


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with 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 treat lastNotificationStatus == idle as idle when no definitive lifecycle was emitted.

CLI hooks merge lifecycle with preservingDefinitive, track lifecycleUpdatedAt (definitive updates only), and advance it on idle notifications for agents that never call set_agent_lifecycle. Claude stop persists idle notification status; notifications classify permission/error vs turn-complete (no longer blanket needsInput). Generic SessionStart skips live .unknown when effective lifecycle is already idle and uses set_agent_lifecycle … --preserve-idle when needed.

App side: Workspace applies --preserve-idle, only advances in-memory lifecycle timestamps on definitive incoming values, and reseeds .idle on hibernation resume (with optional non-durable startup input guard). AgentHibernationController persists terminal-input times to disk and compares input against lifecycleUpdatedAt, not generic updatedAt.

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

    • Notifications are analyzed to detect user-blocking prompts (e.g., permission/approval), improving agent classification and hibernation decisions.
  • Bug Fixes

    • Persisted agent lifecycle and last-notification status are preserved across resume/start so proven states (idle/running/needsInput) aren’t overwritten; restored snapshots reseed idle correctly.
  • Refactor

    • Lifecycle resolution and fallback logic consolidated for more reliable hibernation eligibility.
  • Tests

    • Added comprehensive tests for lifecycle resolution, notification classification, persistence, and planner hibernation behavior.

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>
@vercel

vercel Bot commented Jun 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 12, 2026 10:08pm
cmux-staging Building Building Preview, Comment Jun 12, 2026 10:08pm

@coderabbitai

coderabbitai Bot commented Jun 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds 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.

Changes

Agent Hibernation Lifecycle Resolution

Layer / File(s) Summary
Lifecycle helpers and unit tests
Sources/AgentHibernation/AgentHibernationLifecycleState.swift, cmuxTests/AgentHibernationLifecycleResolutionTests.swift
Adds preservingDefinitive(existing:incoming:), resolved(from:fallback:), effective(agentLifecycle:lastNotificationStatus:), and notificationIndicatesBlocked(subtitle:body:); unit tests validate resolution priorities, effective fallback, notification blocked detection, preserving merge semantics, and hibernation eligibility.
CLI notification & session persistence integration
CLI/cmux.swift, Sources/RestorableAgentSession.swift, cmuxTests/CLIGenericHookPersistenceTests.swift
CLI computes hibernationLifecycle from notification text (permission/approval → .needsInput, otherwise .idle) and uses it for sessionStore.upsert() and setAgentLifecycle(); RestorableAgentHookSessionRecord records lastNotificationStatus and exposes effectiveHibernationLifecycle used by the session index; integration tests verify SessionStart/Stop resume semantics for Codex, Claude, and Gemini.
Workspace aggregation & planner tests
Sources/Workspace.swift, cmuxTests/AgentHibernationTests.swift
Workspace now uses AgentHibernationLifecycleState.resolved(from:fallback:) to compute panel lifecycle, seeds restored snapshots as .idle on resume, and planner tests validate re-hibernation after resume and exclusion for no-emit agents with unconfirmed terminal input.
Test build registration
cmux.xcodeproj/project.pbxproj
Adds AgentHibernationLifecycleResolutionTests.swift to the cmuxTests target build sources.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • manaflow-ai/cmux#4165: Introduced earlier lifecycle tracking and set_agent_lifecycle wiring that this PR refines.

Poem

🐰 I nudged the lifecycle into place,
Merged unknowns with careful grace,
If "approve" or "permission" shows,
I mark it blocked so nothing doze,
Now agents nap in the right space.


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Cmux Source Artifacts ❌ Error .claude/scheduled_tasks.lock is a local tool output file with runtime PID/session data, violating the artifact rule which forbids such files and lists .claude/ as problematic. Remove .claude/scheduled_tasks.lock from the commit. Add it to .gitignore if .claude/ is intended as configuration.
Docstring Coverage ⚠️ Warning Docstring coverage is 47.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Cmux Cache Substitution Correctness ❓ Inconclusive The custom check references .github/review-bot-rules/cache-substitution-correctness.md, which does not exist in the repository. Cannot assess compliance without the rule file. Create the missing rule file or update the check to reference an existing rule. The PR does use persisted cache as fallback only when live states are empty, with event-driven updates.
✅ Passed checks (18 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix Agent Hibernation lifecycle for non-codex agents' directly addresses the main objective—enabling hibernation for Claude, Grok, and other non-Codex agents by fixing lifecycle resolution logic.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Pure static methods added to Sendable enum AgentHibernationLifecycleState. New types properly marked Sendable. Used only in @MainActor Workspace. No mutable shared state. Tests excluded.
Cmux Swift Blocking Runtime ✅ Passed No blocking runtime synchronization patterns detected in production code. New code contains pure logic helpers and deterministic tests only.
Cmux Expensive Synchronous Load ✅ Passed PR adds no expensive synchronous loaders. Two pre-existing RestorableAgentSessionIndex.load() calls remain unchanged and handled correctly via SharedLiveAgentIndex caching.
Cmux No Hacky Sleeps ✅ Passed PR contains only Swift and Xcode files; runtime-no-hacky-sleeps applies only to TypeScript, JavaScript, shell, and build scripts. Swift issues covered by separate check.
Cmux Algorithmic Complexity ✅ Passed New code operates on tiny fixed collections (max 16 agents per panel). Four contains() calls on 16-item collections achieve O(panels) linear-time, meeting rules for explicit tiny bounds.
Cmux Swift Concurrency ✅ Passed PR introduces only synchronous static helper functions for agent hibernation lifecycle logic with no legacy async patterns, no Dispatch queues, no Combine, and no completion handlers.
Cmux Swift @Concurrent ✅ Passed All new Swift methods are pure, lightweight, synchronous functions with no async work. No @concurrent violations detected.
Cmux Swift File And Package Boundaries ✅ Passed PR adds <250 lines to oversized files (CLI/cmux.swift +45, Workspace.swift +31), creates new 151-line enum with single responsibility, and has clear extraction path with comprehensive unit tests.
Cmux Swift Logging ✅ Passed No logging violations found in hibernation PR files: all code complies with swift-logging.md rules; no print, NSLog, debug logging, or file I/O added.
Cmux User-Facing Error Privacy ✅ Passed PR changes are internal lifecycle logic and tests only. No user-facing error messages, alerts, vendor names, credentials, or sensitive data exposed.
Cmux Full Internationalization ✅ Passed PR introduces no new user-facing strings; changes are internal hibernation logic and test-only code, both permitted by i18n policy.
Cmux Swiftui State Layout ✅ Passed PR introduces only pure Swift model logic and existing legacy @Published state touches incidentally; no new SwiftUI state decorators, GeometryReader, lazy view store refs, or render-time mutations.
Cmux Architecture Rethink ✅ Passed Pure functional helpers, single owner state, no timing repairs, observers, locks, or split lifecycle patterns. Documented invariants per rules.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds no NSWindow, NSPanel, NSWindowController, or SwiftUI Window/WindowGroup code. All changes are to internal agent hibernation lifecycle state management, not window UI.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering the problem, root causes, solution, and verification.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-hibernate-all-agents

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread CLI/cmux.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread CLI/cmux.swift Outdated
Comment on lines +21550 to +21554
let hibernationLifecycle: AgentHibernationLifecycleState =
AgentHibernationLifecycleState.notificationIndicatesBlocked(
subtitle: summary.subtitle,
body: summary.body
) ? .needsInput : .idle

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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-apps

greptile-apps Bot commented Jun 6, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes 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 running > needsInput > unknown > idle; plugin/no-emit agents fall back to lastNotificationStatus == \"idle\" when no explicit lifecycle was emitted; and claude's per-notification hook classifies benign "waiting" notifications as idle instead of always emitting needsInput.

  • Lifecycle consolidation: AgentHibernationLifecycleState gains four static helpers (resolved, effective, preservingDefinitive, notificationIndicatesBlocked); Workspace.setAgentLifecycle gains a --preserve-idle path so SessionStart .unknown cannot overwrite a resume-seeded .idle.
  • Durable terminal-input store: AgentHibernationController persists input timestamps with a 1-second debounce; a synchronous flush in stop() is meant to run at process exit but stop() is never called from applicationWillTerminate.
  • RestorableAgentSessionIndex: exposes lifecycleUpdatedAt (advances only on definitive lifecycle events) so hasUnconfirmedTerminalInput has a durable baseline after restarts.

Confidence Score: 3/5

The 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

Filename Overview
Sources/App/AgentHibernationController.swift Adds durable terminal-input persistence (debounce write, synchronous flush in stop(), pruning); the synchronous flush is wired inside stop() but stop() is never called at process exit, leaving the intended process-exit safety mechanism inactive.
CLI/cmux.swift Three-layer fix: adds lifecycleUpdatedAt to ClaudeHookSessionRecord (used for all agents), applies preservingDefinitive on SessionStart, and classifies Claude notifications into idle/needsInput for hibernation only without touching user-facing UX.
Sources/Workspace.swift Replaces clearAgentLifecycleStates on hibernation-resume with a targeted idle seed; adds preserveIdle parameter to setAgentLifecycle; correctly gates lifecycleChangeAt advancement on the incoming (not resolved) lifecycle.
Sources/AgentHibernation/AgentHibernationLifecycleState.swift Adds four static helpers that centralise hibernation eligibility logic; priority fix and completion-notification fallback are correctly implemented and well-tested.
Sources/RestorableAgentSession.swift Adds lastNotificationStatus and lifecycleUpdatedAt to RestorableAgentHookSessionRecord; exposes effectiveHibernationLifecycle and lifecycleUpdatedAt on the index entry.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (20): Last reviewed commit: "Refresh Swift file-length budget for hib..." | Re-trigger Greptile

Comment on lines +63 to +70
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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?

Comment on lines +27 to +37
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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>
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

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 lifecycle=unknown and never re-hibernated. Root causes:

  • SessionStart on resume wrote agentLifecycle=unknown, clobbering the proven idle in the persisted store.
  • The live per-panel lifecycle is wiped at hibernation time (clearAgentPID), so no positive evidence survives to resume; and some agents (e.g. grok) relaunch under a new session id on --resume, so the per-session persisted record can't carry it either.

Fix (designed via a multi-agent workflow + adversarial review, all hibernation-only so notifications/sidebar are untouched):

  • preservingDefinitive(existing:incoming:) — an unknown never erases a proven idle/running/needsInput.
  • Applied at the persisted-store chokepoint, the live-layer SessionStart write, and Workspace.setAgentLifecycle.
  • Seed idle on resume (resumeAgentHibernation): a hibernated agent is idle by construction, so the panel's idle is restored; the resumed process's SessionStart unknown is then suppressed.

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 preservingDefinitive.

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.

Comment thread Sources/Workspace.swift Outdated
coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 6, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between a6cb2d7 and dca4feb.

📒 Files selected for processing (6)
  • CLI/cmux.swift
  • Sources/AgentHibernation/AgentHibernationLifecycleState.swift
  • Sources/Workspace.swift
  • cmuxTests/AgentHibernationLifecycleResolutionTests.swift
  • cmuxTests/AgentHibernationTests.swift
  • cmuxTests/CLIGenericHookPersistenceTests.swift

Comment thread CLI/cmux.swift Outdated
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread CLI/cmux.swift
@lawrencecchen
lawrencecchen dismissed coderabbitai[bot]’s stale review June 6, 2026 11:31

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>
lawrencecchen and others added 2 commits June 10, 2026 16:52
… 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>
Comment thread cmuxTests/AgentHibernationTests.swift

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread CLI/cmux.swift
agentLifecycle: mapped?.agentLifecycle,
lastNotificationStatus: mapped?.lastNotificationStatus?.rawValue
)
if !(mappedEffectiveLifecycle.map { $0 != .unknown } ?? false) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread CLI/cmux.swift
lawrencecchen and others added 2 commits June 12, 2026 00:18
… 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>
Comment thread Sources/Workspace.swift

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread Sources/Workspace.swift Outdated
// 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread Sources/Workspace.swift Outdated
Comment on lines +12308 to +12310
if resolved != .unknown {
recordAgentLifecycleChange(panelId: targetPanelId)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +143 to +144
writeTimer.schedule(deadline: .now() + 1, repeating: .never)
writeTimer.setEventHandler { [weak self] in self?.flushDurableInputStore() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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>
lawrencecchen and others added 2 commits June 12, 2026 14:19
…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>
Comment thread Sources/Workspace.swift
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread CLI/cmux.swift
cwd: parsedInput.cwd,
transcriptPath: parsedInput.transcriptPath,
agentLifecycle: .needsInput,
agentLifecycle: hibernationLifecycle,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7eac4ce. Configure here.

Comment thread CLI/cmux.swift
lifecycle: hibernationLifecycle,
workspaceId: workspaceId,
surfaceId: surfaceId
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7eac4ce. Configure here.

Comment on lines 173 to 190
@@ -114,11 +189,17 @@ final class AgentHibernationController {
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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().

This branch was successfully deployed

1 active deployment
Preview – cmux — 7eac4ceb Deployed Jun 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants