diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 7900fbff1504..c0a214c0dd9c 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -247,6 +247,67 @@ extension Workspace { recomputeListeningPorts() } + /// Retire a panel's stale structured-agent "Running" status once the shell + /// returns to its prompt. + /// + /// Structured agents (claude_code, codex, …) run as the shell's foreground + /// process, so the integration only reports `promptIdle` after the agent + /// process has exited. Hooks normally clear the status/lifecycle on + /// Stop/SessionEnd, but abnormal exits (SIGKILL, crash, the 1s SessionEnd + /// hook timeout, a SessionEnd with no session_id, nested-subagent + /// suppression) can leave the sidebar pill stuck on "Running". This is the + /// app-side safety net the hooks lack: when the prompt returns, clear any + /// structured runtime for this panel whose tracked process is no longer + /// alive (or that never recorded a PID to probe). + @discardableResult + func reconcileStuckAgentStatusOnPromptIdle(panelId: UUID) -> Bool { + let pidKeys = agentPIDKeysByPanelId[panelId] ?? [] + let structuredPIDStatusKeys = Set( + pidKeys + .filter { isStructuredAgentHookPIDKey($0) } + .map { agentStatusKey(forAgentPIDKey: $0) } + ) + + var didChange = false + for key in pidKeys where isStructuredAgentHookPIDKey(key) { + if let pid = agentPIDs[key], Self.isAgentProcessAlive(pid) { + continue + } + if clearAgentPID(key: key, panelId: panelId, clearStatus: true, refreshPorts: false) { + didChange = true + } + } + + // Structured lifecycle/status set without an owning PID key (the + // set_agent_pid hook never recorded a PID, so there is nothing to + // probe). promptIdle alone is enough to retire it. + let orphanLifecycleKeys = (agentLifecycleStatesByPanelId[panelId].map { Array($0.keys) } ?? []) + .filter { Self.structuredAgentHookStatusKeys.contains($0) && !structuredPIDStatusKeys.contains($0) } + for key in orphanLifecycleKeys { + if clearAgentLifecycle(key: key, panelId: panelId) { + didChange = true + } + if !hasAgentRuntime(forStatusKey: key), + statusEntries.removeValue(forKey: key) != nil { + didChange = true + } + } + + if didChange { + refreshTrackedAgentPorts() + } + return didChange + } + + private static func isAgentProcessAlive(_ pid: pid_t) -> Bool { + guard pid > 0 else { return false } + if kill(pid, 0) == 0 { + return true + } + // EPERM means the process exists but is owned by another user. + return errno == EPERM + } + @discardableResult private func discardAgentRuntimeState(_ runtimeState: DetachedAgentRuntimeState?) -> Bool { guard let runtimeState else { return false } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 2a42a9402972..f59cb1a44323 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -10984,6 +10984,9 @@ final class Workspace: Identifiable, ObservableObject { shellState: state ) } + if state == .promptIdle { + reconcileStuckAgentStatusOnPromptIdle(panelId: panelId) + } #if DEBUG cmuxDebugLog( "surface.shellState workspace=\(id.uuidString.prefix(5)) " + diff --git a/cmuxTests/AgentHibernationTests.swift b/cmuxTests/AgentHibernationTests.swift index bdd2293d2ae9..7e3dfe1abf2b 100644 --- a/cmuxTests/AgentHibernationTests.swift +++ b/cmuxTests/AgentHibernationTests.swift @@ -282,6 +282,82 @@ final class AgentHibernationTests: XCTestCase { XCTAssertEqual(workspace.agentHibernationLifecycleState(panelId: secondPanelId, fallback: nil), .running) } + /// Launch and reap a throwaway process so we get a PID that is guaranteed + /// dead by the time the test probes it. + private func deadProcessPID() throws -> pid_t { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/true") + try process.run() + process.waitUntilExit() + return process.processIdentifier + } + + @MainActor + func testPromptIdleClearsStuckRunningStatusWhenAgentProcessDead() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let deadPID = try deadProcessPID() + + workspace.statusEntries["claude_code"] = SidebarStatusEntry( + key: "claude_code", + value: "Running", + icon: "bolt.fill", + color: "#4C8DFF" + ) + workspace.recordAgentPID(key: "claude_code.session", pid: deadPID, panelId: panelId, refreshPorts: false) + workspace.setAgentLifecycle(key: "claude_code", panelId: panelId, lifecycle: .running) + + workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle) + + XCTAssertNil(workspace.statusEntries["claude_code"], "stale Running pill should be cleared") + XCTAssertEqual(workspace.agentHibernationLifecycleState(panelId: panelId, fallback: nil), .unknown) + XCTAssertNil(workspace.agentPIDs["claude_code.session"]) + } + + @MainActor + func testPromptIdleClearsOrphanedRunningStatusWithoutTrackedPID() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + + // Status + lifecycle were reported but the set_agent_pid hook never + // recorded a PID, so there is no process to probe. + workspace.statusEntries["claude_code"] = SidebarStatusEntry( + key: "claude_code", + value: "Running", + icon: "bolt.fill", + color: "#4C8DFF" + ) + workspace.setAgentLifecycle(key: "claude_code", panelId: panelId, lifecycle: .running) + + workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle) + + XCTAssertNil(workspace.statusEntries["claude_code"]) + XCTAssertEqual(workspace.agentHibernationLifecycleState(panelId: panelId, fallback: nil), .unknown) + } + + @MainActor + func testPromptIdleKeepsRunningStatusWhileAgentProcessAlive() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + + // The test runner process is unambiguously alive. + let livePID = ProcessInfo.processInfo.processIdentifier + workspace.statusEntries["claude_code"] = SidebarStatusEntry( + key: "claude_code", + value: "Running", + icon: "bolt.fill", + color: "#4C8DFF" + ) + workspace.recordAgentPID(key: "claude_code.session", pid: livePID, panelId: panelId, refreshPorts: false) + workspace.setAgentLifecycle(key: "claude_code", panelId: panelId, lifecycle: .running) + + workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle) + + XCTAssertNotNil(workspace.statusEntries["claude_code"], "a live agent's status must not be cleared") + XCTAssertEqual(workspace.agentHibernationLifecycleState(panelId: panelId, fallback: nil), .running) + XCTAssertEqual(workspace.agentPIDs["claude_code.session"], livePID) + } + func testSessionIndexLoadsAgentLifecycleFromHookStore() throws { let home = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-hibernation-index-\(UUID().uuidString)", isDirectory: true)