Repository navigation
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
@orlenko is attempting to deploy a commit to the Manaflow Team on Vercel. A member of the Team first needs to authorize it. |
📝 WalkthroughWalkthroughThis PR adds a reconciliation hook that clears stuck agent status entries when the shell prompt returns. The implementation detects dead processes by PID, clears their associated status/lifecycle keys, and clears orphaned entries without owning PIDs. The hook is integrated into shell state transitions on ChangesStuck Agent Status Reconciliation on Prompt Idle
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
Caution Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional.
❌ Failed checks (1 error, 1 warning)
✅ Passed checks (16 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Sources/Workspace.swift`:
- Around line 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.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: f6c431f4-1681-4f56-b691-805eb0406ac1
📒 Files selected for processing (3)
Sources/Workspace+PanelLifecycle.swiftSources/Workspace.swiftcmuxTests/AgentHibernationTests.swift
| if state == .promptIdle { | ||
| reconcileStuckAgentStatusOnPromptIdle(panelId: panelId) | ||
| } |
There was a problem hiding this comment.
🧩 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)
PYRepository: 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)
PYRepository: 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)
PYRepository: 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" || trueRepository: 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
PYRepository: 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.
Greptile SummaryThis PR adds an app-side safety net to clear stuck "Running" agent-status badges when the shell returns to its prompt. Because structured agents (claude_code, codex, …) are foreground processes,
Confidence Score: 4/5Safe to merge; the reconciliation logic is well-guarded by PID liveness checks and correctly scoped to the panel that reported promptIdle. The new reconcileStuckAgentStatusOnPromptIdle correctly handles the dead-PID case, the no-PID orphan fallback, and the live-agent guard. The only finding is a minor actor-isolation style nit on a private static helper with no runtime impact. Sources/Workspace+PanelLifecycle.swift — specifically the isAgentProcessAlive helper. Important Files Changed
Sequence DiagramsequenceDiagram
participant Shell as zsh precmd
participant App as Workspace
participant Hooks as Hook System
Note over Hooks,App: Normal path (hooks fire cleanly)
Hooks->>App: set_agent_lifecycle running
Hooks->>App: set_agent_pid claude_code.session pid
Hooks->>App: set_status claude_code Running
Hooks->>App: Stop or SessionEnd clears status
Note over Shell,App: Abnormal exit path (this PR)
Hooks--xApp: SessionEnd skipped due to SIGKILL / timeout / missing session_id
Shell->>App: promptIdle fires after foreground process exits
App->>App: reconcileStuckAgentStatusOnPromptIdle
App->>App: kill(pid, 0) returns ESRCH — process dead
App->>App: clearAgentPID + clearAgentLifecycle + remove statusEntry
App->>App: refreshTrackedAgentPorts
Note over App: Badge cleared
Note over Shell,App: Live agent guard
Shell->>App: promptIdle
App->>App: kill(pid, 0) returns 0 — process alive
Note over App: Badge preserved
Reviews (1): Last reviewed commit: "Clear stuck agent "Running" badge when t..." | Re-trigger Greptile |
| return didChange | ||
| } | ||
|
|
||
| private static func isAgentProcessAlive(_ pid: pid_t) -> Bool { |
There was a problem hiding this comment.
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.
| 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!
|
Closing as superseded. Current main already clears the stuck agent state when the shell returns to its prompt: #7824 added The only logic this PR would still add is clearing structured-agent entries that never recorded a PID, on Bot review items (panel-scoped 🤖 Generated with Claude Code |
Problem
The sidebar shows a stuck ⚡ Running badge on a workspace whose agent is no longer running — the terminal sits idle at the shell prompt, but the pill never clears.
![stale Running badge: workspace shows ⚡ Running while the terminal is idle at the shell prompt]
Root cause
The structured-agent status pill (
claude_code"Running", and the matching hibernation lifecycle) is entirely hook-driven. The bundled agent wrapper installs hooks that send the app:It is only ever cleared by:
Stop(→ idle) orSessionEnd(→clear_agent_pid … --clear-status), orThere is no app-side liveness check. So any abnormal agent exit that skips a clean
SessionEndleaves the badge stuck on "Running" until the tab is closed or the app restarts:kill -9/ crash (no hook fires at all)SessionEndhook's 1-second timeout elapsesSessionEndwith nosession_id(the CLI can't resolve the record to clear)AgentHibernationControllerpolls every 30s but only hibernates idle agents — it never retires a stale status.Fix
Add the app-side safety net the hooks lack. Structured agents run as the shell's foreground process, so cmux's shell integration only reports
promptIdleafter the agent process exits (zshprecmd,cmux-zsh-integration.zsh). That is a reliable "the foreground agent is gone" signal — it cannot fire mid-session.On a
promptIdletransition,Workspace.reconcileStuckAgentStatusOnPromptIdle(panelId:)reconciles the panel's structured-agent status. It is PID-liveness gated:kill(pid, 0));hasAgentRuntimeguard).This only runs on command-boundary transitions (not the typing hot path), and
kill(pid, 0)is cheap.Tests
Behavior-level tests in
cmuxTests/AgentHibernationTests.swift(drive a realWorkspacethroughupdatePanelShellActivityState(.promptIdle)):testPromptIdleClearsStuckRunningStatusWhenAgentProcessDead— the core repro (reaps/usr/bin/truefor a guaranteed-dead PID).testPromptIdleClearsOrphanedRunningStatusWithoutTrackedPID— the no-PID fallback.testPromptIdleKeepsRunningStatusWhileAgentProcessAlive— guards against over-clearing a live agent.Per the repo's two-commit regression policy, the tests land first (red) and the fix second (green):
Add failing tests for stuck agent "Running" badge on shell prompt returnClear stuck agent "Running" badge when the shell returns to its prompt🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmithwith what you need. Autofix is disabled.Summary by cubic
Fixes the stuck "⚡ Running" badge by clearing it when the terminal returns to the shell prompt. Uses a PID liveness check to only clear crashed or terminated agents, not live ones.
promptIdle, callWorkspace.reconcileStuckAgentStatusOnPromptIdle(panelId:)to reconcile structured-agent state.kill(pid, 0)), or if no PID was recorded.Written for commit 4b98425. Summary will update on new commits.
Summary by CodeRabbit