Skip to content

Clear stuck agent "Running" badge when the shell returns to its prompt - #5129

Closed
orlenko wants to merge 2 commits into
manaflow-ai:mainfrom
orlenko:fix-stale-running-agent-badge
Closed

orlenko wants to merge 2 commits into
manaflow-ai:mainfrom
orlenko:fix-stale-running-agent-badge

Conversation

@orlenko

@orlenko orlenko commented Jun 1, 2026 •

Copy link
Copy Markdown

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:

set_status claude_code Running --icon=bolt.fill --color=#4C8DFF …
set_agent_lifecycle claude_code running …
set_agent_pid claude_code.<sessionId> <pid> …

It is only ever cleared by:

  1. a hook event — Stop (→ idle) or SessionEnd (→ clear_agent_pid … --clear-status), or
  2. the tab/panel closing.

There is no app-side liveness check. So any abnormal agent exit that skips a clean SessionEnd leaves the badge stuck on "Running" until the tab is closed or the app restarts:

  • kill -9 / crash (no hook fires at all)
  • the SessionEnd hook's 1-second timeout elapses
  • a SessionEnd with no session_id (the CLI can't resolve the record to clear)
  • nested-subagent visible-mutation suppression

AgentHibernationController polls 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 promptIdle after the agent process exits (zsh precmd, cmux-zsh-integration.zsh). That is a reliable "the foreground agent is gone" signal — it cannot fire mid-session.

On a promptIdle transition, Workspace.reconcileStuckAgentStatusOnPromptIdle(panelId:) reconciles the panel's structured-agent status. It is PID-liveness gated:

  • clear the status / lifecycle / tracked PID only when the tracked agent PID is dead (kill(pid, 0));
  • fall back to clearing entries that never recorded a PID to probe;
  • leave untouched a genuinely live agent, and a status key shared with a still-running agent on another panel (hasAgentRuntime guard).

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 real Workspace through updatePanelShellActivityState(.promptIdle)):

  • testPromptIdleClearsStuckRunningStatusWhenAgentProcessDead — the core repro (reaps /usr/bin/true for 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):

  1. Add failing tests for stuck agent "Running" badge on shell prompt return
  2. Clear stuck agent "Running" badge when the shell returns to its prompt

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag @codesmith with 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.

  • Bug Fixes
    • On promptIdle, call Workspace.reconcileStuckAgentStatusOnPromptIdle(panelId:) to reconcile structured-agent state.
    • Clear status/lifecycle and tracked PID only if the recorded PID is dead (kill(pid, 0)), or if no PID was recorded.
    • Leave live agents and statuses shared with another panel intact; refresh tracked ports after changes.
    • Added behavior tests for dead PID, missing PID, and live PID cases.

Written for commit 4b98425. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Fixed issue where agent status could remain stuck on "Running" after abnormal termination. The sidebar now properly clears stale status entries and refreshes agent information when the shell prompt returns.

orlenko and others added 2 commits June 1, 2026 14:42
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>
@vercel

vercel Bot commented Jun 1, 2026

Copy link
Copy Markdown

@orlenko is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 1, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This 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 .promptIdle and validated with three test cases covering dead, orphaned, and live process scenarios.

Changes

Stuck Agent Status Reconciliation on Prompt Idle

Layer / File(s) Summary
Core reconciliation logic and process-liveness detection
Sources/Workspace+PanelLifecycle.swift
reconcileStuckAgentStatusOnPromptIdle(panelId:) identifies structured status keys tied to panel-recorded PIDs, clears entries whose processes are dead (via isAgentProcessAlive(_:) using kill(pid, 0)), clears orphan entries without PID keys, and refreshes tracked agent ports on any change.
Prompt-idle integration into shell state lifecycle
Sources/Workspace.swift
updatePanelShellActivityState calls the reconciliation method when the new shell state is .promptIdle, after resume-state bookkeeping.
Test helper and reconciliation validation
cmuxTests/AgentHibernationTests.swift
Introduces deadProcessPID() helper and three test cases: clearing "Running" status when a tracked agent PID is dead, clearing orphaned "Running" status without a tracked PID, and preserving status/lifecycle/PID mapping when the agent process is alive.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • manaflow-ai/cmux#4165: Directly extends the agent lifecycle/hibernation tracking to clear stuck per-agent "Running/unknown" state on prompt idle.

Poem

🐰 A prompt returns, but status lingers on,

Running, running—the process is gone.

With a gentle kill and a PID to check,

We clean up the orphans, set lifecycle straight.

