Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions Sources/Workspace+PanelLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 isAgentProcessAlive accesses no Workspace instance or class-level state — it's a pure POSIX probe. As a private static on an @MainActor class it inherits main-actor isolation, which prevents the compiler from calling it from non-isolated or nonisolated async contexts even though there's nothing actor-sensitive inside it. Marking it nonisolated removes that coupling, matches the cmux convention for pure static helpers, and lets the optimizer emit the call without an actor hop.

Suggested change
private static func isAgentProcessAlive(_ pid: pid_t) -> Bool {
nonisolated private static func isAgentProcessAlive(_ pid: pid_t) -> Bool {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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 }
Expand Down
3 changes: 3 additions & 0 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10984,6 +10984,9 @@ final class Workspace: Identifiable, ObservableObject {
shellState: state
)
}
if state == .promptIdle {
reconcileStuckAgentStatusOnPromptIdle(panelId: panelId)
}
Comment on lines +10987 to +10989

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate and print the reconciliation method + PID-liveness helper
ast-grep --pattern $'func reconcileStuckAgentStatusOnPromptIdle(panelId: $_) {
  $$$
}'

ast-grep --pattern $'func isAgentProcessAlive($_) -> $_ {
  $$$
}'

# Inspect any full-collection iteration over status/PID maps inside the reconciliation path
rg -nP -C3 '\b(statusEntries|agentPIDs)\b\s*\.\s*(forEach|map|filter|keys|values|reduce|first|contains)\b' -g 'Sources/Workspace+PanelLifecycle.swift'

Repository: manaflow-ai/cmux

Length of output: 2538


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find the actual implementation(s)
rg -n "reconcileStuckAgentStatusOnPromptIdle" -S Sources/Workspace+PanelLifecycle.swift

# 2) Print the full function body region around the match (up to a safe window)
#    (We keep this bounded to avoid dumping huge files.)
match_line=$(rg -n "reconcileStuckAgentStatusOnPromptIdle" -S Sources/Workspace+PanelLifecycle.swift | head -n1 | cut -d: -f1)
start=$((match_line-40))
end=$((match_line+200))
sed -n "${start},${end}p" Sources/Workspace+PanelLifecycle.swift | nl -ba | sed 's/^/| /'

# 3) Within that region, look for full-collection scans over statusEntries/agentPIDs
#    (restricted to the same region window)
sed -n "${start},${end}p" Sources/Workspace+PanelLifecycle.swift | \
  rg -n "\b(statusEntries|agentPIDs|agentPIDPanelIdsByKey)\b.*\.(forEach|map|filter|keys|values|reduce|contains|first)\b"

Repository: manaflow-ai/cmux

Length of output: 198


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="Sources/Workspace+PanelLifecycle.swift"

python3 - <<'PY'
import re

path = "Sources/Workspace+PanelLifecycle.swift"
with open(path, "r", encoding="utf-8") as f:
    lines = f.readlines()

pat = re.compile(r'^\s*func\s+reconcileStuckAgentStatusOnPromptIdle\s*\(\s*panelId:\s*UUID\s*\)\s*->\s*Bool\s*{')
start = None
for i,l in enumerate(lines):
    if pat.search(l):
        start = i
        break

if start is None:
    # fallback: find any reconcileStuckAgentStatusOnPromptIdle line
    for i,l in enumerate(lines):
        if "reconcileStuckAgentStatusOnPromptIdle" in l and "(" in l:
            start = i
            break

if start is None:
    print("Could not find function.")
    raise SystemExit(1)

# brace counting from first '{' in the starting line
text = "".join(lines[start:])
# Find first '{' after start
m = re.search(r'{', lines[start])
if not m:
    print("No opening brace on start line.")
    raise SystemExit(1)
brace = 0
end = None
for j in range(start, len(lines)):
    brace += lines[j].count('{')
    brace -= lines[j].count('}')
    if j > start and brace == 0:
        end = j
        break

if end is None:
    end = min(start+250, len(lines)-1)

for k in range(start, end+1):
    print(f"{k+1:6d}: {lines[k].rstrip()}")

