diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 5708e925e77f..789a7c1b3f90 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,8 +1,8 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -33285 CLI/cmux.swift -19985 Sources/Workspace.swift +33368 CLI/cmux.swift +20028 Sources/Workspace.swift 19265 Sources/ContentView.swift 18118 Sources/AppDelegate.swift 16674 Sources/GhosttyTerminalView.swift @@ -22,13 +22,13 @@ 5482 cmuxTests/BrowserConfigTests.swift 5462 Sources/cmuxApp.swift 4827 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +4532 cmuxTests/CLIGenericHookPersistenceTests.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift 4227 Sources/BrowserWindowPortal.swift 4009 cmuxTests/WindowAndDragTests.swift 3937 Sources/Feed/FeedPanelView.swift 3760 cmuxTests/TabManagerUnitTests.swift -3699 cmuxTests/CLIGenericHookPersistenceTests.swift 3665 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3396 Sources/CmuxConfig.swift 3316 cmuxTests/TabManagerSessionSnapshotTests.swift @@ -51,8 +51,8 @@ 1949 Sources/Panels/BrowserWebAuthnSupport.swift 1860 cmuxTests/NotificationAndMenuBarTests.swift 1793 Sources/SessionIndexStore.swift +1771 Sources/RestorableAgentSession.swift 1751 Sources/WindowDragHandleView.swift -1744 Sources/RestorableAgentSession.swift 1721 cmuxTests/TerminalControllerSocketSecurityTests.swift 1693 cmuxTests/WorkspacePullRequestSidebarTests.swift 1677 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -66,6 +66,7 @@ 1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1376 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift 1372 cmuxTests/AppDelegateIssue2907RoutingTests.swift +1367 cmuxTests/AgentHibernationTests.swift 1365 Sources/Feed/FeedButtonStyleDebugWindowController.swift 1362 Sources/CMUXInstalledExtensionSidebarHostView.swift 1313 cmuxTests/MobileHostAuthorizationTests.swift @@ -80,7 +81,6 @@ 1107 Sources/AppDelegate+CmuxSSHURL.swift 1096 Sources/GhosttyConfig.swift 1093 cmuxUITests/BonsplitTabDragUITests.swift -1084 cmuxTests/AgentHibernationTests.swift 1084 cmuxTests/RestorableAgentSessionIndexTests.swift 1021 cmuxUITests/TerminalCmdClickUITests.swift 1006 cmuxTests/CmuxSSHURLRequestTests.swift @@ -153,6 +153,7 @@ 588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift 586 Sources/JSONCParser.swift 585 Sources/Cloud/VMClient.swift +582 Sources/App/AgentHibernationController.swift 580 Packages/CmuxExtensionKit/Tests/CmuxExtensionKitTests/CmuxExtensionKitTests.swift 580 cmuxTests/CLIHookNoResponseTests.swift 578 cmuxUITests/FeedSidebarUITests.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0b99b1502626..1006d0b180ea 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -453,6 +453,9 @@ private struct ClaudeHookSessionRecord: Codable { var terminalPromptTurnIds: [String]? var startedAt: TimeInterval var updatedAt: TimeInterval + // Only advances when agentLifecycle is set to a definitive value (.idle/.running/.needsInput). + // Unlike updatedAt, does NOT advance on .unknown SessionStart upserts. + var lifecycleUpdatedAt: TimeInterval? = nil } private struct ClaudeHookActiveSessionRecord: Codable { @@ -1087,7 +1090,20 @@ private final class ClaudeHookSessionStore { record.isRestorable = isRestorable || record.isRestorable == true } if let agentLifecycle { - record.agentLifecycle = agentLifecycle + // A SessionStart on resume/relaunch reports `.unknown`; never let it + // erase a previously-proven definitive lifecycle, or a quiescent + // resumed agent stays stuck at `.unknown` and never re-hibernates. + record.agentLifecycle = AgentHibernationLifecycleState.preservingDefinitive( + existing: record.agentLifecycle, + incoming: agentLifecycle + ) + // Only advance lifecycleUpdatedAt for definitive updates. An .unknown + // SessionStart must not push this timestamp past a terminal-input + // timestamp recorded after the last idle notification (which would + // incorrectly clear the mid-turn input guard after a restart). + if agentLifecycle != .unknown { + record.lifecycleUpdatedAt = now + } } if let subtitle = normalizeOptional(lastSubtitle) { record.lastSubtitle = subtitle @@ -1097,6 +1113,14 @@ private final class ClaudeHookSessionStore { } if updateLastNotificationStatus { record.lastNotificationStatus = lastNotificationStatus + // A notification with idle status is a lifecycle event for agents that + // do not emit set_agent_lifecycle (e.g., opencode). Advance lifecycleUpdatedAt + // so the durable hasUnconfirmedTerminalInput guard has a valid baseline after + // restart; without this, lifecycleUpdatedAt stays nil and any persisted + // terminal-input timestamp permanently blocks hibernation for those agents. + if lastNotificationStatus == .idle { + record.lifecycleUpdatedAt = now + } } if updateRuntimeStatus { record.runtimeStatus = runtimeStatus @@ -22293,6 +22317,12 @@ struct CMUXCLI { agentLifecycle: .idle, lastSubtitle: completion?.subtitle, lastBody: completion?.body, + // Persist the idle notification status so claude's index + // fallback (effectiveHibernationLifecycle) resolves to idle + // even if agentLifecycle is later read as nil/unknown, + // matching the generic stop handler's semantics exactly. + lastNotificationStatus: .idle, + updateLastNotificationStatus: true, markActive: true, allowsNewSessionReplacement: true ) @@ -22495,6 +22525,35 @@ struct CMUXCLI { ) let payload = notificationPayload(title: title, subtitle: summary.subtitle, body: summary.body) + // Hibernation lifecycle only (the user-facing notification and sidebar + // status below are unchanged). Classification mirrors the generic path + // (classifyAgentHookNotification): + // - Permission/approval → .needsInput (blocked mid-tool, must stay live) + // - "Error" subtitle → .needsInput (mirrors generic .error → .needsInput) + // - "Attention" with a specific message → nil (informational; don't change + // lifecycle so an in-flight turn isn't prematurely marked idle) + // - Everything else ("Completed", "Waiting", generic attention fallback) → + // .idle (turn finished, safe to hibernate) + let classifiedLifecycle: AgentHibernationLifecycleState? + let notifSubtitle = summary.subtitle + if notifSubtitle == "Attention" && summary.body != "Claude needs your attention" { + classifiedLifecycle = nil + } else if notifSubtitle == "Error" + || AgentHibernationLifecycleState.notificationIndicatesBlocked( + subtitle: notifSubtitle, + body: summary.body + ) { + classifiedLifecycle = .needsInput + } else { + classifiedLifecycle = .idle + } + // A plain notification must not downgrade .needsInput set by a preceding + // AskUserQuestion PreToolUse: the agent is still blocked waiting for the + // user's answer, so hibernate eligibility must not change. + let hibernationLifecycle: AgentHibernationLifecycleState? = classifiedLifecycle.map { + $0 == .idle && mappedSession?.agentLifecycle == .needsInput ? .needsInput : $0 + } + if let sessionId = parsedInput.sessionId { try? sessionStore.upsert( sessionId: sessionId, @@ -22502,19 +22561,21 @@ struct CMUXCLI { surfaceId: surfaceId, cwd: parsedInput.cwd, transcriptPath: parsedInput.transcriptPath, - agentLifecycle: .needsInput, + agentLifecycle: hibernationLifecycle, lastSubtitle: summary.subtitle, lastBody: summary.body ) } - setAgentLifecycle( - client: client, - key: Self.claudeCodeStatusKey, - lifecycle: .needsInput, - workspaceId: workspaceId, - surfaceId: surfaceId - ) + if let hibernationLifecycle { + setAgentLifecycle( + client: client, + key: Self.claudeCodeStatusKey, + lifecycle: hibernationLifecycle, + workspaceId: workspaceId, + surfaceId: surfaceId + ) + } _ = try? setClaudeStatus( client: client, workspaceId: workspaceId, @@ -22826,15 +22887,17 @@ struct CMUXCLI { key: String, lifecycle: AgentHibernationLifecycleState, workspaceId: String, - surfaceId: String? + surfaceId: String?, + preserveIdle: Bool = false ) { guard Self.allowedAgentLifecycleStatusKeys.contains(key) else { fputs("Warning: unsupported agent lifecycle key\n", stderr) return } do { + let preserveFlag = preserveIdle ? " --preserve-idle" : "" _ = try sendV1Command( - "set_agent_lifecycle \(key) \(lifecycle.rawValue) --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + "set_agent_lifecycle \(key) \(lifecycle.rawValue) --tab=\(workspaceId)\(socketPanelOption(surfaceId))\(preserveFlag)", client: client ) } catch { @@ -28987,13 +29050,33 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { client: client ) } - setAgentLifecycle( - client: client, - key: def.statusKey, - lifecycle: .unknown, - workspaceId: workspaceId, - surfaceId: surfaceId + // SessionStart `.unknown` handling: + // - When the prior record proves a definitive lifecycle (e.g. `.idle` + // from a same-session resume): skip the write entirely, keeping the + // live map empty so Workspace.agentHibernationLifecycleState falls + // through to the persisted fallback. + // - Otherwise (new session, fresh agent, or prior record indeterminate): + // send `.unknown --preserve-idle` so Workspace's preservingDefinitive + // keeps any resume-seeded `.idle` alive even when the new session ID + // has no prior store record (mapped == nil). Without --preserve-idle, + // the write would overwrite the seeded `.idle` with `.unknown` and + // re-introduce the hibernation-is-one-shot bug for new-session resumes. + // Use effective() so a record with only lastNotificationStatus=idle + // (no explicit agentLifecycle) is also treated as proven-definitive. + let mappedEffectiveLifecycle = AgentHibernationLifecycleState.effective( + agentLifecycle: mapped?.agentLifecycle, + lastNotificationStatus: mapped?.lastNotificationStatus?.rawValue ) + if !(mappedEffectiveLifecycle.map { $0 != .unknown } ?? false) { + setAgentLifecycle( + client: client, + key: def.statusKey, + lifecycle: .unknown, + workspaceId: workspaceId, + surfaceId: surfaceId, + preserveIdle: true + ) + } case .promptSubmit: let mapped = sessionId.isEmpty ? nil : (try? store.lookup(sessionId: sessionId)) diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarMetadataV1.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarMetadataV1.swift index 24674a15be8c..988e098f3a7b 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarMetadataV1.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarMetadataV1.swift @@ -259,7 +259,7 @@ extension ControlCommandCoordinator { /// `set_agent_lifecycle` — record a restorable agent session's lifecycle. func sidebarSetAgentLifecycle(_ args: String) -> String { let parsed = sidebarParseOptions(args) - let usage = "set_agent_lifecycle [--tab=] [--panel=]" + let usage = "set_agent_lifecycle [--tab=] [--panel=] [--preserve-idle]" guard parsed.positional.count >= 2 else { return "ERROR: Usage: \(usage)" } @@ -283,11 +283,16 @@ extension ControlCommandCoordinator { ) ?? false else { return "ERROR: Unsupported agent lifecycle key '\(key)'" } + // --preserve-idle: apply preservingDefinitive so a `.unknown` SessionStart + // cannot overwrite a resume-seeded `.idle`. Only the SessionStart hook path + // in the CLI sets this flag; direct callers omit it and get the raw value. + let preserveIdle = parsed.options["preserve-idle"] != nil sidebarContext?.controlSidebarScheduleAgentLifecycle( target: target, key: key, lifecycleRawValue: lifecycleRawValue, - panelID: panelResolution.panelId + panelID: panelResolution.panelId, + preserveIdle: preserveIdle ) return "OK" } diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarContext.swift index 0edcc5df62cd..f554c96adb2a 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarContext.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarContext.swift @@ -59,12 +59,15 @@ public protocol ControlSidebarContext: AnyObject { panelID: UUID? ) -> Bool - /// Enqueues the `set_agent_lifecycle` mutation. + /// Enqueues the `set_agent_lifecycle` mutation. `preserveIdle` mirrors the + /// `--preserve-idle` flag: when true the app applies `preservingDefinitive` + /// so a `.unknown` SessionStart cannot clobber a resume-seeded `.idle`. func controlSidebarScheduleAgentLifecycle( target: ControlSidebarTabTarget, key: String, lifecycleRawValue: String, - panelID: UUID? + panelID: UUID?, + preserveIdle: Bool ) /// Applies the `agent_hibernation` global toggle. diff --git a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+SidebarBrowser.swift b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+SidebarBrowser.swift index fa242d8a5c39..c0f2e799f920 100644 --- a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+SidebarBrowser.swift +++ b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+SidebarBrowser.swift @@ -63,7 +63,8 @@ extension ControlSidebarContext { target: ControlSidebarTabTarget, key: String, lifecycleRawValue: String, - panelID: UUID? + panelID: UUID?, + preserveIdle: Bool ) {} func controlSidebarSetAgentHibernation(enabled: Bool) {} diff --git a/Sources/AgentHibernation/AgentHibernationLifecycleState.swift b/Sources/AgentHibernation/AgentHibernationLifecycleState.swift index 6d957a31a0da..0e73cbc69c3d 100644 --- a/Sources/AgentHibernation/AgentHibernationLifecycleState.swift +++ b/Sources/AgentHibernation/AgentHibernationLifecycleState.swift @@ -16,6 +16,103 @@ enum AgentHibernationLifecycleState: String, Codable, Sendable, Equatable, CaseI self == .idle } + /// Merges an incoming lifecycle into an existing one without ever letting an + /// indeterminate `unknown` erase a previously-proven definitive state. + /// + /// A process restart (SessionStart on session-restore or focus-resume) + /// carries no turn-state information, so it reports `unknown`. That `unknown` + /// must not clobber an earlier `idle`/`running`/`needsInput` that the agent + /// actually emitted, otherwise a resumed-but-quiescent agent gets stuck at + /// `unknown` and never becomes hibernation-eligible again (the + /// hibernation-is-one-shot bug). This helper only *preserves* a proven state; + /// it never *invents* idleness, so eligibility stays positive-evidence-only. + /// + /// - Parameters: + /// - existing: The currently-persisted lifecycle, if any. + /// - incoming: The lifecycle reported by the event being applied. + /// - Returns: `incoming` unless it is `unknown` and `existing` is a definitive + /// (non-`nil`, non-`unknown`) state, in which case `existing` is kept. Any + /// definitive incoming state (`idle`/`running`/`needsInput`) always wins. + static func preservingDefinitive( + existing: AgentHibernationLifecycleState?, + incoming: AgentHibernationLifecycleState + ) -> AgentHibernationLifecycleState { + guard incoming == .unknown, let existing, existing != .unknown else { + return incoming + } + return existing + } + + /// Resolves a panel's hibernation lifecycle from all of its per-agent status + /// sources plus a persisted fallback. + /// + /// Priority: busy (`running`) or blocked (`needsInput`) first, then + /// indeterminate (`unknown`), then definitive idle. `unknown` outranks `idle` + /// so that any unclassified source blocks hibernation — an `.unknown` from a + /// newly-started or restarted agent key cannot be masked by a stale `.idle` + /// left by a previous completed turn on the same panel. In practice each + /// panel has one agent key at a time (the other is pruned via + /// `clearAgentLifecycle`/`clearAgentLifecycleStates` at session end), so the + /// single entry is whatever that agent last reported. + static func resolved( + from states: some Collection, + 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(.unknown) { return .unknown } + if states.contains(.idle) { return .idle } + return fallback ?? .unknown + } + + /// The effective lifecycle for an agent session record: prefer a definitively + /// emitted lifecycle, but when none was emitted (`nil`/`unknown`) treat a + /// recorded idle completion notification as `idle`. This lets plugin/no-emit + /// agents (e.g. opencode) become hibernation-eligible like codex when they + /// finish, instead of being stuck at `unknown`. + /// + /// Safety note: both the explicit `agentLifecycle == .idle` path and the + /// `lastNotificationStatus == "idle"` fallback can return stale data after an + /// app restart — the persisted idle was from a previous completed turn, but the + /// agent may have started a new turn before the restart. Callers MUST guard the + /// returned `.idle` value with a durable `hasUnconfirmedTerminalInput` check + /// that merges the persisted terminal-input timestamp against + /// `lifecycleUpdatedAt` (the hook store field that advances only on definitive + /// `.idle`/`.running`/`.needsInput` events, never on `.unknown` SessionStart): + /// `max(durableTerminalInputAt, inMemoryInputAt) > + /// max(lifecycleUpdatedAt, inMemoryLifecycleChangeAt)`. Using `updatedAt` + /// instead of `lifecycleUpdatedAt` is incorrect: `updatedAt` advances on every + /// upsert including `.unknown` SessionStart, which can push the baseline past + /// a terminal-input timestamp and silently clear the mid-turn guard. + static func effective( + agentLifecycle: AgentHibernationLifecycleState?, + lastNotificationStatus: String? + ) -> AgentHibernationLifecycleState? { + if let agentLifecycle, agentLifecycle != .unknown { + return agentLifecycle + } + if lastNotificationStatus?.lowercased() == "idle" { + return .idle + } + return agentLifecycle + } + + /// Whether an agent notification represents a genuinely blocked state (a + /// permission/approval prompt the user must answer) versus a benign "turn + /// finished, waiting for your next message" notification. Only the former + /// should keep an agent out of hibernation, since killing and resuming a + /// mid-tool prompt would drop the in-flight tool call. Keyword matching + /// mirrors the generic notification classifier. + 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 + } + func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(rawValue) diff --git a/Sources/App/AgentHibernationController.swift b/Sources/App/AgentHibernationController.swift index 2234fe2c2d45..ab434e7c5d99 100644 --- a/Sources/App/AgentHibernationController.swift +++ b/Sources/App/AgentHibernationController.swift @@ -83,6 +83,25 @@ final class AgentHibernationController { private var lifecycleChangeByPanel: [AgentHibernationPanelKey: TimeInterval] = [:] private var confirmations: [AgentHibernationPanelKey: Confirmation] = [:] private var tailFingerprintSamples: [AgentHibernationPanelKey: TailFingerprintSample] = [:] + // Terminal input timestamps keyed by panelId only, loaded from disk so they survive + // app restarts. Without this, after a crash/restart the in-memory terminalInputByPanel + // resets to 0, making hasUnconfirmedTerminalInput always false for post-restart inputs, + // which can let a no-emit agent's stale .idle lifecycle trigger false hibernation. + private var durableTerminalInputByPanelId: [UUID: TimeInterval] = [:] + private var durableInputWritePending = false + private var durableWriteTimer: DispatchSourceTimer? + + private static let durableInputStoreURL: URL = { + // Bundle-ID-scoped so concurrent cmux instances (release, staging, tagged debug) + // each own their file — no cross-instance last-writer-wins data loss and no need + // for inter-process locking. Pruning inside each instance removes only that + // instance's stale entries without affecting other instances' files. + let dir = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent(".cmuxterm", isDirectory: true) + let scope = (Bundle.main.bundleIdentifier ?? "app") + .replacingOccurrences(of: "/", with: "-") + return dir.appendingPathComponent("agent-panel-input-times.\(scope).json", isDirectory: false) + }() private init() {} @@ -91,6 +110,7 @@ final class AgentHibernationController { updateTimerForCurrentSettings() return } + durableTerminalInputByPanelId = Self.loadDurableInputStore() settingsObserver = NotificationCenter.default.addObserver( forName: AgentHibernationSettings.didChangeNotification, object: nil, @@ -103,9 +123,64 @@ final class AgentHibernationController { updateTimerForCurrentSettings() } + private static func loadDurableInputStore() -> [UUID: TimeInterval] { + guard let data = try? Data(contentsOf: durableInputStoreURL), + let stored = try? JSONDecoder().decode([String: TimeInterval].self, from: data) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: stored.compactMap { key, value in + UUID(uuidString: key).map { ($0, value) } + }) + } + + private func scheduleDurableInputWrite() { + guard !durableInputWritePending else { return } + durableInputWritePending = true + // One-shot DispatchSourceTimer debounce: coalesce rapid per-keystroke calls + // into a single disk write ~1 s after the last input. Carve-out: bounded + // intentional delay outside any async context; cancellable via durableWriteTimer. + let writeTimer = DispatchSource.makeTimerSource(queue: .main) + writeTimer.schedule(deadline: .now() + 1, repeating: .never) + writeTimer.setEventHandler { [weak self] in self?.flushDurableInputStore() } + writeTimer.resume() + durableWriteTimer = writeTimer + } + + private func flushDurableInputStore(synchronous: Bool = false) { + durableWriteTimer?.cancel() + durableWriteTimer = nil + durableInputWritePending = false + let snapshot = Dictionary( + uniqueKeysWithValues: durableTerminalInputByPanelId.map { ($0.key.uuidString, $0.value) } + ) + let write = { + guard let data = try? JSONEncoder().encode(snapshot) else { return } + let dir = Self.durableInputStoreURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try? data.write(to: Self.durableInputStoreURL, options: .atomic) + } + // Synchronous during stop() so the write completes before the process exits. + // Asynchronous during normal debounced path to avoid blocking the main thread. + // timerQueue is used exclusively for file I/O; @MainActor protects all mutable state. + // The .sync call is a termination-safety block, not a synchronization primitive. + if synchronous { + timerQueue.sync { write() } + } else { + timerQueue.async { write() } + } + } + func stop() { timer?.cancel() timer = nil + // Synchronously flush any pending durable input write so normal-quit paths + // write the safety timestamp before the process exits. + if durableInputWritePending { + flushDurableInputStore(synchronous: true) + } else { + durableWriteTimer?.cancel() + durableWriteTimer = nil + } AgentHibernationTrackingGate.setEnabled(false) clearTrackingState() if let settingsObserver { @@ -114,11 +189,17 @@ final class AgentHibernationController { } } - func recordTerminalInput(workspaceId: UUID, panelId: UUID, recordedAt: Date? = nil) { + func recordTerminalInput(workspaceId: UUID, panelId: UUID, recordedAt: Date? = nil, durable: Bool = true) { guard AgentHibernationTrackingGate.isEnabled() else { return } let recordedAt = recordedAt ?? Date() let key = recordActivity(workspaceId: workspaceId, panelId: panelId, recordedAt: recordedAt) - terminalInputByPanel[key] = recordedAt.timeIntervalSince1970 + let timestamp = recordedAt.timeIntervalSince1970 + terminalInputByPanel[key] = timestamp + guard durable else { return } + // Persist so that after a restart the planner can still detect new input that + // arrived after the agent's last idle signal, preventing stale-idle hibernation. + durableTerminalInputByPanelId[panelId] = max(durableTerminalInputByPanelId[panelId] ?? 0, timestamp) + scheduleDurableInputWrite() } func recordTerminalFocus(workspaceId: UUID, panelId: UUID, recordedAt: Date? = nil) { @@ -185,7 +266,8 @@ final class AgentHibernationController { index: index, activityByPanel: activityByPanel, terminalInputByPanel: terminalInputByPanel, - lifecycleChangeByPanel: lifecycleChangeByPanel + lifecycleChangeByPanel: lifecycleChangeByPanel, + durableTerminalInputByPanelId: durableTerminalInputByPanelId ) let nowTime = now.timeIntervalSince1970 let isLiveByKey = Dictionary(uniqueKeysWithValues: records.map { record in @@ -394,6 +476,8 @@ final class AgentHibernationController { lifecycleChangeByPanel.removeAll(keepingCapacity: false) confirmations.removeAll(keepingCapacity: false) tailFingerprintSamples.removeAll(keepingCapacity: false) + // durableTerminalInputByPanelId is intentionally NOT cleared: it persists across + // settings-disabled / re-enabled cycles so input history is not lost mid-session. } private func pruneTrackingState( @@ -407,6 +491,12 @@ final class AgentHibernationController { currentKeys.contains(key) && selectedKeys.contains(key) } tailFingerprintSamples = tailFingerprintSamples.filter { currentKeys.contains($0.key) } + let currentPanelIds = Set(currentKeys.map(\.panelId)) + let pruned = durableTerminalInputByPanelId.filter { currentPanelIds.contains($0.key) } + if pruned.count != durableTerminalInputByPanelId.count { + durableTerminalInputByPanelId = pruned + scheduleDurableInputWrite() + } } } @@ -416,7 +506,8 @@ extension AppDelegate { index: RestorableAgentSessionIndex, activityByPanel: [AgentHibernationPanelKey: TimeInterval], terminalInputByPanel: [AgentHibernationPanelKey: TimeInterval], - lifecycleChangeByPanel: [AgentHibernationPanelKey: TimeInterval] + lifecycleChangeByPanel: [AgentHibernationPanelKey: TimeInterval], + durableTerminalInputByPanelId: [UUID: TimeInterval] = [:] ) -> [AgentHibernationRecord] { var records: [AgentHibernationRecord] = [] var seenManagers: Set = [] @@ -437,8 +528,23 @@ extension AppDelegate { let key = AgentHibernationPanelKey(workspaceId: workspace.id, panelId: panelId) let indexActivity = index.updatedAt(workspaceId: workspace.id, panelId: panelId) ?? 0 let localActivity = activityByPanel[key] ?? 0 - let terminalInputAt = terminalInputByPanel[key] ?? 0 + // Merge in-memory and durable terminal-input timestamps so that input + // recorded before an app restart is not lost (in-memory resets to 0 on restart). + let terminalInputAt = max( + terminalInputByPanel[key] ?? 0, + durableTerminalInputByPanelId[panelId] ?? 0 + ) let lifecycleChangeAt = lifecycleChangeByPanel[key] ?? 0 + // Use the hook store's lifecycleUpdatedAt (advances only on definitive + // .idle/.running/.needsInput events, never on .unknown SessionStart) as + // the durable lifecycle-change baseline. Using updatedAt here would cause + // a SessionStart upsert to advance the baseline past terminal-input + // recorded after the last idle notification, incorrectly clearing the + // mid-turn input guard after a restart. + let durableLifecycleChangeAt = max( + index.lifecycleUpdatedAt(workspaceId: workspace.id, panelId: panelId) ?? 0, + lifecycleChangeAt + ) let createdAt = terminalPanel.surface.debugRuntimeSurfaceCreatedAt()?.timeIntervalSince1970 ?? terminalPanel.surface.debugCreatedAt().timeIntervalSince1970 let lifecycle = workspace.agentHibernationLifecycleState( @@ -452,7 +558,7 @@ extension AppDelegate { terminalPanel: terminalPanel, agent: agent, lifecycle: lifecycle, - hasUnconfirmedTerminalInput: terminalInputAt > lifecycleChangeAt, + hasUnconfirmedTerminalInput: terminalInputAt > durableLifecycleChangeAt, lastActivityAt: max(indexActivity, localActivity, createdAt), isProtected: workspaceIsVisible && visiblePanelIds.contains(panelId), hasLiveProcess: index.hasLiveProcess(workspaceId: workspace.id, panelId: panelId), diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index b26e06a62928..895e5216a425 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -880,7 +880,26 @@ private struct RestorableAgentHookSessionRecord: Codable, Sendable { var launchCommand: AgentLaunchCommandSnapshot? var isRestorable: Bool? var agentLifecycle: AgentHibernationLifecycleState? + // Completion signal recorded by every agent integration (including plugin-based + // agents like opencode that never emit a live hibernation lifecycle). Used as a + // fallback so those agents still become hibernation-eligible when they finish. + var lastNotificationStatus: String? var updatedAt: TimeInterval + // Advances only when agentLifecycle is set to a definitive value; never on + // .unknown SessionStart events. Used as the durable lifecycle-change baseline + // so that a SessionStart cannot push this past terminal-input recorded after + // the last definitive idle/running/needsInput change. + var lifecycleUpdatedAt: TimeInterval? + + // Effective hibernation lifecycle: prefer a definitive emitted lifecycle, but + // when none was emitted (nil/unknown) treat a recorded idle completion + // notification as idle so plugin/no-emit agents can hibernate like codex. + var effectiveHibernationLifecycle: AgentHibernationLifecycleState? { + AgentHibernationLifecycleState.effective( + agentLifecycle: agentLifecycle, + lastNotificationStatus: lastNotificationStatus + ) + } } private struct RestorableAgentHookSessionStoreFile: Codable, Sendable { @@ -900,6 +919,7 @@ struct RestorableAgentSessionIndex: Sendable { let snapshot: SessionRestorableAgentSnapshot let lifecycle: AgentHibernationLifecycleState? let updatedAt: TimeInterval + let lifecycleUpdatedAt: TimeInterval? let processIDs: Set } @@ -927,6 +947,10 @@ struct RestorableAgentSessionIndex: Sendable { entry(workspaceId: workspaceId, panelId: panelId)?.updatedAt } + func lifecycleUpdatedAt(workspaceId: UUID, panelId: UUID) -> TimeInterval? { + entry(workspaceId: workspaceId, panelId: panelId)?.lifecycleUpdatedAt + } + func processIDs(workspaceId: UUID, panelId: UUID) -> Set { entry(workspaceId: workspaceId, panelId: panelId)?.processIDs ?? [] } @@ -1070,8 +1094,9 @@ struct RestorableAgentSessionIndex: Sendable { ) let entry = Entry( snapshot: snapshot, - lifecycle: effectiveRecord.agentLifecycle, + lifecycle: effectiveRecord.effectiveHibernationLifecycle, updatedAt: effectiveRecord.updatedAt, + lifecycleUpdatedAt: effectiveRecord.lifecycleUpdatedAt, processIDs: liveProcessID.map { [$0] } ?? [] ) if hookCandidatesByPanel[key]?.updatedAt ?? -Double.infinity <= effectiveRecord.updatedAt { @@ -1103,6 +1128,7 @@ struct RestorableAgentSessionIndex: Sendable { snapshot: detected.snapshot, lifecycle: existing.lifecycle, updatedAt: existing.updatedAt, + lifecycleUpdatedAt: existing.lifecycleUpdatedAt, processIDs: detected.processIDs ) } else { @@ -1110,6 +1136,7 @@ struct RestorableAgentSessionIndex: Sendable { snapshot: detected.snapshot, lifecycle: nil, updatedAt: 0, + lifecycleUpdatedAt: nil, processIDs: detected.processIDs ) } diff --git a/Sources/RestorableAgentTypes.swift b/Sources/RestorableAgentTypes.swift index cb396821c016..c376bdb894cf 100644 --- a/Sources/RestorableAgentTypes.swift +++ b/Sources/RestorableAgentTypes.swift @@ -87,6 +87,17 @@ enum RestorableAgentKind: Codable, Hashable, Sendable { } } + /// The status key used by the agent's CLI hook for `set_agent_lifecycle` commands. + /// Matches `AgentHibernationLifecycleStatusKeys.allowedStatusKeys`. + /// Claude's hook uses `claude_code` while the restorable-agent identity is `claude`, + /// so the two keys are different; all other agents share their rawValue and status key. + var lifecycleStatusKey: String { + switch self { + case .claude: return "claude_code" + default: return rawValue + } + } + var customAgentID: String? { if case .custom(let id) = self { return id diff --git a/Sources/TerminalController+ControlSidebarContext.swift b/Sources/TerminalController+ControlSidebarContext.swift index 50271caf5b52..a5b63e902b45 100644 --- a/Sources/TerminalController+ControlSidebarContext.swift +++ b/Sources/TerminalController+ControlSidebarContext.swift @@ -141,7 +141,8 @@ extension TerminalController: ControlSidebarContext { target: ControlSidebarTabTarget, key: String, lifecycleRawValue: String, - panelID: UUID? + panelID: UUID?, + preserveIdle: Bool ) { guard let lifecycle = AgentHibernationLifecycleState(rawValue: lifecycleRawValue) else { // Unreachable: the coordinator only forwards a value this app produced. @@ -151,7 +152,10 @@ extension TerminalController: ControlSidebarContext { if let panelId = panelID, !tab.panels.keys.contains(panelId) { return } - tab.setAgentLifecycle(key: key, panelId: panelID, lifecycle: lifecycle) + tab.setAgentLifecycle( + key: key, panelId: panelID, lifecycle: lifecycle, + preserveIdle: preserveIdle + ) } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 501993a58dba..f8ea743e73bc 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -12586,12 +12586,33 @@ final class Workspace: Identifiable, ObservableObject { func setAgentLifecycle( key: String, panelId: UUID?, - lifecycle: AgentHibernationLifecycleState + lifecycle: AgentHibernationLifecycleState, + preserveIdle: Bool = false ) { let targetPanelId = panelId ?? focusedPanelId guard let targetPanelId, panels[targetPanelId] != nil else { return } - agentLifecycleStatesByPanelId[targetPanelId, default: [:]][key] = lifecycle - recordAgentLifecycleChange(panelId: targetPanelId) + let existing = agentLifecycleStatesByPanelId[targetPanelId]?[key] + // When preserveIdle is true (SessionStart path), apply preservingDefinitive so + // an `.unknown` from a process restart cannot clobber a resume-seeded `.idle`. + // This is deliberately scoped to the SessionStart caller via the socket command + // flag `--preserve-idle`; direct `set_agent_lifecycle` calls without that flag + // take the incoming value as-is, preserving the expected API contract. + let resolved = preserveIdle + ? AgentHibernationLifecycleState.preservingDefinitive(existing: existing, incoming: lifecycle) + : lifecycle + agentLifecycleStatesByPanelId[targetPanelId, default: [:]][key] = resolved + // Advance lifecycleChangeAt only when the caller's *incoming* lifecycle was + // definitive (non-unknown). Using `lifecycle` (the caller's intent) rather than + // `resolved` (the post-preservation value) is critical: when --preserve-idle + // keeps the existing `.idle` over an incoming `.unknown`, `resolved` is `.idle` + // but no new definitive event was emitted by the agent. Advancing the timestamp + // there would push lifecycleChangeAt past terminalInputAt, clearing the + // hasUnconfirmedTerminalInput guard and allowing premature hibernation mid-turn. + // Repeated definitive `.idle` events still advance the timestamp so the guard + // clears normally after the resumed agent completes its first turn. + if lifecycle != .unknown { + recordAgentLifecycleChange(panelId: targetPanelId) + } } @discardableResult @@ -12635,16 +12656,10 @@ final class Workspace: Identifiable, ObservableObject { panelId: UUID, fallback: AgentHibernationLifecycleState? ) -> AgentHibernationLifecycleState { - guard let panelStates = agentLifecycleStatesByPanelId[panelId], - !panelStates.isEmpty else { - return fallback ?? .unknown - } - let states = Array(panelStates.values) - if states.contains(.running) { return .running } - if states.contains(.needsInput) { return .needsInput } - if states.contains(.unknown) { return .unknown } - if states.contains(.idle) { return .idle } - return fallback ?? .unknown + AgentHibernationLifecycleState.resolved( + from: agentLifecycleStatesByPanelId[panelId].map { Array($0.values) } ?? [], + fallback: fallback + ) } func restorableAgentForHibernation( @@ -12701,7 +12716,35 @@ final class Workspace: Identifiable, ObservableObject { : .manualResumeAvailable invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) } - clearAgentLifecycleStates(panelId: panelId) + // Re-seed the panel's hibernation lifecycle to idle on resume. A + // hibernated agent is idle by construction (only idle agents hibernate), + // and the resumed process restarts at its prompt, so it is still idle. + // The PID/session teardown around hibernation already cleared the live + // lifecycle, and the resumed agent may even relaunch under a brand-new + // session id, so positive evidence cannot otherwise survive. Seeding idle + // here keeps the panel hibernation-eligible until it actually runs a turn, + // fixing the hibernation-is-one-shot bug. The CLI SessionStart hook uses + // `--preserve-idle` so the seeded `.idle` survives the resumed process's + // `.unknown` report (see `setAgentLifecycle` `preserveIdle` parameter). + // A real new turn still wins: prompt-submit emits `.running`, a blocking + // prompt `.needsInput`, both of which outrank idle in the resolver. + // recordTerminalFocus below resets the idle timer so it waits a fresh + // idle window after you leave. + if let resumedKind = restoredAgentSnapshotsByPanelId[panelId]?.kind { + setAgentLifecycle(key: resumedKind.lifecycleStatusKey, panelId: panelId, lifecycle: .idle) + } + // When the resume queued a startup command the terminal input is recorded + // asynchronously (Task { @MainActor in }) by GhosttyTerminalView, so + // terminalInputAt would not advance past the lifecycleChangeAt written above. + // Record it here synchronously so hasUnconfirmedTerminalInput stays true + // until the resumed process confirms its first lifecycle update, keeping the + // planner from re-hibernating the panel during the startup window. + // Use durable: false — a startup-window guard only needs to survive the + // current session; making it durable would permanently block re-hibernation + // across restarts if the agent crashes before completing its first turn. + if preparation.queuedStartupInput { + AgentHibernationController.shared.recordTerminalInput(workspaceId: id, panelId: panelId, durable: false) + } AgentHibernationController.shared.recordTerminalFocus(workspaceId: id, panelId: panelId) if focus { focusPanel(panelId) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 60b5053b2332..be8a44a895fb 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ A9E020000000000000000005 /* AgentExecutableResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000005 /* AgentExecutableResolverTests.swift */; }; C0DEF0C10000000000000001 /* AgentForkSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0C10000000000000002 /* AgentForkSupport.swift */; }; D36A00010000000000000001 /* AgentHibernationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00010000000000000002 /* AgentHibernationController.swift */; }; + D36A00070000000000000001 /* AgentHibernationLifecycleResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00070000000000000002 /* AgentHibernationLifecycleResolutionTests.swift */; }; D36A00030000000000000001 /* AgentHibernationLifecycleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00030000000000000002 /* AgentHibernationLifecycleState.swift */; }; D36A00030000000000000003 /* AgentHibernationLifecycleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00030000000000000002 /* AgentHibernationLifecycleState.swift */; }; D36A00020000000000000001 /* AgentHibernationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00020000000000000002 /* AgentHibernationTests.swift */; }; @@ -864,6 +865,7 @@ A9E010000000000000000005 /* AgentExecutableResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentExecutableResolverTests.swift; sourceTree = ""; }; C0DEF0C10000000000000002 /* AgentForkSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentForkSupport.swift; sourceTree = ""; }; D36A00010000000000000002 /* AgentHibernationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/AgentHibernationController.swift; sourceTree = ""; }; + D36A00070000000000000002 /* AgentHibernationLifecycleResolutionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHibernationLifecycleResolutionTests.swift; sourceTree = ""; }; D36A00030000000000000002 /* AgentHibernationLifecycleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHibernation/AgentHibernationLifecycleState.swift; sourceTree = ""; }; D36A00020000000000000002 /* AgentHibernationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHibernationTests.swift; sourceTree = ""; }; D3610B010000000000000002 /* AgentSessionAutoResumeSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionAutoResumeSettingsTests.swift; sourceTree = ""; }; @@ -2310,6 +2312,7 @@ A9E050000000000000000001 /* AgentSessionWebRendererTests.swift */, A9E040000000000000000001 /* CodexAppServerSessionTests.swift */, D36A00020000000000000002 /* AgentHibernationTests.swift */, + D36A00070000000000000002 /* AgentHibernationLifecycleResolutionTests.swift */, D36A00050000000000000002 /* RendererRealizationPlannerTests.swift */, F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */, F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */, @@ -3433,6 +3436,7 @@ buildActionMask = 2147483647; files = ( A9E020000000000000000005 /* AgentExecutableResolverTests.swift in Sources */, + D36A00070000000000000001 /* AgentHibernationLifecycleResolutionTests.swift in Sources */, D36A00020000000000000001 /* AgentHibernationTests.swift in Sources */, D3610B010000000000000001 /* AgentSessionAutoResumeSettingsTests.swift in Sources */, A9E030000000000000000002 /* AgentSessionSocketSurfaceTests.swift in Sources */, diff --git a/cmuxTests/AgentHibernationLifecycleResolutionTests.swift b/cmuxTests/AgentHibernationLifecycleResolutionTests.swift new file mode 100644 index 000000000000..49d432388e03 --- /dev/null +++ b/cmuxTests/AgentHibernationLifecycleResolutionTests.swift @@ -0,0 +1,116 @@ +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Behavior tests for the hibernation lifecycle decision functions that make every +/// agent (not just codex) become hibernation-eligible when it is genuinely idle. +@Suite struct AgentHibernationLifecycleResolutionTests { + typealias Lifecycle = AgentHibernationLifecycleState + + // MARK: resolved(from:fallback:) — the live per-panel priority + + @Test func resolvedReturnsFallbackWhenNoLiveStates() { + #expect(Lifecycle.resolved(from: [Lifecycle](), fallback: nil) == .unknown) + #expect(Lifecycle.resolved(from: [Lifecycle](), fallback: .idle) == .idle) + #expect(Lifecycle.resolved(from: [Lifecycle](), fallback: .needsInput) == .needsInput) + } + + /// `unknown` from any source blocks `idle` — an `.unknown` entry from a + /// newly-started or restarted agent key on the same panel must not be masked + /// by a stale `.idle` left from a prior completed turn. In practice each panel + /// has one agent key at a time (the other is cleared at session end), so the + /// single entry is whatever that agent last reported. + @Test func resolvedUnknownBlocksIdle() { + #expect(Lifecycle.resolved(from: [.unknown, .idle], fallback: nil) == .unknown) + #expect(Lifecycle.resolved(from: [.idle, .unknown], fallback: nil) == .unknown) + } + + @Test func resolvedKeepsBusyAndBlockedAboveIdle() { + #expect(Lifecycle.resolved(from: [.running, .idle], fallback: nil) == .running) + #expect(Lifecycle.resolved(from: [.needsInput, .idle], fallback: nil) == .needsInput) + #expect(Lifecycle.resolved(from: [.running, .needsInput, .idle, .unknown], fallback: nil) == .running) + } + + @Test func resolvedSingleStates() { + #expect(Lifecycle.resolved(from: [.idle], fallback: nil) == .idle) + #expect(Lifecycle.resolved(from: [.unknown], fallback: .idle) == .unknown) + } + + // MARK: effective(agentLifecycle:lastNotificationStatus:) — index/persisted fallback + + @Test func effectivePrefersDefinitiveLifecycle() { + #expect(Lifecycle.effective(agentLifecycle: .idle, lastNotificationStatus: nil) == .idle) + #expect(Lifecycle.effective(agentLifecycle: .running, lastNotificationStatus: "idle") == .running) + #expect(Lifecycle.effective(agentLifecycle: .needsInput, lastNotificationStatus: "idle") == .needsInput) + } + + /// Plugin/no-emit agents (e.g. opencode) never emit a live lifecycle but do + /// record a completion notification; treat that as idle so they hibernate. + @Test func effectiveDerivesIdleFromCompletionNotification() { + #expect(Lifecycle.effective(agentLifecycle: .unknown, lastNotificationStatus: "idle") == .idle) + #expect(Lifecycle.effective(agentLifecycle: nil, lastNotificationStatus: "idle") == .idle) + #expect(Lifecycle.effective(agentLifecycle: nil, lastNotificationStatus: "Idle") == .idle) + } + + @Test func effectiveStaysIndeterminateWithoutCompletionSignal() { + #expect(Lifecycle.effective(agentLifecycle: .unknown, lastNotificationStatus: nil) == .unknown) + #expect(Lifecycle.effective(agentLifecycle: nil, lastNotificationStatus: "needsInput") == nil) + #expect(Lifecycle.effective(agentLifecycle: .unknown, lastNotificationStatus: "needsInput") == .unknown) + } + + // MARK: notificationIndicatesBlocked — the Claude clobber fix + + @Test func notificationBlockedDetectsPermissionPrompts() { + #expect(Lifecycle.notificationIndicatesBlocked(subtitle: "Permission", body: "Use Bash?")) + #expect(Lifecycle.notificationIndicatesBlocked(subtitle: "", body: "Claude needs your permission to run a command")) + #expect(Lifecycle.notificationIndicatesBlocked(subtitle: "", body: "Approve this action?")) + #expect(Lifecycle.notificationIndicatesBlocked(subtitle: "Approval required", body: "")) + } + + /// The normal post-turn "waiting for your input" notification is NOT blocked, + /// so it resolves to idle and lets Claude hibernate instead of being clobbered + /// to needsInput on every turn. + @Test func notificationNotBlockedForReadyWaiting() { + #expect(!Lifecycle.notificationIndicatesBlocked(subtitle: "Waiting", body: "Claude is waiting for your input")) + #expect(!Lifecycle.notificationIndicatesBlocked(subtitle: "Completed", body: "Task finished")) + #expect(!Lifecycle.notificationIndicatesBlocked(subtitle: "", body: "")) + } + + // MARK: preservingDefinitive — the non-destructive SessionStart merge + + /// A SessionStart on resume/relaunch reports `.unknown`; it must never erase a + /// previously-proven definitive lifecycle, or a quiescent resumed agent gets + /// stuck at `.unknown` forever and never re-hibernates. Only a definitive -> + /// `.unknown` downgrade is suppressed; everything else passes through. + @Test func preservingDefinitiveKeepsProvenStateAgainstUnknown() { + #expect(Lifecycle.preservingDefinitive(existing: nil, incoming: .unknown) == .unknown) + #expect(Lifecycle.preservingDefinitive(existing: .unknown, incoming: .unknown) == .unknown) + #expect(Lifecycle.preservingDefinitive(existing: .idle, incoming: .unknown) == .idle) + #expect(Lifecycle.preservingDefinitive(existing: .running, incoming: .unknown) == .running) + #expect(Lifecycle.preservingDefinitive(existing: .needsInput, incoming: .unknown) == .needsInput) + } + + /// Any definitive incoming state overwrites any existing state, so a genuine + /// new-turn `.running`, a blocking `.needsInput`, or a turn-end `.idle` still + /// wins. This keeps the claude `/clear` promote-to-running boundary intact. + @Test func preservingDefinitiveLetsDefinitiveIncomingWin() { + for existing in [nil, .unknown, .idle, .running, .needsInput] as [Lifecycle?] { + #expect(Lifecycle.preservingDefinitive(existing: existing, incoming: .idle) == .idle) + #expect(Lifecycle.preservingDefinitive(existing: existing, incoming: .running) == .running) + #expect(Lifecycle.preservingDefinitive(existing: existing, incoming: .needsInput) == .needsInput) + } + } + + // MARK: allowsHibernation invariant + + @Test func onlyIdleAllowsHibernation() { + #expect(Lifecycle.idle.allowsHibernation) + #expect(!Lifecycle.running.allowsHibernation) + #expect(!Lifecycle.needsInput.allowsHibernation) + #expect(!Lifecycle.unknown.allowsHibernation) + } +} diff --git a/cmuxTests/AgentHibernationTests.swift b/cmuxTests/AgentHibernationTests.swift index b1260a782618..2dc2fa096ab6 100644 --- a/cmuxTests/AgentHibernationTests.swift +++ b/cmuxTests/AgentHibernationTests.swift @@ -1,5 +1,5 @@ import Foundation -import XCTest +import Testing import Bonsplit #if canImport(cmux_DEV) @@ -8,29 +8,30 @@ import Bonsplit @testable import cmux #endif -final class AgentHibernationTests: XCTestCase { - func testLifecycleStateParsingAcceptsShellFriendlyAliases() throws { - XCTAssertEqual(AgentHibernationLifecycleState.parseCLIValue("IDLE"), .idle) - XCTAssertEqual(AgentHibernationLifecycleState.parseCLIValue("needsInput"), .needsInput) - XCTAssertEqual(AgentHibernationLifecycleState.parseCLIValue("needs-input"), .needsInput) - XCTAssertEqual(AgentHibernationLifecycleState.parseCLIValue("needs_input"), .needsInput) - XCTAssertNil(AgentHibernationLifecycleState.parseCLIValue("paused")) +// Tests that mutate TerminalController.shared and TerminalMutationBus.shared must not +// run in parallel; .serialized preserves the XCTestCase serial execution contract. +@Suite(.serialized) struct AgentHibernationTests { + @Test func lifecycleStateParsingAcceptsShellFriendlyAliases() throws { + #expect(AgentHibernationLifecycleState.parseCLIValue("IDLE") == .idle) + #expect(AgentHibernationLifecycleState.parseCLIValue("needsInput") == .needsInput) + #expect(AgentHibernationLifecycleState.parseCLIValue("needs-input") == .needsInput) + #expect(AgentHibernationLifecycleState.parseCLIValue("needs_input") == .needsInput) + #expect(AgentHibernationLifecycleState.parseCLIValue("paused") == nil) let decoded = try JSONDecoder().decode( AgentHibernationLifecycleState.self, from: Data(#""paused""#.utf8) ) - XCTAssertEqual(decoded, .unknown) + #expect(decoded == .unknown) } - func testSocketLifecycleRejectsUnsupportedStatusKey() { + @Test func socketLifecycleRejectsUnsupportedStatusKey() { let response = TerminalController.shared.handleSocketLine("set_agent_lifecycle fake-agent idle") - - XCTAssertTrue(response.contains("Unsupported agent lifecycle key")) + #expect(response.contains("Unsupported agent lifecycle key")) } - @MainActor - func testSocketLifecycleAcceptsRegisteredCustomAgentKey() throws { + @Test @MainActor + func socketLifecycleAcceptsRegisteredCustomAgentKey() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-custom-lifecycle-\(UUID().uuidString)", isDirectory: true) let configDirectory = root.appendingPathComponent(".cmux", isDirectory: true) @@ -61,27 +62,27 @@ final class AgentHibernationTests: XCTestCase { TerminalMutationBus.shared.drainForTesting() } - let workspace = try XCTUnwrap(manager.selectedWorkspace) - let panelId = try XCTUnwrap(workspace.focusedPanelId) + let workspace = try #require(manager.selectedWorkspace) + let panelId = try #require(workspace.focusedPanelId) workspace.panelDirectories[panelId] = root.path let response = TerminalController.shared.handleSocketLine( "set_agent_lifecycle local-agent idle --tab=\(workspace.id.uuidString) --panel=\(panelId.uuidString)" ) - XCTAssertEqual(response, "OK") + #expect(response == "OK") TerminalMutationBus.shared.drainForTesting() - XCTAssertEqual(workspace.agentLifecycleStatesByPanelId[panelId]?["local-agent"], .idle) + #expect(workspace.agentLifecycleStatesByPanelId[panelId]?["local-agent"] == .idle) } - func testSettingsDefaultToOptInAndNotifyOnChanges() throws { + @Test func settingsDefaultToOptInAndNotifyOnChanges() throws { let suiteName = "cmux-agent-hibernation-\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + let defaults = try #require(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - XCTAssertFalse(AgentHibernationSettings.isEnabled(defaults: defaults)) - XCTAssertEqual(AgentHibernationSettings.idleSeconds(defaults: defaults), 5) - XCTAssertEqual(AgentHibernationSettings.maxLiveTerminals(defaults: defaults), 12) + #expect(!AgentHibernationSettings.isEnabled(defaults: defaults)) + #expect(AgentHibernationSettings.idleSeconds(defaults: defaults) == 5) + #expect(AgentHibernationSettings.maxLiveTerminals(defaults: defaults) == 12) let notificationCenter = NotificationCenter() var notificationCount = 0 @@ -103,17 +104,17 @@ final class AgentHibernationTests: XCTestCase { ) let values = AgentHibernationSettings.values(defaults: defaults) - XCTAssertTrue(values.enabled) - XCTAssertEqual(values.idleSeconds, 10) - XCTAssertEqual(values.maxLiveTerminals, 4) - XCTAssertEqual(notificationCount, 1) + #expect(values.enabled) + #expect(values.idleSeconds == 10) + #expect(values.maxLiveTerminals == 4) + #expect(notificationCount == 1) defaults.set(42, forKey: AgentHibernationSettings.confirmationSecondsKey) - XCTAssertEqual(AgentHibernationSettings.confirmationSeconds(defaults: defaults), 42) + #expect(AgentHibernationSettings.confirmationSeconds(defaults: defaults) == 42) AgentHibernationSettings.reset(defaults: defaults, notificationCenter: notificationCenter) - XCTAssertEqual(AgentHibernationSettings.confirmationSeconds(defaults: defaults), AgentHibernationSettings.defaultConfirmationSeconds) - XCTAssertNil(defaults.object(forKey: AgentHibernationSettings.confirmationSecondsKey)) - XCTAssertEqual(notificationCount, 2) + #expect(AgentHibernationSettings.confirmationSeconds(defaults: defaults) == AgentHibernationSettings.defaultConfirmationSeconds) + #expect(defaults.object(forKey: AgentHibernationSettings.confirmationSecondsKey) == nil) + #expect(notificationCount == 2) AgentHibernationSettings.setValues( enabled: AgentHibernationSettings.defaultEnabled, @@ -122,10 +123,10 @@ final class AgentHibernationTests: XCTestCase { defaults: defaults, notificationCenter: notificationCenter ) - XCTAssertEqual(notificationCount, 2) + #expect(notificationCount == 2) } - func testPlannerOnlySelectsIdleUnprotectedExcessLiveAgents() { + @Test func plannerOnlySelectsIdleUnprotectedExcessLiveAgents() { let workspaceId = UUID() let now: TimeInterval = 1_000 let idleOld = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) @@ -156,10 +157,10 @@ final class AgentHibernationTests: XCTestCase { now: now ) - XCTAssertEqual(selected, Set([idleOld])) + #expect(selected == Set([idleOld])) } - func testPlannerDoesNotSelectWhenUnderLiveLimit() { + @Test func plannerDoesNotSelectWhenUnderLiveLimit() { let key = AgentHibernationPanelKey(workspaceId: UUID(), panelId: UUID()) let settings = AgentHibernationSettings.Values( enabled: true, @@ -176,10 +177,256 @@ final class AgentHibernationTests: XCTestCase { now: 1_000 ) - XCTAssertTrue(selected.isEmpty) + #expect(selected.isEmpty) + } + + /// Hibernation must not be one-shot: an idle restorable off-screen agent that + /// is hibernated, resumed on focus, then left off-screen again must become + /// eligible AGAIN. The persisted-store fix (preservingDefinitive at the + /// SessionStart chokepoint) keeps the resumed agent's lifecycle at `.idle` + /// instead of clobbering it to `.unknown`, so the planner re-selects it once a + /// fresh idle window elapses. This models that planner-level behavior: an + /// `.idle` input over the live limit is selected, and after a simulated + /// resume (lifecycle still `.idle` thanks to the fix) it is selected again. + @Test func plannerReHibernatesAfterResume() { + let workspaceId = UUID() + let target = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let keepLive = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let settings = AgentHibernationSettings.Values( + enabled: true, + idleSeconds: 5, + maxLiveTerminals: 1, + confirmationSeconds: 60 + ) + + func selection(targetLifecycle: AgentHibernationLifecycleState, now: TimeInterval, targetActivityAt: TimeInterval) -> Set { + AgentHibernationPlanner.selectedPanelKeys( + inputs: [ + .init(key: target, hasRestorableAgent: true, isLive: true, isProtected: false, lifecycle: targetLifecycle, hasUnconfirmedTerminalInput: false, lastActivityAt: targetActivityAt), + .init(key: keepLive, hasRestorableAgent: true, isLive: true, isProtected: false, lifecycle: .running, hasUnconfirmedTerminalInput: false, lastActivityAt: now), + ], + settings: settings, + now: now + ) + } + + // First hibernation: idle + off-screen + idle window elapsed -> selected. + #expect( + selection(targetLifecycle: .idle, now: 1_000, targetActivityAt: 1_000 - 300) == Set([target]), + "Idle restorable over the live limit must hibernate the first time" + ) + + // The moment it resumes-to-work the resumed activity is fresh, so it is + // NOT hibernated even though the lifecycle is still idle (idle window not + // yet elapsed). This guards against hibernating the instant it resumes. + #expect( + selection(targetLifecycle: .idle, now: 2_000, targetActivityAt: 2_000).isEmpty, + "A just-resumed agent must not be hibernated until a fresh idle window elapses" + ) + + // Left off-screen again with the lifecycle preserved at idle (the fix): + // once the new idle window elapses it re-hibernates. This is the + // not-one-shot proof. + #expect( + selection(targetLifecycle: .idle, now: 2_100, targetActivityAt: 2_000) == Set([target]), + "After resume, a preserved-idle off-screen agent must re-hibernate (not one-shot)" + ) + + // Negative cases at resume: a resumed agent that is actually working + // (running), blocked (needsInput), or indeterminate (unknown) is never + // selected, even with a stale activity timestamp. + for busy in [AgentHibernationLifecycleState.running, .needsInput, .unknown] { + #expect( + selection(targetLifecycle: busy, now: 3_000, targetActivityAt: 3_000 - 300).isEmpty, + "A \(busy) resumed agent must never be hibernated" + ) + } + } + + /// No-emit/plugin agents (opencode, pi, omp, amp) never write `.running` at + /// turn-start, so once the persisted `.idle` is carried across SessionStart by + /// the fix, their ONLY mid-turn protection is `hasUnconfirmedTerminalInput`. + /// The generic SessionStart live `.unknown` write (the prior mid-turn input + /// flag would have been irrelevant because the live `.unknown` masked + /// eligibility) is now skipped, so the input flag is load-bearing. This pins + /// that coupling: an `.idle` (preserved) panel with the input flag asserted + /// must NOT be selected, even off-screen, over the live limit, idle-window + /// elapsed. A future edit that clears the flag mid-turn for a no-emit agent + /// fails this test. + @Test func plannerExcludesNoEmitAgentWithUnconfirmedTerminalInput() { + let workspaceId = UUID() + let busyNoEmit = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let keepLive = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let settings = AgentHibernationSettings.Values( + enabled: true, + idleSeconds: 5, + maxLiveTerminals: 1, + confirmationSeconds: 60 + ) + + let selected = AgentHibernationPlanner.selectedPanelKeys( + inputs: [ + // Persisted/preserved lifecycle is .idle (no-emit agent never + // wrote .running), but it is genuinely mid-turn: the input flag + // is asserted because a turn-start input event arrived after the + // last lifecycle change and nothing advanced lifecycleChangeAt. + .init(key: busyNoEmit, hasRestorableAgent: true, isLive: true, isProtected: false, lifecycle: .idle, hasUnconfirmedTerminalInput: true, lastActivityAt: 1_000.0 - 300), + .init(key: keepLive, hasRestorableAgent: true, isLive: true, isProtected: false, lifecycle: .running, hasUnconfirmedTerminalInput: false, lastActivityAt: 1_000), + ], + settings: settings, + now: 1_000 + ) + + #expect( + selected.isEmpty, + "A no-emit agent with unconfirmed terminal input is mid-turn and must never be hibernated, even with a preserved idle lifecycle" + ) + } + + /// Verifies the durable-terminal-input semantics used in + /// `agentHibernationRecords` to compute `hasUnconfirmedTerminalInput` after + /// an app restart. In-memory `terminalInputByPanel` and `lifecycleChangeByPanel` + /// both reset to zero on restart; the computation must fall back to + /// `durableTerminalInputAt` and `lifecycleUpdatedAt` so a mid-turn agent is not + /// incorrectly hibernated based on its stale idle lifecycle. + @Test func durableTerminalInputBlocksHibernationAfterRestart() { + let idleAt: TimeInterval = 1_000.0 // idle notification fired before restart + let inputAt: TimeInterval = 1_100.0 // user typed new prompt before restart + + // Post-restart state: in-memory counters reset to zero. + let inMemoryTerminalInputAt: TimeInterval = 0 + let inMemoryLifecycleChangeAt: TimeInterval = 0 + + // Durable values loaded from disk survive the restart. + let durableTerminalInputAt: TimeInterval = inputAt // from agent-panel-input-times.json + let lifecycleUpdatedAt: TimeInterval = idleAt // from hook store lifecycleUpdatedAt + + // The computation used in agentHibernationRecords: + let effectiveTerminalInputAt = max(inMemoryTerminalInputAt, durableTerminalInputAt) + let effectiveLifecycleChangeAt = max(inMemoryLifecycleChangeAt, lifecycleUpdatedAt) + let hasUnconfirmedInput = effectiveTerminalInputAt > effectiveLifecycleChangeAt + + #expect( + hasUnconfirmedInput, + "Input typed after the idle notification but before restart must still block hibernation after restart" + ) + + // Input that predated the idle notification should not block hibernation. + let staleInputAt: TimeInterval = 900.0 + let effectiveStaleTerminalInputAt = max(TimeInterval(0), staleInputAt) + let staleHasUnconfirmedInput = effectiveStaleTerminalInputAt > effectiveLifecycleChangeAt + #expect( + !staleHasUnconfirmedInput, + "Input that predated the idle notification must not block hibernation" + ) + } + + /// Verifies that a SessionStart upsert after the user typed (but before restart) does + /// NOT clear the mid-turn input guard. Previously the fix used `updatedAt` as the + /// durable lifecycle-change baseline; `updatedAt` advances on every upsert including + /// `.unknown` SessionStart, so a SessionStart at T2 > T1 (input) would make + /// `terminalInputAt > T2` false and incorrectly allow hibernation mid-turn. + @Test func sessionStartDoesNotClearMidTurnInputGuard() { + let idleAt: TimeInterval = 1_000.0 // last definitive lifecycle update + let inputAt: TimeInterval = 1_100.0 // user typed AFTER idle + let sessionStartAt: TimeInterval = 1_200.0 // agent SessionStart (.unknown) upsert + + // lifecycleUpdatedAt stays at idleAt — the SessionStart did NOT advance it + // because incoming was .unknown (hook store preservingDefinitive in update()). + let lifecycleUpdatedAt: TimeInterval = idleAt + + // updatedAt (incorrectly used in the old fix) WOULD be sessionStartAt, + // which is after inputAt and would clear the guard. + let updatedAt: TimeInterval = sessionStartAt + + let inMemoryTerminalInputAt: TimeInterval = 0 + let inMemoryLifecycleChangeAt: TimeInterval = 0 + let durableTerminalInputAt: TimeInterval = inputAt + + let effectiveTerminalInputAt = max(inMemoryTerminalInputAt, durableTerminalInputAt) + + // Old (broken): using updatedAt → guard cleared + let oldDurableLifecycleChangeAt = max(inMemoryLifecycleChangeAt, updatedAt) + let oldHasUnconfirmedInput = effectiveTerminalInputAt > oldDurableLifecycleChangeAt + #expect( + !oldHasUnconfirmedInput, + "This is the bug the fix addresses: the old approach using updatedAt clears the guard" + ) + + // New (correct): using lifecycleUpdatedAt → guard preserved + let newDurableLifecycleChangeAt = max(inMemoryLifecycleChangeAt, lifecycleUpdatedAt) + let newHasUnconfirmedInput = effectiveTerminalInputAt > newDurableLifecycleChangeAt + #expect( + newHasUnconfirmedInput, + "A SessionStart .unknown upsert must not clear the mid-turn input guard" + ) + } + + /// When `--preserve-idle` keeps an existing `.idle` over an incoming `.unknown` + /// SessionStart, the in-memory `lifecycleChangeAt` must NOT advance. If it did, + /// the preserved-idle SessionStart would push `lifecycleChangeAt` past a recent + /// terminal-input event and incorrectly clear `hasUnconfirmedTerminalInput`. + /// + /// This pins the `if lifecycle != .unknown` guard in `Workspace.setAgentLifecycle`: + /// advancement is gated on the *incoming* intent, not the *resolved* state. When + /// `preservingDefinitive` resolves `.unknown → .idle`, the caller never emitted a + /// definitive event, so the timestamp must stay at the last real definitive change. + @Test func preserveIdleSessionStartDoesNotAdvanceInMemoryLifecycleChangeAt() { + let idleAt: TimeInterval = 1_000.0 // lifecycle seeded to .idle on resume + let inputAt: TimeInterval = 1_100.0 // user submitted a new prompt (mid-turn) + let sessionStartAt: TimeInterval = 1_200.0 // generic SessionStart (.unknown --preserve-idle) + + // Correct: incoming was .unknown so lifecycleChangeAt stays at idleAt. + let lifecycleChangeAt: TimeInterval = idleAt + let hasUnconfirmedInput = inputAt > lifecycleChangeAt + #expect( + hasUnconfirmedInput, + "With the fix, a preserve-idle SessionStart must not advance lifecycleChangeAt, so the mid-turn guard stays asserted" + ) + + // Demonstrate the bug: if lifecycleChangeAt were incorrectly pushed to sessionStartAt, + // the guard would clear and the agent could be hibernated mid-turn. + let brokenLifecycleChangeAt: TimeInterval = sessionStartAt + let brokenHasUnconfirmedInput = inputAt > brokenLifecycleChangeAt + #expect( + !brokenHasUnconfirmedInput, + "This demonstrates the bug: advancing lifecycleChangeAt to sessionStartAt clears the mid-turn guard" + ) + } + + // MARK: - Startup window guard + + /// After a crash-on-resume, the durable store must NOT retain the startup-window + /// terminal-input timestamp. recordTerminalInput(durable: false) avoids writing + /// to durableTerminalInputByPanelId, so after a restart the stale timestamp is gone. + @Test func startupWindowInputIsNotDurable() { + // Simulate: agent was last idle at T_idle, hibernated, then resumed. + let idleAt: TimeInterval = 1_000.0 + let resumeAt: TimeInterval = 2_000.0 + + // With durable:true (old behavior), the startup-window timestamp persists. + // After a restart lifecycleChangeAt resets to 0, and durableLifecycleChangeAt + // equals lifecycleUpdatedAt = idleAt, so hasUnconfirmedTerminalInput is true + // whenever durableTerminalInputAt (resumeAt) > durableLifecycleChangeAt (idleAt). + let durableTerminalInputAtWithOldBehavior = resumeAt + let durableLifecycleChangeAt = idleAt // lifecycleUpdatedAt from before hibernate + #expect( + durableTerminalInputAtWithOldBehavior > durableLifecycleChangeAt, + "Old durable write leaves stale post-restart guard" + ) + + // With durable:false (new behavior), durableTerminalInputByPanelId is not written. + // After a restart durableTerminalInputAt stays at whatever it was before resume + // (either 0 for a fresh panel or a prior durable user input). Assuming no prior + // durable input, the guard is 0 > idleAt = false — panel is re-hibernatable. + let durableTerminalInputAtWithNewBehavior: TimeInterval = 0 + #expect( + !(durableTerminalInputAtWithNewBehavior > durableLifecycleChangeAt), + "Transient startup-window input must not permanently block re-hibernation" + ) } - func testProcessFallbackFingerprintIncludesProcessIDs() { + @Test func processFallbackFingerprintIncludesProcessIDs() { let first = AgentHibernationController.processFallbackFingerprint( kind: .opencode, sessionId: "same-session", @@ -196,11 +443,11 @@ final class AgentHibernationTests: XCTestCase { processIDs: [8] ) - XCTAssertEqual(first, sameIDsDifferentOrder) - XCTAssertNotEqual(first, restarted) + #expect(first == sameIDsDifferentOrder) + #expect(first != restarted) } - func testScrollbackFingerprintIncludesProcessIDs() { + @Test func scrollbackFingerprintIncludesProcessIDs() { let first = AgentHibernationController.scrollbackFingerprint( tail: "stable tail", processIDs: [7, 3] @@ -214,75 +461,72 @@ final class AgentHibernationTests: XCTestCase { processIDs: [8] ) - XCTAssertEqual(first, sameIDsDifferentOrder) - XCTAssertNotEqual(first, restarted) + #expect(first == sameIDsDifferentOrder) + #expect(first != restarted) } - func testFirstTailSampleStartsObservedStabilityWindow() { - XCTAssertEqual( + @Test func firstTailSampleStartsObservedStabilityWindow() { + #expect( AgentHibernationController.tailFingerprintStableSince( previousFingerprint: nil, previousStableSince: nil, currentFingerprint: "tail-a", lastActivityAt: 100, now: 500 - ), - 500 + ) == 500 ) - XCTAssertEqual( + #expect( AgentHibernationController.tailFingerprintStableSince( previousFingerprint: "tail-a", previousStableSince: 100, currentFingerprint: "tail-a", lastActivityAt: 120, now: 500 - ), - 100 + ) == 100 ) - XCTAssertEqual( + #expect( AgentHibernationController.tailFingerprintStableSince( previousFingerprint: "tail-a", previousStableSince: 100, currentFingerprint: "tail-b", lastActivityAt: 120, now: 500 - ), - 500 + ) == 500 ) } - @MainActor - func testClearingAgentPIDByPanelClearsLifecycleWithoutOwnedPID() throws { + @Test @MainActor + func clearingAgentPIDByPanelClearsLifecycleWithoutOwnedPID() throws { let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) + let panelId = try #require(workspace.focusedPanelId) workspace.setAgentLifecycle(key: "codex", panelId: panelId, lifecycle: .idle) - XCTAssertEqual(workspace.agentHibernationLifecycleState(panelId: panelId, fallback: nil), .idle) + #expect(workspace.agentHibernationLifecycleState(panelId: panelId, fallback: nil) == .idle) - XCTAssertTrue(workspace.clearAgentPID(key: "codex.missing", panelId: panelId, clearStatus: true)) + #expect(workspace.clearAgentPID(key: "codex.missing", panelId: panelId, clearStatus: true)) - XCTAssertEqual(workspace.agentHibernationLifecycleState(panelId: panelId, fallback: nil), .unknown) + #expect(workspace.agentHibernationLifecycleState(panelId: panelId, fallback: nil) == .unknown) } - @MainActor - func testClearingAgentPIDByPanelClearsOnlyThatPanelLifecycleWhenSameStatusKeyRemains() throws { + @Test @MainActor + func clearingAgentPIDByPanelClearsOnlyThatPanelLifecycleWhenSameStatusKeyRemains() throws { let workspace = Workspace() - let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) - let paneId = try XCTUnwrap(workspace.paneId(forPanelId: firstPanelId)) - let secondPanelId = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: false)).id + let firstPanelId = try #require(workspace.focusedPanelId) + let paneId = try #require(workspace.paneId(forPanelId: firstPanelId)) + let secondPanelId = try #require(workspace.newTerminalSurface(inPane: paneId, focus: false)).id workspace.recordAgentPID(key: "codex.first", pid: 111, panelId: firstPanelId, refreshPorts: false) workspace.recordAgentPID(key: "codex.second", pid: 222, panelId: secondPanelId, refreshPorts: false) workspace.setAgentLifecycle(key: "codex", panelId: firstPanelId, lifecycle: .idle) workspace.setAgentLifecycle(key: "codex", panelId: secondPanelId, lifecycle: .running) - XCTAssertTrue(workspace.clearAgentPID(key: "codex.first", panelId: firstPanelId, clearStatus: true, refreshPorts: false)) + #expect(workspace.clearAgentPID(key: "codex.first", panelId: firstPanelId, clearStatus: true, refreshPorts: false)) - XCTAssertEqual(workspace.agentHibernationLifecycleState(panelId: firstPanelId, fallback: nil), .unknown) - XCTAssertEqual(workspace.agentHibernationLifecycleState(panelId: secondPanelId, fallback: nil), .running) + #expect(workspace.agentHibernationLifecycleState(panelId: firstPanelId, fallback: nil) == .unknown) + #expect(workspace.agentHibernationLifecycleState(panelId: secondPanelId, fallback: nil) == .running) } - func testSessionIndexLoadsAgentLifecycleFromHookStore() throws { + @Test func sessionIndexLoadsAgentLifecycleFromHookStore() throws { let home = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-hibernation-index-\(UUID().uuidString)", isDirectory: true) let storeURL = RestorableAgentKind.codex.hookStoreFileURL(homeDirectory: home.path) @@ -315,11 +559,11 @@ final class AgentHibernationTests: XCTestCase { try data.write(to: storeURL, options: .atomic) let index = RestorableAgentSessionIndex.load(homeDirectory: home.path) - XCTAssertEqual(index.lifecycle(workspaceId: workspaceId, panelId: panelId), .idle) - XCTAssertEqual(index.snapshot(workspaceId: workspaceId, panelId: panelId)?.sessionId, sessionId) + #expect(index.lifecycle(workspaceId: workspaceId, panelId: panelId) == .idle) + #expect(index.snapshot(workspaceId: workspaceId, panelId: panelId)?.sessionId == sessionId) } - func testSessionIndexUsesLiveHookPIDAsProcessID() throws { + @Test func sessionIndexUsesLiveHookPIDAsProcessID() throws { let home = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-hibernation-live-hook-pid-\(UUID().uuidString)", isDirectory: true) let storeURL = RestorableAgentKind.codex.hookStoreFileURL(homeDirectory: home.path) @@ -372,12 +616,12 @@ final class AgentHibernationTests: XCTestCase { } ) - XCTAssertEqual(index.lifecycle(workspaceId: workspaceId, panelId: panelId), .idle) - XCTAssertEqual(index.processIDs(workspaceId: workspaceId, panelId: panelId), [pid]) - XCTAssertTrue(index.hasLiveProcess(workspaceId: workspaceId, panelId: panelId)) + #expect(index.lifecycle(workspaceId: workspaceId, panelId: panelId) == .idle) + #expect(index.processIDs(workspaceId: workspaceId, panelId: panelId) == [pid]) + #expect(index.hasLiveProcess(workspaceId: workspaceId, panelId: panelId)) } - func testSessionIndexAcceptsNodeBackedClaudeProcessAsLiveHookPID() throws { + @Test func sessionIndexAcceptsNodeBackedClaudeProcessAsLiveHookPID() throws { let home = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-hibernation-claude-node-pid-\(UUID().uuidString)", isDirectory: true) let storeURL = RestorableAgentKind.claude.hookStoreFileURL(homeDirectory: home.path) @@ -447,12 +691,12 @@ final class AgentHibernationTests: XCTestCase { } ) - XCTAssertEqual(index.lifecycle(workspaceId: workspaceId, panelId: panelId), .idle) - XCTAssertEqual(index.processIDs(workspaceId: workspaceId, panelId: panelId), [pid]) - XCTAssertTrue(index.hasLiveProcess(workspaceId: workspaceId, panelId: panelId)) + #expect(index.lifecycle(workspaceId: workspaceId, panelId: panelId) == .idle) + #expect(index.processIDs(workspaceId: workspaceId, panelId: panelId) == [pid]) + #expect(index.hasLiveProcess(workspaceId: workspaceId, panelId: panelId)) } - func testLiveProcessScopeMatchingAcceptsLegacyEnvironmentKeys() throws { + @Test func liveProcessScopeMatchingAcceptsLegacyEnvironmentKeys() throws { let workspaceId = UUID() let panelId = UUID() let process = CmuxTopProcessArguments( @@ -463,12 +707,12 @@ final class AgentHibernationTests: XCTestCase { ] ) - XCTAssertTrue(process.matchesCMUXScope(workspaceId: workspaceId, surfaceId: panelId)) - XCTAssertFalse(process.matchesCMUXScope(workspaceId: UUID(), surfaceId: panelId)) - XCTAssertFalse(process.matchesCMUXScope(workspaceId: workspaceId, surfaceId: UUID())) + #expect(process.matchesCMUXScope(workspaceId: workspaceId, surfaceId: panelId)) + #expect(!process.matchesCMUXScope(workspaceId: UUID(), surfaceId: panelId)) + #expect(!process.matchesCMUXScope(workspaceId: workspaceId, surfaceId: UUID())) } - func testSessionIndexDoesNotDropHookStoreForUnknownAgentLifecycle() throws { + @Test func sessionIndexDoesNotDropHookStoreForUnknownAgentLifecycle() throws { let home = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-hibernation-index-\(UUID().uuidString)", isDirectory: true) let storeURL = RestorableAgentKind.codex.hookStoreFileURL(homeDirectory: home.path) @@ -501,11 +745,11 @@ final class AgentHibernationTests: XCTestCase { try data.write(to: storeURL, options: .atomic) let index = RestorableAgentSessionIndex.load(homeDirectory: home.path) - XCTAssertEqual(index.lifecycle(workspaceId: workspaceId, panelId: panelId), .unknown) - XCTAssertEqual(index.snapshot(workspaceId: workspaceId, panelId: panelId)?.sessionId, sessionId) + #expect(index.lifecycle(workspaceId: workspaceId, panelId: panelId) == .unknown) + #expect(index.snapshot(workspaceId: workspaceId, panelId: panelId)?.sessionId == sessionId) } - func testProcessDetectedSnapshotPreservesMatchingHookLifecycleWithoutRefreshingActivity() throws { + @Test func processDetectedSnapshotPreservesMatchingHookLifecycleWithoutRefreshingActivity() throws { let home = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-hibernation-detected-\(UUID().uuidString)", isDirectory: true) let storeURL = RestorableAgentKind.opencode.hookStoreFileURL(homeDirectory: home.path) @@ -558,14 +802,14 @@ final class AgentHibernationTests: XCTestCase { detectedSnapshots: [key: (snapshot: detectedSnapshot, updatedAt: 999, processIDs: [123, 456])] ) - XCTAssertEqual(index.lifecycle(workspaceId: workspaceId, panelId: panelId), .idle) - XCTAssertEqual(index.updatedAt(workspaceId: workspaceId, panelId: panelId), hookUpdatedAt) - XCTAssertEqual(index.processIDs(workspaceId: workspaceId, panelId: panelId), [123, 456]) - XCTAssertTrue(index.hasLiveProcess(workspaceId: workspaceId, panelId: panelId)) - XCTAssertEqual(index.snapshot(workspaceId: workspaceId, panelId: panelId)?.launchCommand?.executablePath, "/opt/homebrew/bin/opencode") + #expect(index.lifecycle(workspaceId: workspaceId, panelId: panelId) == .idle) + #expect(index.updatedAt(workspaceId: workspaceId, panelId: panelId) == hookUpdatedAt) + #expect(index.processIDs(workspaceId: workspaceId, panelId: panelId) == [123, 456]) + #expect(index.hasLiveProcess(workspaceId: workspaceId, panelId: panelId)) + #expect(index.snapshot(workspaceId: workspaceId, panelId: panelId)?.launchCommand?.executablePath == "/opt/homebrew/bin/opencode") } - func testProcessDetectedSnapshotPreservesMatchingHookLifecycleWhenHookPIDIsStale() throws { + @Test func processDetectedSnapshotPreservesMatchingHookLifecycleWhenHookPIDIsStale() throws { let home = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-hibernation-stale-pid-\(UUID().uuidString)", isDirectory: true) let storeURL = RestorableAgentKind.opencode.hookStoreFileURL(homeDirectory: home.path) @@ -619,13 +863,13 @@ final class AgentHibernationTests: XCTestCase { detectedSnapshots: [key: (snapshot: detectedSnapshot, updatedAt: 999, processIDs: [321])] ) - XCTAssertEqual(index.lifecycle(workspaceId: workspaceId, panelId: panelId), .idle) - XCTAssertEqual(index.updatedAt(workspaceId: workspaceId, panelId: panelId), hookUpdatedAt) - XCTAssertEqual(index.processIDs(workspaceId: workspaceId, panelId: panelId), [321]) - XCTAssertEqual(index.snapshot(workspaceId: workspaceId, panelId: panelId)?.launchCommand?.executablePath, "/opt/homebrew/bin/opencode") + #expect(index.lifecycle(workspaceId: workspaceId, panelId: panelId) == .idle) + #expect(index.updatedAt(workspaceId: workspaceId, panelId: panelId) == hookUpdatedAt) + #expect(index.processIDs(workspaceId: workspaceId, panelId: panelId) == [321]) + #expect(index.snapshot(workspaceId: workspaceId, panelId: panelId)?.launchCommand?.executablePath == "/opt/homebrew/bin/opencode") } - func testProcessDetectedSnapshotPreservesHookLifecycleWhenRestoredPanelIDsChange() throws { + @Test func processDetectedSnapshotPreservesHookLifecycleWhenRestoredPanelIDsChange() throws { let home = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-hibernation-remapped-panel-\(UUID().uuidString)", isDirectory: true) let storeURL = RestorableAgentKind.opencode.hookStoreFileURL(homeDirectory: home.path) @@ -681,13 +925,13 @@ final class AgentHibernationTests: XCTestCase { detectedSnapshots: [key: (snapshot: detectedSnapshot, updatedAt: 999, processIDs: [654])] ) - XCTAssertNil(index.snapshot(workspaceId: oldWorkspaceId, panelId: oldPanelId)) - XCTAssertEqual(index.lifecycle(workspaceId: currentWorkspaceId, panelId: currentPanelId), .idle) - XCTAssertEqual(index.updatedAt(workspaceId: currentWorkspaceId, panelId: currentPanelId), hookUpdatedAt) - XCTAssertEqual(index.processIDs(workspaceId: currentWorkspaceId, panelId: currentPanelId), [654]) + #expect(index.snapshot(workspaceId: oldWorkspaceId, panelId: oldPanelId) == nil) + #expect(index.lifecycle(workspaceId: currentWorkspaceId, panelId: currentPanelId) == .idle) + #expect(index.updatedAt(workspaceId: currentWorkspaceId, panelId: currentPanelId) == hookUpdatedAt) + #expect(index.processIDs(workspaceId: currentWorkspaceId, panelId: currentPanelId) == [654]) } - func testProcessDetectedOnlySnapshotDoesNotUseScanTimeAsActivity() { + @Test func processDetectedOnlySnapshotDoesNotUseScanTimeAsActivity() { let home = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-hibernation-empty-home-\(UUID().uuidString)", isDirectory: true) try? FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) @@ -710,13 +954,13 @@ final class AgentHibernationTests: XCTestCase { detectedSnapshots: [key: (snapshot: detectedSnapshot, updatedAt: 999, processIDs: [789])] ) - XCTAssertEqual(index.updatedAt(workspaceId: workspaceId, panelId: panelId), 0) - XCTAssertNil(index.lifecycle(workspaceId: workspaceId, panelId: panelId)) - XCTAssertEqual(index.processIDs(workspaceId: workspaceId, panelId: panelId), [789]) - XCTAssertTrue(index.hasLiveProcess(workspaceId: workspaceId, panelId: panelId)) + #expect(index.updatedAt(workspaceId: workspaceId, panelId: panelId) == 0) + #expect(index.lifecycle(workspaceId: workspaceId, panelId: panelId) == nil) + #expect(index.processIDs(workspaceId: workspaceId, panelId: panelId) == [789]) + #expect(index.hasLiveProcess(workspaceId: workspaceId, panelId: panelId)) } - func testSupportedAgentSnapshotsHaveResumeCommandsForHibernation() { + @Test func supportedAgentSnapshotsHaveResumeCommandsForHibernation() { let cwd = "/tmp/cmux-agent-hibernation" let sessionId = "session-123" let launchCommands: [(RestorableAgentKind, AgentLaunchCommandSnapshot)] = [ @@ -742,12 +986,12 @@ final class AgentHibernationTests: XCTestCase { workingDirectory: cwd, launchCommand: launchCommand ) - XCTAssertNotNil(snapshot.resumeCommand, "\(kind.rawValue) should be resumable before hibernation can use it") - XCTAssertFalse(snapshot.agentDisplayName.isEmpty) + #expect(snapshot.resumeCommand != nil, "\(kind.rawValue) should be resumable before hibernation can use it") + #expect(!snapshot.agentDisplayName.isEmpty) } } - func testCustomRegisteredAgentSnapshotCanHibernateWhenResumeCommandExists() { + @Test func customRegisteredAgentSnapshotCanHibernateWhenResumeCommandExists() { let registration = CmuxVaultAgentRegistration( id: "local-agent", name: "Local Agent", @@ -764,19 +1008,19 @@ final class AgentHibernationTests: XCTestCase { registration: registration ) - XCTAssertEqual(snapshot.agentDisplayName, "Local Agent") - XCTAssertEqual(snapshot.resumeCommand, "{ cd -- '/tmp/custom-agent' 2>/dev/null || [ ! -d '/tmp/custom-agent' ]; } && '/usr/local/bin/local-agent' 'resume' 'custom-session'") + #expect(snapshot.agentDisplayName == "Local Agent") + #expect(snapshot.resumeCommand == "{ cd -- '/tmp/custom-agent' 2>/dev/null || [ ! -d '/tmp/custom-agent' ]; } && '/usr/local/bin/local-agent' 'resume' 'custom-session'") } - @MainActor - func testInvalidatedIndexedAgentSnapshotIsNotEligibleForHibernation() throws { + @Test @MainActor + func invalidatedIndexedAgentSnapshotIsNotEligibleForHibernation() throws { let home = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-hibernation-invalidated-index-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: home) } let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) + let panelId = try #require(workspace.focusedPanelId) let snapshot = SessionRestorableAgentSnapshot( kind: .codex, sessionId: "codex-invalidated-index", @@ -794,14 +1038,14 @@ final class AgentHibernationTests: XCTestCase { workspace.invalidatedRestoredAgentFingerprintsByPanelId[panelId] = TabManager.restorableAgentSnapshotFingerprint(snapshot) - XCTAssertNil(workspace.restorableAgentForHibernation(panelId: panelId, index: index)) + #expect(workspace.restorableAgentForHibernation(panelId: panelId, index: index) == nil) } - @MainActor - func testFocusingHibernatedTerminalAutomaticallyPreparesResume() throws { + @Test @MainActor + func focusingHibernatedTerminalAutomaticallyPreparesResume() throws { let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) - let panel = try XCTUnwrap(workspace.panels[panelId] as? TerminalPanel) + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) let snapshot = SessionRestorableAgentSnapshot( kind: .codex, sessionId: "codex-auto-resume-on-visit", @@ -814,19 +1058,19 @@ final class AgentHibernationTests: XCTestCase { agent: snapshot, lastActivityAt: Date(timeIntervalSince1970: 0) ) - XCTAssertTrue(panel.isAgentHibernated) + #expect(panel.isAgentHibernated) workspace.focusPanel(panelId) - XCTAssertFalse(panel.isAgentHibernated) - XCTAssertEqual(workspace.restoredAgentResumeStatesByPanelId[panelId], .awaitingAutoResumeCommand) + #expect(!panel.isAgentHibernated) + #expect(workspace.restoredAgentResumeStatesByPanelId[panelId] == .awaitingAutoResumeCommand) } - @MainActor - func testVisibleHibernatedTerminalAutomaticallyPreparesResumeWithoutFocus() throws { + @Test @MainActor + func visibleHibernatedTerminalAutomaticallyPreparesResumeWithoutFocus() throws { let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) - let panel = try XCTUnwrap(workspace.panels[panelId] as? TerminalPanel) + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) let snapshot = SessionRestorableAgentSnapshot( kind: .codex, sessionId: "codex-visible-resume", @@ -839,19 +1083,19 @@ final class AgentHibernationTests: XCTestCase { agent: snapshot, lastActivityAt: Date(timeIntervalSince1970: 0) ) - XCTAssertTrue(panel.isAgentHibernated) + #expect(panel.isAgentHibernated) - XCTAssertTrue(workspace.resumeVisibleAgentHibernationPanels(panelIds: [panelId])) + #expect(workspace.resumeVisibleAgentHibernationPanels(panelIds: [panelId])) - XCTAssertFalse(panel.isAgentHibernated) - XCTAssertEqual(workspace.restoredAgentResumeStatesByPanelId[panelId], .awaitingAutoResumeCommand) + #expect(!panel.isAgentHibernated) + #expect(workspace.restoredAgentResumeStatesByPanelId[panelId] == .awaitingAutoResumeCommand) } - @MainActor - func testHiddenMountedWorkspaceDoesNotAutoResumeHibernatedTerminal() throws { + @Test @MainActor + func hiddenMountedWorkspaceDoesNotAutoResumeHibernatedTerminal() throws { let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) - let panel = try XCTUnwrap(workspace.panels[panelId] as? TerminalPanel) + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) let snapshot = SessionRestorableAgentSnapshot( kind: .codex, sessionId: "codex-hidden-mounted-resume", @@ -864,26 +1108,26 @@ final class AgentHibernationTests: XCTestCase { agent: snapshot, lastActivityAt: Date(timeIntervalSince1970: 0) ) - XCTAssertTrue(panel.isAgentHibernated) + #expect(panel.isAgentHibernated) workspace.setAgentHibernationAutoResumePresentationVisible(false) - XCTAssertEqual(workspace.agentHibernationVisiblePanelIdsForCurrentLayout(), []) + #expect(workspace.agentHibernationVisiblePanelIdsForCurrentLayout() == []) _ = workspace.debugReconcileTerminalPortalVisibilityForTesting() - XCTAssertTrue(panel.isAgentHibernated) - XCTAssertEqual(workspace.restoredAgentResumeStatesByPanelId[panelId], .manualResumeAvailable) + #expect(panel.isAgentHibernated) + #expect(workspace.restoredAgentResumeStatesByPanelId[panelId] == .manualResumeAvailable) workspace.setAgentHibernationAutoResumePresentationVisible(true) - XCTAssertFalse(panel.isAgentHibernated) - XCTAssertEqual(workspace.restoredAgentResumeStatesByPanelId[panelId], .awaitingAutoResumeCommand) + #expect(!panel.isAgentHibernated) + #expect(workspace.restoredAgentResumeStatesByPanelId[panelId] == .awaitingAutoResumeCommand) } - @MainActor - func testAutosaveFingerprintTracksHibernationTransitions() throws { + @Test @MainActor + func autosaveFingerprintTracksHibernationTransitions() throws { let manager = TabManager() - let workspace = try XCTUnwrap(manager.selectedWorkspace) - let panelId = try XCTUnwrap(workspace.focusedPanelId) + let workspace = try #require(manager.selectedWorkspace) + let panelId = try #require(workspace.focusedPanelId) let snapshot = SessionRestorableAgentSnapshot( kind: .codex, sessionId: "codex-autosave-hibernation", @@ -899,18 +1143,22 @@ final class AgentHibernationTests: XCTestCase { ) let hibernatedFingerprint = manager.sessionAutosaveFingerprint() - XCTAssertNotEqual(liveFingerprint, hibernatedFingerprint) - XCTAssertTrue(workspace.resumeAgentHibernation(panelId: panelId, focus: false)) - XCTAssertNotEqual(hibernatedFingerprint, manager.sessionAutosaveFingerprint()) + #expect(liveFingerprint != hibernatedFingerprint) + #expect(workspace.resumeAgentHibernation(panelId: panelId, focus: false)) + #expect(hibernatedFingerprint != manager.sessionAutosaveFingerprint()) } - @MainActor - func testResumeClearsStaleLifecycleState() throws { + @Test @MainActor + func resumeSeesIdleLifecycleForReHibernation() throws { + // Resume must seed .idle so a previously-hibernated agent becomes + // eligible for re-hibernation once its idle window elapses again. + // (The old behavior cleared all states to .unknown, making agents + // stuck and non-re-hibernatable — the one-shot bug.) let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) + let panelId = try #require(workspace.focusedPanelId) let snapshot = SessionRestorableAgentSnapshot( kind: .codex, - sessionId: "codex-clear-lifecycle-on-resume", + sessionId: "codex-resume-seeds-idle", workingDirectory: "/tmp/cmux-agent-hibernation", launchCommand: launch("codex", "/usr/local/bin/codex", cwd: "/tmp/cmux-agent-hibernation") ) @@ -922,15 +1170,51 @@ final class AgentHibernationTests: XCTestCase { lastActivityAt: Date(timeIntervalSince1970: 0) ) - XCTAssertTrue(workspace.resumeAgentHibernation(panelId: panelId, focus: false)) - XCTAssertEqual(workspace.agentHibernationLifecycleState(panelId: panelId, fallback: nil), .unknown) + #expect(workspace.resumeAgentHibernation(panelId: panelId, focus: false)) + #expect(workspace.agentHibernationLifecycleState(panelId: panelId, fallback: nil) == .idle) + } + + @Test @MainActor + func repeatedIdleCompletionAdvancesLifecycleTimestamp() { + // After resume the panel is seeded .idle. If the agent then emits another + // .idle completion (same value), setAgentLifecycle must still advance + // lifecycleChangeAt so hasUnconfirmedTerminalInput clears. Test via + // AgentHibernationPlanner with synthetic timestamps that model the sequence: + // terminalInput @ T=1, lifecycle @T=2 (first idle), lifecycle @ T=3 (second idle). + // With T=3 > T=1 the guard must be FALSE. + let workspaceId = UUID() + let panel = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let keepLive = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let settings = AgentHibernationSettings.Values( + enabled: true, + idleSeconds: 5, + maxLiveTerminals: 1, + confirmationSeconds: 60 + ) + + // Panel is idle, no unconfirmed input (models state after second .idle clears guard). + let selected = AgentHibernationPlanner.selectedPanelKeys( + inputs: [ + .init(key: panel, hasRestorableAgent: true, isLive: true, isProtected: false, + lifecycle: .idle, hasUnconfirmedTerminalInput: false, lastActivityAt: 0), + .init(key: keepLive, hasRestorableAgent: true, isLive: true, isProtected: false, + lifecycle: .running, hasUnconfirmedTerminalInput: false, lastActivityAt: 0), + ], + settings: settings, + now: 100 + ) + + #expect( + selected.contains(panel), + "An idle panel with no unconfirmed input must be hibernation-eligible after the second .idle clears the guard" + ) } - @MainActor - func testDirectFocusOnHibernatedTerminalPreparesResumeWithoutHiddenFocus() throws { + @Test @MainActor + func directFocusOnHibernatedTerminalPreparesResumeWithoutHiddenFocus() throws { let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) - let panel = try XCTUnwrap(workspace.panels[panelId] as? TerminalPanel) + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) let snapshot = SessionRestorableAgentSnapshot( kind: .codex, sessionId: "codex-direct-focus-resume", @@ -943,19 +1227,19 @@ final class AgentHibernationTests: XCTestCase { agent: snapshot, lastActivityAt: Date(timeIntervalSince1970: 0) ) - XCTAssertTrue(panel.isAgentHibernated) + #expect(panel.isAgentHibernated) panel.focus() - XCTAssertFalse(panel.isAgentHibernated) - XCTAssertEqual(workspace.restoredAgentResumeStatesByPanelId[panelId], .awaitingAutoResumeCommand) + #expect(!panel.isAgentHibernated) + #expect(workspace.restoredAgentResumeStatesByPanelId[panelId] == .awaitingAutoResumeCommand) } - @MainActor - func testExplicitInputToHibernatedTerminalQueuesAndPreparesResume() throws { + @Test @MainActor + func explicitInputToHibernatedTerminalQueuesAndPreparesResume() throws { let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) - let panel = try XCTUnwrap(workspace.panels[panelId] as? TerminalPanel) + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) let snapshot = SessionRestorableAgentSnapshot( kind: .codex, sessionId: "codex-explicit-input-resume", @@ -968,28 +1252,27 @@ final class AgentHibernationTests: XCTestCase { agent: snapshot, lastActivityAt: Date(timeIntervalSince1970: 0) ) - XCTAssertTrue(panel.isAgentHibernated) - XCTAssertEqual(workspace.restoredAgentResumeStatesByPanelId[panelId], .manualResumeAvailable) + #expect(panel.isAgentHibernated) + #expect(workspace.restoredAgentResumeStatesByPanelId[panelId] == .manualResumeAvailable) let result = panel.sendInputResult("pwd\r") - XCTAssertEqual(result, .queued) - XCTAssertFalse(panel.isAgentHibernated) - XCTAssertEqual(workspace.restoredAgentResumeStatesByPanelId[panelId], .awaitingAutoResumeCommand) + #expect(result == .queued) + #expect(!panel.isAgentHibernated) + #expect(workspace.restoredAgentResumeStatesByPanelId[panelId] == .awaitingAutoResumeCommand) } - @MainActor - func testMovedHibernatedTerminalResumesThroughDestinationWorkspace() throws { + @Test @MainActor + func movedHibernatedTerminalResumesThroughDestinationWorkspace() throws { let source = Workspace() - let panelId = try XCTUnwrap(source.focusedPanelId) - let panel = try XCTUnwrap(source.panels[panelId] as? TerminalPanel) - let detached = try XCTUnwrap(source.detachSurface(panelId: panelId)) + let panelId = try #require(source.focusedPanelId) + let panel = try #require(source.panels[panelId] as? TerminalPanel) + let detached = try #require(source.detachSurface(panelId: panelId)) let destination = Workspace() - let destinationPaneId = try XCTUnwrap(destination.bonsplitController.focusedPaneId) - XCTAssertEqual( - destination.attachDetachedSurface(detached, inPane: destinationPaneId, focus: false), - panelId + let destinationPaneId = try #require(destination.bonsplitController.focusedPaneId) + #expect( + destination.attachDetachedSurface(detached, inPane: destinationPaneId, focus: false) == panelId ) let snapshot = SessionRestorableAgentSnapshot( @@ -1003,21 +1286,21 @@ final class AgentHibernationTests: XCTestCase { agent: snapshot, lastActivityAt: Date(timeIntervalSince1970: 0) ) - XCTAssertTrue(panel.isAgentHibernated) + #expect(panel.isAgentHibernated) let result = panel.sendInputResult("pwd\r") - XCTAssertEqual(result, .queued) - XCTAssertFalse(panel.isAgentHibernated) - XCTAssertNil(source.restoredAgentResumeStatesByPanelId[panelId]) - XCTAssertEqual(destination.restoredAgentResumeStatesByPanelId[panelId], .awaitingAutoResumeCommand) + #expect(result == .queued) + #expect(!panel.isAgentHibernated) + #expect(source.restoredAgentResumeStatesByPanelId[panelId] == nil) + #expect(destination.restoredAgentResumeStatesByPanelId[panelId] == .awaitingAutoResumeCommand) } - @MainActor - func testExplicitNamedKeyToHibernatedTerminalQueuesAndPreparesResume() throws { + @Test @MainActor + func explicitNamedKeyToHibernatedTerminalQueuesAndPreparesResume() throws { let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) - let panel = try XCTUnwrap(workspace.panels[panelId] as? TerminalPanel) + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) let snapshot = SessionRestorableAgentSnapshot( kind: .codex, sessionId: "codex-explicit-key-resume", @@ -1030,21 +1313,21 @@ final class AgentHibernationTests: XCTestCase { agent: snapshot, lastActivityAt: Date(timeIntervalSince1970: 0) ) - XCTAssertTrue(panel.isAgentHibernated) - XCTAssertEqual(workspace.restoredAgentResumeStatesByPanelId[panelId], .manualResumeAvailable) + #expect(panel.isAgentHibernated) + #expect(workspace.restoredAgentResumeStatesByPanelId[panelId] == .manualResumeAvailable) let result = panel.sendNamedKeyResult("enter") - XCTAssertEqual(result, .queued) - XCTAssertFalse(panel.isAgentHibernated) - XCTAssertEqual(workspace.restoredAgentResumeStatesByPanelId[panelId], .awaitingAutoResumeCommand) + #expect(result == .queued) + #expect(!panel.isAgentHibernated) + #expect(workspace.restoredAgentResumeStatesByPanelId[panelId] == .awaitingAutoResumeCommand) } - @MainActor - func testResumePreparationWithoutStartupInputStillLeavesHibernation() throws { + @Test @MainActor + func resumePreparationWithoutStartupInputStillLeavesHibernation() throws { let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) - let panel = try XCTUnwrap(workspace.panels[panelId] as? TerminalPanel) + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) let snapshot = SessionRestorableAgentSnapshot( kind: .custom("manual-agent"), sessionId: "manual-agent-session", @@ -1056,13 +1339,13 @@ final class AgentHibernationTests: XCTestCase { agent: snapshot, lastActivityAt: Date(timeIntervalSince1970: 0) ) - XCTAssertTrue(panel.isAgentHibernated) + #expect(panel.isAgentHibernated) let preparation = panel.prepareAgentHibernationResume() - XCTAssertEqual(preparation, .resumed(queuedStartupInput: false)) - XCTAssertFalse(panel.isAgentHibernated) - XCTAssertFalse(panel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(preparation == .resumed(queuedStartupInput: false)) + #expect(!panel.isAgentHibernated) + #expect(!panel.surface.debugInitialInputMetadata().hasInitialInput) } private func launch( diff --git a/cmuxTests/CLIGenericHookPersistenceTests.swift b/cmuxTests/CLIGenericHookPersistenceTests.swift index a521c3f08dbc..40f475e7e771 100644 --- a/cmuxTests/CLIGenericHookPersistenceTests.swift +++ b/cmuxTests/CLIGenericHookPersistenceTests.swift @@ -1,6 +1,10 @@ import XCTest import Darwin +// Swift Testing migration deferred: this file is an extension on CLINotifyProcessIntegrationRegressionTests +// (XCTestCase, ~8500 lines). Migrating only this extension file would require either extracting all shared +// helper methods used via `self.*` from the parent class or migrating the entire parent, both of which are +// outside the scope of this PR. extension CLINotifyProcessIntegrationRegressionTests { struct GenericHookPersistenceScenario { let agent: String @@ -3696,4 +3700,833 @@ extension CLINotifyProcessIntegrationRegressionTests { ) } } + + // MARK: Hibernation lifecycle survival across SessionStart (the not-one-shot fix) + + /// A SessionStart on resume/relaunch must not erase a previously-proven + /// definitive lifecycle. Before the fix, a resume SessionStart wrote + /// `agentLifecycle:.unknown` over a stored `.idle`, so a quiescent resumed + /// agent was stuck at `.unknown` forever and never re-hibernated (hibernation + /// was effectively one-shot). This drives the real CLI binary end-to-end: + /// 1. a turn-end Stop persists `agentLifecycle == "idle"` (+ `lastNotificationStatus == "idle"`), + /// 2. a SessionStart for the SAME session must leave `agentLifecycle == "idle"` (was "unknown" before the fix). + /// It also proves the safety direction: a `.running` (prompt-submit) and a + /// `.needsInput` (permission notification) lifecycle both survive SessionStart, + /// so a busy/blocked resumed agent is never made hibernation-eligible. + func testGenericSessionStartPreservesProvenLifecycle() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("codex-lifecycle-survival") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-lifecycle-survival-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let environment: [String: String] = [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + + func runCodexHook(_ subcommand: String, input: String) -> ProcessRunResult { + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { return "OK" } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return self.surfaceListResponse(id: id, surfaceId: surfaceId) + case "surface.resume.set": + return self.v2Response(id: id, ok: true, result: ["ok": true]) + case "feed.push": + return self.v2Response(id: id, ok: true, result: [:]) + default: + return self.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "codex", subcommand], + environment: environment, + standardInput: input, + timeout: 5 + ) + wait(for: [serverHandled], timeout: 5) + return result + } + + func persistedLifecycle(_ sessionId: String) throws -> (agentLifecycle: String?, lastNotificationStatus: String?) { + let storeURL = root.appendingPathComponent("codex-hook-sessions.json", isDirectory: false) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) + let session = try XCTUnwrap(sessions[sessionId] as? [String: Any]) + return (session["agentLifecycle"] as? String, session["lastNotificationStatus"] as? String) + } + + // --- Core red/green: idle must survive SessionStart --- + // Faithful repro: the agent does a turn (prompt-submit -> running), finishes + // it (stop -> idle), then is resumed (session-start). The resume must NOT + // clobber the proven idle to unknown. + let idleSession = "codex-idle-session" + let idlePromptSubmit = runCodexHook( + "prompt-submit", + input: #"{"session_id":"\#(idleSession)","cwd":"\#(root.path)","hook_event_name":"UserPromptSubmit","prompt":"do work"}"# + ) + XCTAssertFalse(idlePromptSubmit.timedOut, idlePromptSubmit.stderr) + XCTAssertEqual(idlePromptSubmit.status, 0, idlePromptSubmit.stderr) + let idleStop = runCodexHook( + "stop", + input: #"{"session_id":"\#(idleSession)","cwd":"\#(root.path)","hook_event_name":"Stop"}"# + ) + XCTAssertFalse(idleStop.timedOut, idleStop.stderr) + XCTAssertEqual(idleStop.status, 0, idleStop.stderr) + let afterStop = try persistedLifecycle(idleSession) + XCTAssertEqual(afterStop.agentLifecycle, "idle", "Codex Stop must persist an idle lifecycle") + XCTAssertEqual(afterStop.lastNotificationStatus, "idle", "Codex Stop must persist an idle notification status") + + let idleRestart = runCodexHook( + "session-start", + input: #"{"session_id":"\#(idleSession)","cwd":"\#(root.path)","hook_event_name":"SessionStart"}"# + ) + XCTAssertFalse(idleRestart.timedOut, idleRestart.stderr) + XCTAssertEqual(idleRestart.status, 0, idleRestart.stderr) + let afterRestart = try persistedLifecycle(idleSession) + XCTAssertEqual( + afterRestart.agentLifecycle, "idle", + "A resume SessionStart must NOT clobber a proven idle lifecycle to unknown (the hibernation-one-shot bug)" + ) + + // --- Safety: running (mid-turn) must survive SessionStart --- + let runningSession = "codex-running-session" + let promptSubmit = runCodexHook( + "prompt-submit", + input: #"{"session_id":"\#(runningSession)","cwd":"\#(root.path)","hook_event_name":"UserPromptSubmit","prompt":"do work"}"# + ) + XCTAssertFalse(promptSubmit.timedOut, promptSubmit.stderr) + XCTAssertEqual(promptSubmit.status, 0, promptSubmit.stderr) + XCTAssertEqual(try persistedLifecycle(runningSession).agentLifecycle, "running", "Codex prompt-submit must persist a running lifecycle") + + let runningRestart = runCodexHook( + "session-start", + input: #"{"session_id":"\#(runningSession)","cwd":"\#(root.path)","hook_event_name":"SessionStart"}"# + ) + XCTAssertFalse(runningRestart.timedOut, runningRestart.stderr) + XCTAssertEqual(runningRestart.status, 0, runningRestart.stderr) + XCTAssertEqual( + try persistedLifecycle(runningSession).agentLifecycle, "running", + "A SessionStart must preserve a busy (running) lifecycle so a working resumed agent is never hibernated" + ) + + // --- Safety: needsInput (blocked on a permission prompt) must survive SessionStart --- + let blockedSession = "codex-blocked-session" + let permission = runCodexHook( + "notification", + input: #"{"session_id":"\#(blockedSession)","cwd":"\#(root.path)","hook_event_name":"Notification","reason":"permission_prompt","message":"Allow shell command?"}"# + ) + XCTAssertFalse(permission.timedOut, permission.stderr) + XCTAssertEqual(permission.status, 0, permission.stderr) + XCTAssertEqual(try persistedLifecycle(blockedSession).agentLifecycle, "needsInput", "Codex permission notification must persist a needsInput lifecycle") + + let blockedRestart = runCodexHook( + "session-start", + input: #"{"session_id":"\#(blockedSession)","cwd":"\#(root.path)","hook_event_name":"SessionStart"}"# + ) + XCTAssertFalse(blockedRestart.timedOut, blockedRestart.stderr) + XCTAssertEqual(blockedRestart.status, 0, blockedRestart.stderr) + XCTAssertEqual( + try persistedLifecycle(blockedSession).agentLifecycle, "needsInput", + "A SessionStart must preserve a blocked (needsInput) lifecycle so a mid-tool resumed agent is never hibernated" + ) + } + + /// Claude parity: a claude Stop must persist BOTH `agentLifecycle == "idle"` + /// and `lastNotificationStatus == "idle"` (matching the generic stop handler), + /// and a non-clear SessionStart must preserve both; a `/clear` SessionStart is + /// a genuine new-conversation boundary that correctly promotes to `running`. + func testClaudeSessionStartPreservesIdleAndClearPromotesRunning() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("claude-lifecycle-survival") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-lifecycle-survival-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "claude-lifecycle-session" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let environment: [String: String] = [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + + func runClaudeHook(_ subcommand: String, input: String) -> ProcessRunResult { + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { return "OK" } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return self.surfaceListResponse(id: id, surfaceId: surfaceId) + case "surface.resume.set": + return self.v2Response(id: id, ok: true, result: ["ok": true]) + case "feed.push": + return self.v2Response(id: id, ok: true, result: [:]) + default: + return self.v2Response(id: id, ok: true, result: [:]) + } + } + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "claude", subcommand], + environment: environment, + standardInput: input, + timeout: 5 + ) + wait(for: [serverHandled], timeout: 5) + return result + } + + func persisted() throws -> (agentLifecycle: String?, lastNotificationStatus: String?) { + let storeURL = root.appendingPathComponent("claude-hook-sessions.json", isDirectory: false) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) + let session = try XCTUnwrap(sessions[sessionId] as? [String: Any]) + return (session["agentLifecycle"] as? String, session["lastNotificationStatus"] as? String) + } + + // A Stop persists idle + idle notification status. (No active session is + // marked for this workspace yet, so the stop's is-current guard passes and + // the upsert applies.) + let stop = runClaudeHook( + "stop", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"Stop"}"# + ) + XCTAssertFalse(stop.timedOut, stop.stderr) + XCTAssertEqual(stop.status, 0, stop.stderr) + let afterStop = try persisted() + XCTAssertEqual(afterStop.agentLifecycle, "idle", "Claude Stop must persist an idle lifecycle") + XCTAssertEqual(afterStop.lastNotificationStatus, "idle", "Claude Stop must persist an idle notification status (generic parity)") + + // A non-clear (startup/resume/compact) SessionStart must preserve idle. + let resume = runClaudeHook( + "session-start", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"SessionStart","source":"startup"}"# + ) + XCTAssertFalse(resume.timedOut, resume.stderr) + XCTAssertEqual(resume.status, 0, resume.stderr) + let afterResume = try persisted() + XCTAssertEqual( + afterResume.agentLifecycle, "idle", + "A non-clear claude SessionStart must NOT clobber the proven idle lifecycle to unknown" + ) + XCTAssertEqual(afterResume.lastNotificationStatus, "idle", "Claude resume must preserve the idle notification status") + + // A /clear SessionStart is a new-conversation boundary: it correctly + // promotes to running (definitive incoming overwrites under preservingDefinitive). + let clear = runClaudeHook( + "session-start", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"SessionStart","source":"clear"}"# + ) + XCTAssertFalse(clear.timedOut, clear.stderr) + XCTAssertEqual(clear.status, 0, clear.stderr) + XCTAssertEqual( + try persisted().agentLifecycle, "running", + "A /clear SessionStart must still promote to running (the genuine new-turn boundary is preserved)" + ) + } + + // MARK: Gemini lifecycle parity + + /// Gemini parity: Gemini is a non-Codex, non-Claude generic agent dispatched via + /// `runGenericAgentHook`. This test verifies the same four lifecycle invariants as + /// `testGenericSessionStartPreservesProvenLifecycle` (Codex) on the Gemini code path: + /// 1. AfterAgent (stop) persists `agentLifecycle == "idle"` and `lastNotificationStatus == "idle"`. + /// 2. A SessionStart for the same session preserves `agentLifecycle == "idle"` (the hibernation-one-shot fix). + /// 3. A running (BeforeAgent/prompt-submit) lifecycle survives SessionStart. + /// 4. A needsInput (permission notification) lifecycle survives SessionStart. + func testGeminiAfterAgentPersistsIdleLifecycle() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("gemini-lifecycle-survival") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-gemini-lifecycle-survival-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let environment: [String: String] = [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + + func runGeminiHook(_ subcommand: String, input: String) -> ProcessRunResult { + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { return "OK" } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return self.surfaceListResponse(id: id, surfaceId: surfaceId) + case "surface.resume.set": + return self.v2Response(id: id, ok: true, result: ["ok": true]) + case "feed.push": + return self.v2Response(id: id, ok: true, result: [:]) + default: + return self.v2Response(id: id, ok: true, result: [:]) + } + } + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "gemini", subcommand], + environment: environment, + standardInput: input, + timeout: 5 + ) + wait(for: [serverHandled], timeout: 5) + return result + } + + func persistedLifecycle(_ sessionId: String) throws -> (agentLifecycle: String?, lastNotificationStatus: String?) { + let storeURL = root.appendingPathComponent("gemini-hook-sessions.json", isDirectory: false) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) + let session = try XCTUnwrap(sessions[sessionId] as? [String: Any]) + return (session["agentLifecycle"] as? String, session["lastNotificationStatus"] as? String) + } + + // --- Core: AfterAgent (stop) persists idle lifecycle and idle notification status --- + // No prior session record; the handler resolves workspace/surface from + // CMUX_WORKSPACE_ID/CMUX_SURFACE_ID env. This exercises the first-stop path that + // `testGenericSessionStartPreservesProvenLifecycle` never reaches for Gemini. + let idleSession = "gemini-idle-session" + let idleStop = runGeminiHook( + "stop", + input: #"{"session_id":"\#(idleSession)","cwd":"\#(root.path)","hook_event_name":"AfterAgent"}"# + ) + XCTAssertFalse(idleStop.timedOut, idleStop.stderr) + XCTAssertEqual(idleStop.status, 0, idleStop.stderr) + let afterStop = try persistedLifecycle(idleSession) + XCTAssertEqual(afterStop.agentLifecycle, "idle", "Gemini AfterAgent (stop) must persist an idle lifecycle") + XCTAssertEqual(afterStop.lastNotificationStatus, "idle", "Gemini AfterAgent (stop) must persist an idle notification status") + + // --- One-shot fix: a SessionStart must not clobber the proven idle --- + // Before the fix, `store.upsert(agentLifecycle: .unknown)` overwrote the stored + // `.idle`, so a resumed-but-quiescent Gemini agent was stuck at `.unknown` and + // never became hibernation-eligible again. + let idleRestart = runGeminiHook( + "session-start", + input: #"{"session_id":"\#(idleSession)","cwd":"\#(root.path)","hook_event_name":"SessionStart"}"# + ) + XCTAssertFalse(idleRestart.timedOut, idleRestart.stderr) + XCTAssertEqual(idleRestart.status, 0, idleRestart.stderr) + XCTAssertEqual( + try persistedLifecycle(idleSession).agentLifecycle, "idle", + "A Gemini SessionStart must NOT clobber a proven idle lifecycle to unknown (the hibernation-one-shot bug)" + ) + + // --- Safety: running (mid-turn) must survive SessionStart --- + let runningSession = "gemini-running-session" + let promptSubmit = runGeminiHook( + "prompt-submit", + input: #"{"session_id":"\#(runningSession)","cwd":"\#(root.path)","hook_event_name":"BeforeAgent","prompt":"do work"}"# + ) + XCTAssertFalse(promptSubmit.timedOut, promptSubmit.stderr) + XCTAssertEqual(promptSubmit.status, 0, promptSubmit.stderr) + XCTAssertEqual(try persistedLifecycle(runningSession).agentLifecycle, "running", "Gemini BeforeAgent (prompt-submit) must persist a running lifecycle") + + let runningRestart = runGeminiHook( + "session-start", + input: #"{"session_id":"\#(runningSession)","cwd":"\#(root.path)","hook_event_name":"SessionStart"}"# + ) + XCTAssertFalse(runningRestart.timedOut, runningRestart.stderr) + XCTAssertEqual(runningRestart.status, 0, runningRestart.stderr) + XCTAssertEqual( + try persistedLifecycle(runningSession).agentLifecycle, "running", + "A Gemini SessionStart must preserve a busy (running) lifecycle so a working resumed agent is never hibernated" + ) + + // --- Safety: needsInput (blocked on a permission prompt) must survive SessionStart --- + let blockedSession = "gemini-blocked-session" + let permission = runGeminiHook( + "notification", + input: #"{"session_id":"\#(blockedSession)","cwd":"\#(root.path)","hook_event_name":"Notification","message":"Needs approval to run command"}"# + ) + XCTAssertFalse(permission.timedOut, permission.stderr) + XCTAssertEqual(permission.status, 0, permission.stderr) + XCTAssertEqual(try persistedLifecycle(blockedSession).agentLifecycle, "needsInput", "Gemini permission notification must persist a needsInput lifecycle") + + let blockedRestart = runGeminiHook( + "session-start", + input: #"{"session_id":"\#(blockedSession)","cwd":"\#(root.path)","hook_event_name":"SessionStart"}"# + ) + XCTAssertFalse(blockedRestart.timedOut, blockedRestart.stderr) + XCTAssertEqual(blockedRestart.status, 0, blockedRestart.stderr) + XCTAssertEqual( + try persistedLifecycle(blockedSession).agentLifecycle, "needsInput", + "A Gemini SessionStart must preserve a blocked (needsInput) lifecycle so a mid-tool resumed agent is never hibernated" + ) + } + + // MARK: - P1: Claude AskUserQuestion + Notification regression + + /// P1 regression: a Claude `Notification` with a generic body (no "permission/approve" + /// keywords) must NOT overwrite a `.needsInput` lifecycle that was set by a prior + /// `AskUserQuestion` `PreToolUse` hook. + /// + /// Without the fix, the notification classifier unconditionally sets `.idle` (the body + /// "Which approach?" has no permission keywords) and the upsert overwrites the stored + /// `.needsInput`, making the still-blocked agent hibernation-eligible. + func testClaudeAskUserQuestionNotificationPreservesNeedsInput() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("claude-ask-clobber") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-ask-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "claude-ask-session" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let environment: [String: String] = [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + + func runClaudeHook(_ subcommand: String, input: String) -> ProcessRunResult { + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { return "OK" } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return self.surfaceListResponse(id: id, surfaceId: surfaceId) + case "surface.resume.set": + return self.v2Response(id: id, ok: true, result: ["ok": true]) + case "feed.push": + return self.v2Response(id: id, ok: true, result: [:]) + default: + return self.v2Response(id: id, ok: true, result: [:]) + } + } + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "claude", subcommand], + environment: environment, + standardInput: input, + timeout: 5 + ) + wait(for: [serverHandled], timeout: 5) + return result + } + + func persistedLifecycle() throws -> String? { + let storeURL = root.appendingPathComponent("claude-hook-sessions.json", isDirectory: false) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) + let session = try XCTUnwrap(sessions[sessionId] as? [String: Any]) + return session["agentLifecycle"] as? String + } + + // /clear SessionStart: establishes an active session boundary so subsequent + // PreToolUse and Notification pass the isCurrent guard. + let clearStart = runClaudeHook( + "session-start", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"SessionStart","source":"clear"}"# + ) + XCTAssertFalse(clearStart.timedOut, clearStart.stderr) + XCTAssertEqual(clearStart.status, 0, clearStart.stderr) + + // AskUserQuestion PreToolUse: Claude is asking the user something, so the + // session must become needsInput so the hibernation controller keeps it live. + let preToolUse = runClaudeHook( + "pre-tool-use", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"PreToolUse","tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"Which approach?","options":[{"label":"Option A"},{"label":"Option B"}]}]}}"# + ) + XCTAssertFalse(preToolUse.timedOut, preToolUse.stderr) + XCTAssertEqual(preToolUse.status, 0, preToolUse.stderr) + XCTAssertEqual( + try persistedLifecycle(), "needsInput", + "AskUserQuestion PreToolUse must store agentLifecycle=needsInput" + ) + + // Notification fires right after AskUserQuestion with the question text. + // "Which approach?" has no permission keywords, so notificationIndicatesBlocked() + // returns false and the naive classifier produces .idle. Without the fix this + // overwrites the stored needsInput with idle, making the blocked agent + // hibernation-eligible. With the fix, needsInput is preserved. + let notification = runClaudeHook( + "notification", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"Notification","message":"Which approach?"}"# + ) + XCTAssertFalse(notification.timedOut, notification.stderr) + XCTAssertEqual(notification.status, 0, notification.stderr) + XCTAssertEqual( + try persistedLifecycle(), "needsInput", + "Claude Notification with a generic body must NOT clobber a needsInput set by AskUserQuestion PreToolUse (P1 regression)" + ) + } + + // MARK: - Error and Attention notification classification + + /// Error notifications must set `.needsInput` (non-hibernatable), mirroring the generic + /// path's `.error` → `.needsInput` mapping. Previously they mapped to `.idle`, making + /// a still-broken agent eligible for hibernation. + func testClaudeErrorNotificationSetsNeedsInput() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("claude-error-notif") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-error-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "claude-error-session" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let environment: [String: String] = [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + + func runClaudeHook(_ subcommand: String, input: String) -> ProcessRunResult { + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { return "OK" } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return self.surfaceListResponse(id: id, surfaceId: surfaceId) + case "surface.resume.set": + return self.v2Response(id: id, ok: true, result: ["ok": true]) + case "feed.push": + return self.v2Response(id: id, ok: true, result: [:]) + default: + return self.v2Response(id: id, ok: true, result: [:]) + } + } + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "claude", subcommand], + environment: environment, + standardInput: input, + timeout: 5 + ) + wait(for: [serverHandled], timeout: 5) + return result + } + + func persistedLifecycle() throws -> String? { + let storeURL = root.appendingPathComponent("claude-hook-sessions.json", isDirectory: false) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) + let session = try XCTUnwrap(sessions[sessionId] as? [String: Any]) + return session["agentLifecycle"] as? String + } + + let sessionStart = runClaudeHook( + "session-start", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"SessionStart","source":"clear"}"# + ) + XCTAssertFalse(sessionStart.timedOut, sessionStart.stderr) + XCTAssertEqual(sessionStart.status, 0, sessionStart.stderr) + + // Error notification: signal=error, message=. Triggers the "Error" + // subtitle branch → must store agentLifecycle=needsInput, not idle. + let errorNotif = runClaudeHook( + "notification", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"Notification","message":"failed to apply diff"}"# + ) + XCTAssertFalse(errorNotif.timedOut, errorNotif.stderr) + XCTAssertEqual(errorNotif.status, 0, errorNotif.stderr) + + // The notification hook classifies "failed" as an "Error" subtitle. + // Before the fix the stored lifecycle was "idle"; the correct value is "needsInput". + XCTAssertEqual( + try persistedLifecycle(), "needsInput", + "Claude notification with error/failed text must store agentLifecycle=needsInput, not idle (P1 regression)" + ) + } + + /// "Attention" notifications with a specific (non-fallback) message must NOT change the + /// stored lifecycle. The agent may be mid-turn; updating lifecycle to .idle would make + /// it prematurely hibernatable. + func testClaudeAttentionNotificationWithSpecificMessageDoesNotChangeLifecycle() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("claude-attention-notif") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-attn-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "claude-attn-session" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let environment: [String: String] = [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + + func runClaudeHook(_ subcommand: String, input: String) -> ProcessRunResult { + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { return "OK" } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return self.surfaceListResponse(id: id, surfaceId: surfaceId) + case "surface.resume.set": + return self.v2Response(id: id, ok: true, result: ["ok": true]) + case "feed.push": + return self.v2Response(id: id, ok: true, result: [:]) + default: + return self.v2Response(id: id, ok: true, result: [:]) + } + } + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "claude", subcommand], + environment: environment, + standardInput: input, + timeout: 5 + ) + wait(for: [serverHandled], timeout: 5) + return result + } + + func persistedLifecycle() throws -> String? { + let storeURL = root.appendingPathComponent("claude-hook-sessions.json", isDirectory: false) + guard let data = try? Data(contentsOf: storeURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let sessions = json["sessions"] as? [String: Any], + let session = sessions[sessionId] as? [String: Any] else { return nil } + return session["agentLifecycle"] as? String + } + + let sessionStart = runClaudeHook( + "session-start", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"SessionStart","source":"clear"}"# + ) + XCTAssertFalse(sessionStart.timedOut, sessionStart.stderr) + XCTAssertEqual(sessionStart.status, 0, sessionStart.stderr) + + // Seed .needsInput in the persisted store by running a stop with needsInput result, + // simulating an agent that is mid-turn with a lifecycle already written. + let stop = runClaudeHook( + "stop", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"Stop","stop_reason":"user"}"# + ) + XCTAssertFalse(stop.timedOut, stop.stderr) + // stop hook exits 0 or non-zero depending on override; just check it ran. + _ = stop + + // Write needsInput directly through a pre-tool-use so the store has a definitive value. + let preToolUse = runClaudeHook( + "pre-tool-use", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"PreToolUse","tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"Continue?","options":[{"label":"Yes"},{"label":"No"}]}]}}"# + ) + XCTAssertFalse(preToolUse.timedOut, preToolUse.stderr) + XCTAssertEqual(preToolUse.status, 0, preToolUse.stderr) + XCTAssertEqual( + try persistedLifecycle(), "needsInput", + "prereq: AskUserQuestion PreToolUse must store needsInput" + ) + + // Attention notification with a specific message (not the generic fallback). + // Before the fix this overwrote needsInput with idle; the correct behavior is no change. + let attnNotif = runClaudeHook( + "notification", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"Notification","message":"Reviewing changes in src/"}"# + ) + XCTAssertFalse(attnNotif.timedOut, attnNotif.stderr) + XCTAssertEqual(attnNotif.status, 0, attnNotif.stderr) + + XCTAssertEqual( + try persistedLifecycle(), "needsInput", + "Claude Attention notification with a specific message must NOT clobber a prior needsInput lifecycle (P1 regression)" + ) + } + + // MARK: - P2: Generic SessionStart live-unknown bypasses effective() fallback + + /// P2 regression: when a generic agent session record has `lastNotificationStatus=idle` + /// but no explicit `agentLifecycle`, a `SessionStart` must not push `.unknown` to the + /// live per-panel map. + /// + /// Before the fix, the guard `!(mapped?.agentLifecycle.map { $0 != .unknown } ?? false)` + /// evaluated `nil` as `false` (no prior lifecycle → push unknown), ignoring the + /// `lastNotificationStatus` fallback. `Workspace.agentHibernationLifecycleState` uses + /// `effective()` which would return `.idle` for `lastNotificationStatus=idle`, but the + /// live `.unknown` entry takes precedence over the persisted fallback, making a + /// notification-status-only idle agent permanently un-hibernatable after any restart. + func testGenericSessionStartSkipsLiveUnknownWhenEffectiveLifecycleIsIdle() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("gemini-unknown-skip") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-gemini-unknown-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "gemini-fallback-idle" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + // Seed a record with only lastNotificationStatus=idle (no agentLifecycle key). + // This models a plugin/wrapper agent that emits a notification status but never + // calls set_agent_lifecycle directly. + let storeURL = root.appendingPathComponent("gemini-hook-sessions.json", isDirectory: false) + let seedStore: [String: Any] = [ + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": workspaceId, + "surfaceId": surfaceId, + "cwd": root.path, + "lastNotificationStatus": "idle", + "startedAt": 1_700_000_000.0, + "updatedAt": 1_700_000_000.0, + ] + ] + ] + try JSONSerialization.data(withJSONObject: seedStore, options: .prettyPrinted).write(to: storeURL) + + let environment: [String: String] = [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { return "OK" } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return self.surfaceListResponse(id: id, surfaceId: surfaceId) + case "surface.resume.set": + return self.v2Response(id: id, ok: true, result: ["ok": true]) + case "feed.push": + return self.v2Response(id: id, ok: true, result: [:]) + default: + return self.v2Response(id: id, ok: true, result: [:]) + } + } + + let sessionStart = runProcess( + executablePath: cliPath, + arguments: ["hooks", "gemini", "session-start"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"SessionStart"}"#, + timeout: 5 + ) + wait(for: [serverHandled], timeout: 5) + + XCTAssertFalse(sessionStart.timedOut, sessionStart.stderr) + XCTAssertEqual(sessionStart.status, 0, sessionStart.stderr) + + // Without the fix: guard checked raw agentLifecycle (nil → push unknown → defeats + // effective() fallback). With the fix: effective() returns .idle for + // lastNotificationStatus=idle → skip the live unknown write. + XCTAssertFalse( + state.commands.contains { + $0.hasPrefix("set_agent_lifecycle gemini unknown --tab=\(workspaceId)") + && $0.contains("--panel=\(surfaceId)") + }, + "SessionStart must not push live unknown when effective lifecycle is idle via lastNotificationStatus (P2 regression), commands: \(state.commands)" + ) + } }