Desktop: stale 'Running N terminals' shown after all processes are already dead

Open 💬 6 comments Opened Feb 20, 2026 by koke1997
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What version of Codex CLI/Desktop is running?

Codex Desktop app (backed by Codex CLI tooling in sessions). I observed this on 2026-02-20.

What subscription do you have?

N/A for this bug report (UI/session tracking behavior).

Which model were you using?

GPT-5.3 Codex in the desktop app.

What platform is your computer?

  • macOS 26.3 (Build 25D125)
  • Darwin Kernel: 25.3.0
  • arm64

What issue are you seeing?

The Desktop UI shows a stale banner/panel like "Running 8 terminals" even after those terminal processes have already been terminated at OS level.

Concretely, the panel keeps listing previously launched commands (for example ./gradlew runIde --no-daemon ... and ./scripts/hot-reload-local-ide.sh --watch ...) as if still active, but process inspection confirms they are gone.

This appears to be a state reconciliation bug between the UI terminal tracker and real process lifecycle.

Related issues (similar symptom class):

  • #12033 (stale Waiting for background terminal message)
  • #8656 (background terminal lifecycle/kill UX)

What steps can reproduce the bug?

I can reproduce with this pattern:

  1. Start multiple background terminals from Codex desktop during active work (in my case: several runIde and watch/reload scripts).
  2. Stop/kill those processes externally (or from another terminal).
  3. Verify no matching processes remain via pgrep/ps.
  4. Return to Codex desktop thread view.
  5. Observe panel still showing stale "Running N terminals" with old command list.

What is the expected behavior?

When no tracked process is alive, the UI should promptly transition to a non-running state and clear/deactivate stale entries.

Additional information

Evidence snapshot

After cleanup, I ran:

pgrep -af "[g]radle-wrapper.jar [r]unIde|[g]radlew [r]unIde|[h]ot-reload-local-ide.sh --watch|[i]dea.plugin.in.sandbox.mode=true|[i]ntellij-ansible-plugin/build/idea-sandbox"
ps -Ao pid=,ppid=,stat=,etime=,command= | rg "[h]ot-reload-local-ide|[r]unIde --no-daemon|[i]dea.plugin.in.sandbox.mode=true|[g]radle-wrapper.jar [r]unIde|[i]ntellij-ansible-plugin/build/idea-sandbox"

Both returned no matching running processes.

Yet the desktop UI still displayed "Running 8 terminals" with those same commands.

---

Root-cause hypothesis (inference)

I cannot see internals, but behavior strongly suggests one (or both):

  1. Missing terminal-death reconciliation path
  • UI list is append/update driven by spawn/start events.
  • External termination (or detached process death) may not emit a terminal-finished event consumed by the UI store.
  1. Cross-thread/session stale cache
  • Terminal metadata appears persisted in thread state.
  • UI rehydrates stale entries as "running" without a fresh process-liveness check.
  1. Tracker identity mismatch
  • If a command forks/wraps (zsh -> gradlew -> java) and the tracked PID is parent-only, child/parent lifecycle edges could desync status updates.

---

Sequence diagram (current observed behavior)

sequenceDiagram
    participant U as User
    participant UI as Desktop UI Tracker
    participant BE as Terminal Session Manager
    participant OS as OS Process Table

    U->>UI: Start background terminals (N tasks)
    UI->>BE: spawn task records
    BE->>OS: launch processes
    OS-->>BE: running
    BE-->>UI: mark running

    U->>OS: Kill processes externally
    OS-->>BE: process exits
    Note over UI,BE: Expected: terminal-exit event + store update

    alt Reconciliation path fails
        UI-->>U: Still shows "Running N terminals"
    else Reconciliation works
        UI-->>U: Shows no running terminals
    end

Desired lifecycle state model

stateDiagram-v2
    [*] --> Spawned
    Spawned --> Running
    Running --> Exited: process exit event
    Running --> KilledExternally: SIGTERM/SIGKILL outside app
    KilledExternally --> Reconciled: liveness poll detects missing PID/PGID
    Exited --> Reconciled
    Reconciled --> [*]

    note right of Reconciled
      UI removes from "Running terminals"
      and keeps optional historical "Finished" entry
    end note

Concrete hardening ideas

  1. On thread/app focus, run cheap liveness reconciliation for all running terminal entries.
  2. Store and validate both PID and process-group/session when available.
  3. Add TTL heartbeat for running terminals; auto-mark stale after timeout + failed liveness check.
  4. Surface explicit status: running | finished | stale.
  5. Add a deterministic integration test: spawn -> external kill -> assert UI list clears.

If useful, I can test a candidate build and confirm whether this exact stale-state case is resolved.

View original on GitHub ↗

6 Comments

koke1997 · 5 months ago

Related tracking context: this looks adjacent to #12033 (stale background-terminal waiting state) and #8656 (background terminal lifecycle/kill UX), but this report is specifically about stale "Running N terminals" in Desktop after OS-level process termination.