# simple scan for collection-walk patterns in that range
joined = "\n".join(lines[start:end+1])
patterns = [
    r'\bstatusEntries\b\s*\.\s*(values|keys)\b',
    r'\bagentPIDs\b\s*\.\s*(keys|values)\b',
    r'\bagentPIDPanelIdsByKey\b\s*\.\s*(keys|values)\b',
    r'\.\s*filter\s*{',
    r'\.\s*map\s*{',
    r'\.\s*forEach\s*{',
    r'\.\s*reduce\s*\(',
    r'\bfor\s+\w+\s+in\s+(statusEntries|agentPIDs|agentPIDPanelIdsByKey)\b',
    r'\bfor\s+\w+\s+in\s+.+(statusEntries|agentPIDs|agentPIDPanelIdsByKey).+',
]
print("\n--- MATCHES (within function range) ---")
for p in patterns:
    if re.search(p, joined):
        print(p)
PY

Repository: manaflow-ai/cmux

Length of output: 1981


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="Sources/Workspace+PanelLifecycle.swift"

python3 - <<'PY'
import re

def extract_func(lines, name):
    # locate line with func name
    pat = re.compile(r'^\s*(private\s+)?func\s+'+re.escape(name)+r'\s*\(')
    start=None
    for i,l in enumerate(lines):
        if pat.search(l):
            start=i
            break
    if start is None:
        print(f"NOT FOUND: {name}")
        return
    # find first '{' on/after start line
    brace_start = None
    for j in range(start, min(start+5, len(lines))):
        if '{' in lines[j]:
            brace_start=j
            break
    if brace_start is None:
        # fallback: first '{' after start
        txt="".join(lines[start:])
        m=re.search(r'{', txt)
        if not m:
            print(f"NO OPEN BRACE for {name}")
            return
        # compute absolute index
        # approximate by counting braces in chunk
    brace=0
    end=None
    for j in range(start, len(lines)):
        brace += lines[j].count('{')
        brace -= lines[j].count('}')
        if j>start and brace==0:
            end=j
            break
    if end is None:
        end=min(start+220, len(lines)-1)
    for k in range(start, end+1):
        print(f"{k+1:6d}: {lines[k].rstrip()}")

with open("Sources/Workspace+PanelLifecycle.swift","r",encoding="utf-8") as f:
    lines=f.readlines()

for name in [
    "hasAgentRuntime",
    "clearAgentPID",
    "clearAgentLifecycle",
    "refreshTrackedAgentPorts",
]:
    print("\n\n===== "+name+" =====")
    extract_func(lines, name)
PY

Repository: manaflow-ai/cmux

Length of output: 1093


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="Sources/Workspace+PanelLifecycle.swift"

# 1) Find the real name/location of the lifecycle clearer used by reconcileStuckAgentStatusOnPromptIdle
rg -n "clearAgentLifecycle" -S "$FILE"

# 2) Extract a larger window around clearAgentPID to capture its full body
start=$(rg -n "func clearAgentPID\(" -S "$FILE" | head -n1 | cut -d: -f1)
start=$((start-20))
end=$((start+120))
sed -n "${start},${end}p" "$FILE" | nl -ba | sed 's/^/| /'

Repository: manaflow-ai/cmux

Length of output: 340


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import re

path="Sources/Workspace+PanelLifecycle.swift"
lines=open(path,'r',encoding='utf-8').read().splitlines()

def find_func(name):
    # try common forms: func name( ... ) {  OR  private func name(
    pat=re.compile(r'^\s*(private\s+)?func\s+'+re.escape(name)+r'\s*\(')
    for i,l in enumerate(lines):
        if pat.search(l):
            return i
    return None

def extract_func(start):
    # find opening brace from start line onward
    brace=None
    for j in range(start, min(start+30,len(lines))):
        if '{' in lines[j]:
            brace=j
            break
    if brace is None:
        brace=start
    depth=0
    end=None
    for j in range(start, len(lines)):
        depth += lines[j].count('{')
        depth -= lines[j].count('}')
        if j>start and depth==0:
            end=j
            break
    if end is None: end=min(start+300,len(lines)-1)
    for k in range(start,end+1):
        print(f"{k+1:6d}: {lines[k]}")
    return end

targets=[
    "clearAgentPID",
    "clearAgentLifecycle",
    "clearAgentLifecycleStates",
]
for t in targets:
    print("\n\n===== "+t+" =====")
    idx=find_func(t)
    if idx is None:
        print("NOT FOUND:", t)
        continue
    extract_func(idx)
PY

Repository: manaflow-ai/cmux

Length of output: 291


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="Sources/Workspace+PanelLifecycle.swift"

# Find likely definitions (not just call sites)
echo "== Definitions containing 'func clearAgentPID' =="
rg -n "func\s+clearAgentPID" -S "$FILE" || true