Now hibernation sleeps without regret! ✨


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Cmux Algorithmic Complexity ❌ Error Loop in reconcileStuckAgentStatusOnPromptIdle calls hasAgentRuntime per orphan, scanning global agentPIDs/agentPIDPanelIdsByKey dicts. O(orphans*G) complexity lacks benchmark at 1000-workspace scale. Refactor hasAgentRuntime to be panel-scoped or use indexed lookup.
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (16 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: clearing a stuck agent Running badge when the shell returns to prompt.
Description check ✅ Passed The description covers problem statement, root cause analysis, solution approach, and tests, but lacks demo video and incomplete checklist.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed New methods inherit @MainActor from Workspace class; static helper isAgentProcessAlive is appropriately nonisolated; all tests properly @MainActor-annotated; no async/background violations.
Cmux Swift Blocking Runtime ✅ Passed Production code introduces no blocking patterns. New methods use synchronous logic and kill(pid, 0) non-blocking syscall. Test-only Process.waitUntilExit() is deterministic scaffolding as permitted.
Cmux No Hacky Sleeps ✅ Passed PR contains only Swift code; check applies to TypeScript/JavaScript/shell/non-Swift build scripts. Swift timing covered by separate swift-blocking-runtime.md rule.
Cmux Swift Concurrency ✅ Passed PR introduces no legacy async patterns: new synchronous functions (reconcileStuckAgentStatusOnPromptIdle, isAgentProcessAlive), proper @MainActor tests using waitUntilExit (not fire-and-forget).
Cmux Swift @Concurrent ✅ Passed All new Swift methods are synchronous and properly respect actor isolation: both new methods are MainActor-isolated via Workspace class, and kill(pid,0) is not CPU/IO/network-heavy.
Cmux Swift File And Package Boundaries ✅ Passed Pre-existing extension file (421 lines), only 3 lines added to Workspace.swift despite it being 24% over budget. No mixed responsibilities, appropriate app-target placement for lifecycle logic.
Cmux Swift Logging ✅ Passed No logging violations detected. New production methods contain no print/NSLog/debugPrint/dump. Existing DEBUG-guarded cmuxDebugLog is allowed. Tests contain no logging.
Cmux User-Facing Error Privacy ✅ Passed PR introduces internal logic and tests without user-facing error messages, alerts, or output strings that could violate the policy.
Cmux Full Internationalization ✅ Passed Changes add internal agent status reconciliation logic with no user-facing Swift text, String(localized:), NSLocalizedString calls, or Info.plist/catalog changes.
Cmux Swiftui State Layout ✅ Passed No SwiftUI state violations found. PR adds model/controller code for agent lifecycle reconciliation with no new @Published/@Observable/@State, GeometryReader, lazy-list refs, or render-time mutations.
Cmux Architecture Rethink ✅ Passed Synchronous kill(pid,0) process check, no async/timing/lock/observer patterns, no new shared owners. Single entry point. Complies with rethink rules.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds agent status reconciliation logic only; no new NSWindow, NSPanel, NSWindowController, SwiftUI Window/WindowGroup, or window identifier assignments are introduced.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 264455e and 4b98425.

📒 Files selected for processing (3)
  • Sources/Workspace+PanelLifecycle.swift
  • Sources/Workspace.swift
  • cmuxTests/AgentHibernationTests.swift

Comment thread Sources/Workspace.swift
Comment on lines +10987 to +10989
if state == .promptIdle {
reconcileStuckAgentStatusOnPromptIdle(panelId: panelId)
}

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.

@greptile-apps

greptile-apps Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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, promptIdle (zsh precmd) is a reliable signal that the agent has exited; the new reconcileStuckAgentStatusOnPromptIdle(panelId:) uses kill(pid, 0) liveness gating to retire stale status/lifecycle/PID entries only when the tracked process is provably dead (or when no PID was ever recorded).

  • Core fix (Workspace+PanelLifecycle.swift): two loops — one over PID-backed keys that skips any still-alive process via kill(pid, 0) with EPERM handled conservatively, and one over orphan lifecycle keys (no recorded PID) that relies on promptIdle alone, guarded by hasAgentRuntime before touching the global status entry.
  • Integration (Workspace.swift): three-line hook into the existing updatePanelShellActivityState path, after the restored-agent resume update.
  • Tests (AgentHibernationTests.swift): three @MainActor behavior tests using a reaped /usr/bin/true for a guaranteed-dead PID, covering the dead-PID repro, the no-PID orphan path, and the live-agent guard.

Confidence Score: 4/5

Safe 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

Filename Overview
Sources/Workspace+PanelLifecycle.swift Adds reconcileStuckAgentStatusOnPromptIdle(panelId:) and isAgentProcessAlive(_:) — the core of the fix. The PID-liveness loop and orphan-lifecycle path are logically sound; one nit: isAgentProcessAlive is a pure POSIX helper that inherits @MainActor isolation unnecessarily.
Sources/Workspace.swift Single three-line addition hooking reconcileStuckAgentStatusOnPromptIdle into updatePanelShellActivityState when state == .promptIdle. Correct placement after the restored-agent resume update and before the debug log.
cmuxTests/AgentHibernationTests.swift Three new @MainActor behavior tests covering the dead-PID repro, the no-PID orphan path, and the live-agent guard. Uses a reaped /usr/bin/true for a guaranteed-dead PID — a solid and deterministic approach.

Sequence Diagram

sequenceDiagram
    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
Loading

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 {

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!

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No issues found across 3 files

Re-trigger cubic

@orlenko

orlenko commented Sep 15, 2026

Copy link
Copy Markdown
Author

Closing as superseded. Current main already clears the stuck agent state when the shell returns to its prompt: #7824 added clearStaleAgentPIDs(panelId:) on promptIdle in Workspace.updatePanelShellActivityState, and it checks the recorded process start-time identity, which is stricter than the kill(pid, 0) probe here.

The only logic this PR would still add is clearing structured-agent entries that never recorded a PID, on promptIdle alone. Main now treats a prompt mark as insufficient proof of exit (#12084: Pi-compatible TUIs emit OSC 133 marks while the agent keeps running), and those orphan entries have no session identity to check liveness against. So that path would risk clearing a live agent's badge.

Bot review items (panel-scoped hasAgentRuntime, nonisolated on isAgentProcessAlive) apply only to code that is not being kept.

🤖 Generated with Claude Code

@orlenko orlenko closed this Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant