From 233928c4865c43319464d2dd934c35b28129377e Mon Sep 17 00:00:00 2001 From: Vlad Orlenko Date: Mon, 1 Jun 2026 14:42:16 -0400 Subject: [PATCH 1/2] Add failing tests for stuck agent "Running" badge on shell prompt return The sidebar's structured-agent status pill (e.g. claude_code "Running") is set by the agent hook integration and only cleared by a Stop/SessionEnd hook or by closing the tab. There is no app-side liveness check, so an abnormal agent exit that skips a clean SessionEnd (SIGKILL, crash, the 1s SessionEnd hook timeout, a SessionEnd with no session_id, nested-subagent suppression) leaves the pill stuck on "Running" while the shell sits idle at its prompt. Add behavior-level tests that drive a panel to promptIdle and assert the structured status/lifecycle is reconciled when the tracked agent process is dead (or was never recorded), while a genuinely live agent is preserved. These fail without the fix in the following commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmuxTests/AgentHibernationTests.swift | 76 +++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) 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) From 4b984255a3c30d8a47985dbd1851a30dae2e0a0a Mon Sep 17 00:00:00 2001 From: Vlad Orlenko Date: Mon, 1 Jun 2026 14:42:23 -0400 Subject: [PATCH 2/2] Clear stuck agent "Running" badge when the shell returns to its prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structured agents (claude_code, codex, …) run as the shell's foreground process, so the shell integration only reports promptIdle after the agent process has exited. The hooks that set the "Running" status/lifecycle are supposed to clear it on Stop/SessionEnd, but abnormal exits skip that path and leave the sidebar pill stuck on "Running" indefinitely. Add an app-side safety net: on a promptIdle transition, reconcile the panel's structured-agent status. It is PID-liveness gated — clear only when the tracked agent PID is dead (kill(pid, 0)), with a fallback that retires entries which never recorded a PID to probe. A live agent, and a status shared with a still-running agent on another panel, are left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Workspace+PanelLifecycle.swift | 61 ++++++++++++++++++++++++++ Sources/Workspace.swift | 3 ++ 2 files changed, 64 insertions(+) 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)) " +