echo "== Definitions containing 'func clearAgentLifecycle' =="
rg -n "func\s+clearAgentLifecycle" -S "$FILE" || true

echo "== Definitions containing 'clearAgentLifecycle' near 'func' =="
rg -n "func[^{\n]*clearAgentLifecycle" -S "$FILE" || true

Repository: manaflow-ai/cmux

Length of output: 257


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "clearAgentLifecycle\s*\(" -S . \
  --glob '!**/node_modules/**' --glob '!**/build/**'

rg -n "clearAgentLifecycleStates\s*\(" -S . \
  --glob '!**/node_modules/**' --glob '!**/build/**'

Repository: manaflow-ai/cmux

Length of output: 675


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import re

def extract_func(path, name, start_hint=None):
    lines=open(path,'r',encoding='utf-8').read().splitlines()
    pat=re.compile(r'^\s*(private\s+)?func\s+'+re.escape(name)+r'\s*\(')
    start=None
    if start_hint is not None:
        start=start_hint-1
    if start is None:
        for i,l in enumerate(lines):
            if pat.search(l):
                start=i
                break
    if start is None:
        print(f"NOT FOUND {name} in {path}")
        return
    # find opening brace after start line
    brace_found=False
    for j in range(start, min(start+20,len(lines))):
        if '{' in lines[j]:
            brace_found=True
            depth=0
            end=None
            for k in range(start, len(lines)):
                depth += lines[k].count('{')
                depth -= lines[k].count('}')
                if k>start and depth==0:
                    end=k
                    break
            if end is None:
                end=min(start+250,len(lines)-1)
            for k in range(start,end+1):
                print(f"{k+1:6d}: {lines[k]}")
            return
    # fallback
    print(f"NO BRACE BLOCK FOUND QUICKLY for {name} in {path}")
    for k in range(start, min(start+80,len(lines))):
        print(f"{k+1:6d}: {lines[k]}")

extract_func("Sources/Workspace.swift", "clearAgentLifecycle", start_hint=11010)
print("\n\n===== clearAgentLifecycleStates =====")
extract_func("Sources/Workspace.swift", "clearAgentLifecycleStates", start_hint=11025)

print("\n\n===== clearAgentPID (if present in Workspace.swift) =====")
# Locate by searching definitions
import subprocess, sys, os
PY

Repository: manaflow-ai/cmux

Length of output: 1189


Reconciliation is panel-scoped for mutations, but runtime-checking scans globally per orphan lifecycle key

reconcileStuckAgentStatusOnPromptIdle(panelId:) is panel-scoped for the data it mutates (it derives pidKeys from agentPIDKeysByPanelId[panelId] and orphanLifecycleKeys from agentLifecycleStatesByPanelId[panelId]; clearAgentLifecycle(key:panelId:) only removes within agentLifecycleStatesByPanelId[panelId]).
But for each orphan lifecycle key it calls hasAgentRuntime(forStatusKey:), which iterates agentPIDs.keys and agentPIDPanelIdsByKey.keys without filtering by panelId, so promptIdle handling can trigger repeated full-collection scans across all panels.

Change hasAgentRuntime to be panel-scoped (or use an indexed lookup by statusKey) to avoid global rescans on this path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Workspace.swift` around lines 10987 - 10989,
reconcileStuckAgentStatusOnPromptIdle(panelId:) is panel-scoped but calls
hasAgentRuntime(forStatusKey:) which scans global maps (agentPIDs,
agentPIDPanelIdsByKey); change hasAgentRuntime to be panel-scoped or provide a
new hasAgentRuntime(forStatusKey:panelId:) and update the call in
reconcileStuckAgentStatusOnPromptIdle to pass panelId. Specifically, add a
panelId parameter to the API (or create an indexed lookup by statusKey),
implement the function to only check entries belonging to the given panelId
(e.g., consult agentPIDPanelIdsByKey[statusKey] and verify the panelId or filter
agentPIDs by agentPIDKeysByPanelId[panelId]), and update the
reconcileStuckAgentStatusOnPromptIdle callsite and any other callers; keep
clearAgentLifecycle(key:panelId:) and the panel-scoped maps
(agentPIDKeysByPanelId, agentLifecycleStatesByPanelId) intact and run relevant
tests.

#if DEBUG
cmuxDebugLog(
"surface.shellState workspace=\(id.uuidString.prefix(5)) " +
Expand Down
76 changes: 76 additions & 0 deletions cmuxTests/AgentHibernationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down