github-actions[bot] contributor · 5 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • ##12033

Powered by Codex Action

koke1997 · 5 months ago

Thanks for taking a look. Adding a concrete acceptance/repro checklist to reduce ambiguity.

Minimal deterministic acceptance criteria

  1. Start N background terminals from Desktop thread (at least 2).
  2. Kill all corresponding OS processes externally (kill/pkill).
  3. Within bounded time (e.g. <= 2s), Desktop UI no longer shows them as Running.
  4. Thread summary/pill (Running N terminals) is removed or updated to 0.
  5. No stale running state remains after app restart + thread reopen.

Repro matrix (good for regression test)

  • Kill mode: SIGTERM and SIGKILL
  • Launch style:
  • direct long process
  • wrapper/fork chain (zsh -> gradle -> java)
  • Exit origin:
  • natural process completion
  • external kill
  • parent wrapper exit while child keeps running

Expected in all: terminal state converges to non-running truthfully.

Suggested implementation guardrails

  • Reconcile on both event stream and periodic/lifecycle liveness checks.
  • On app focus / thread open / resume, run one-shot liveness reconciliation for entries currently marked running.
  • Track enough identity to avoid parent/child PID confusion (PID + PGID/session where available).
  • Mark entries stale if event says running but liveness probe disagrees, then auto-converge to finished state.

Candidate integration test shape

  • Spawn background terminal task in test harness.
  • Capture tracked process metadata.
  • Kill process externally from test (not via internal stop path).
  • Assert UI/store transitions from running to non-running within timeout.
  • Reopen thread model and assert no stale running entries remain.

Why this matters

Current UX can imply dangerous false state (user thinks tasks still active when they are not), and it blocks trust in stop/reload workflows during heavy iterative development.

If helpful, I can help validate a candidate fix build against this matrix.

koke1997 · 5 months ago

I dug into current main (commit 03ff04cd6) and found a likely concrete mismatch in keying logic that could explain stale "running terminals" entries.

Suspected mismatch

In TUI tracking:

  • Begin path keys process by process_id (preferred), fallback call_id:
  • codex-rs/tui/src/chatwidget.rs:1979
  • End path removes by process_id (preferred), fallback call_id:
  • codex-rs/tui/src/chatwidget.rs:2001

In unified-exec backend responses, when a process has exited, process_id is intentionally returned as None:

  • codex-rs/core/src/unified_exec/process_manager.rs:311-315
  • codex-rs/core/src/unified_exec/process_manager.rs:393-396

That means:

  1. Begin may store key as process_id.
  2. End (for exited process) may emit process_id = None, so end key becomes call_id.
  3. If stored key is process_id, remove-by-call_id misses, leaving stale running entry.

Why this aligns with observed UI behavior

The footer/status text derives from unified_exec_processes in chatwidget. If retain/remove misses, stale entries continue to render as running.

Candidate low-risk fix

In track_unified_exec_process_end, remove by either identifier:

  • process.key == ev.process_id OR process.call_id == ev.call_id

That avoids dependence on which ID is present in end events.

Suggested regression test

A test where:

  1. Begin is tracked with non-empty process_id.
  2. End arrives with process_id = None, same call_id.
  3. Assert entry is removed from unified_exec_processes and footer clears.

If maintainers want, I can open a PR with this exact patch + test.

koke1997 · 5 months ago

I validated this hypothesis locally against openai/codex (03ff04cd6) with a minimal patch in codex-rs/tui/src/chatwidget.rs:

  • In track_unified_exec_process_end, remove process entries by either process_id or call_id.

And added regression test:

  • chatwidget::tests::unified_exec_end_without_process_id_still_clears_running_process

Test command used:

cd codex-rs
cargo test -p codex-tui unified_exec_end_without_process_id_still_clears_running_process -- --nocapture

Result: pass.

If maintainers want, I can open a PR with this patch + test.

palvaleri · 4 months ago

Confirmed on macOS Desktop as a stale-running-state bug.

Environment:

  • Codex Desktop app on macOS (Codex.app 26.313.41514)
  • subagent/background-terminal heavy workflow
  • local sessions on codex-cli 0.115.0

In /ps, the UI was showing 25 background terminals running, with entries like short-lived sed -n ..., rg -n ..., and python scripts/plan_lint.py ... commands.

But an OS-level process check immediately after did not show matching sed / rg / python processes anymore. The only live Codex-related processes I could still see were the Desktop app itself and a couple of unrelated long-lived codex resume ... sessions.

So in this case the UI was not reflecting real live shell processes anymore; it was holding onto stale background-terminal entries after the commands had already exited.

This seems to fit the same family as the process_id / call_id cleanup mismatch hypothesis discussed here. I also hit the companion symptom where explicit /stop did not clear the stale entries; I am noting that separately on #12478 because that seems like the idle-stop cleanup path failing on top of the stale state.