Desktop: stale 'Running N terminals' shown after all processes are already dead
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 terminalmessage) - #8656 (background terminal lifecycle/kill UX)
What steps can reproduce the bug?
I can reproduce with this pattern:
- Start multiple background terminals from Codex desktop during active work (in my case: several
runIdeand watch/reload scripts). - Stop/kill those processes externally (or from another terminal).
- Verify no matching processes remain via
pgrep/ps. - Return to Codex desktop thread view.
- 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):
- 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.
- Cross-thread/session stale cache
- Terminal metadata appears persisted in thread state.
- UI rehydrates stale entries as "running" without a fresh process-liveness check.
- 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
- On thread/app focus, run cheap liveness reconciliation for all
runningterminal entries. - Store and validate both PID and process-group/session when available.
- Add TTL heartbeat for running terminals; auto-mark stale after timeout + failed liveness check.
- Surface explicit status:
running | finished | stale. - 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.
6 Comments
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.
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Thanks for taking a look. Adding a concrete acceptance/repro checklist to reduce ambiguity.
Minimal deterministic acceptance criteria
kill/pkill).Running.Running N terminals) is removed or updated to0.runningstate remains after app restart + thread reopen.Repro matrix (good for regression test)
SIGTERMandSIGKILLzsh -> gradle -> java)Expected in all: terminal state converges to non-running truthfully.
Suggested implementation guardrails
running.staleif event says running but liveness probe disagrees, then auto-converge to finished state.Candidate integration test shape
runningto non-running within timeout.runningentries 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.
I dug into current
main(commit03ff04cd6) and found a likely concrete mismatch in keying logic that could explain stale "running terminals" entries.Suspected mismatch
In TUI tracking:
process_id(preferred), fallbackcall_id:codex-rs/tui/src/chatwidget.rs:1979process_id(preferred), fallbackcall_id:codex-rs/tui/src/chatwidget.rs:2001In unified-exec backend responses, when a process has exited,
process_idis intentionally returned asNone:codex-rs/core/src/unified_exec/process_manager.rs:311-315codex-rs/core/src/unified_exec/process_manager.rs:393-396That means:
process_id.process_id = None, so end key becomescall_id.process_id, remove-by-call_idmisses, leaving stale running entry.Why this aligns with observed UI behavior
The footer/status text derives from
unified_exec_processesin 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_idORprocess.call_id == ev.call_idThat avoids dependence on which ID is present in end events.
Suggested regression test
A test where:
process_id.process_id = None, samecall_id.unified_exec_processesand footer clears.If maintainers want, I can open a PR with this exact patch + test.
I validated this hypothesis locally against
openai/codex(03ff04cd6) with a minimal patch incodex-rs/tui/src/chatwidget.rs:track_unified_exec_process_end, remove process entries by eitherprocess_idorcall_id.And added regression test:
chatwidget::tests::unified_exec_end_without_process_id_still_clears_running_processTest command used:
Result: pass.
If maintainers want, I can open a PR with this patch + test.
Confirmed on macOS Desktop as a stale-running-state bug.
Environment:
Codex.app26.313.41514)codex-cli 0.115.0In
/ps, the UI was showing25 background terminals running, with entries like short-livedsed -n ...,rg -n ..., andpython scripts/plan_lint.py ...commands.But an OS-level process check immediately after did not show matching
sed/rg/pythonprocesses anymore. The only live Codex-related processes I could still see were the Desktop app itself and a couple of unrelated long-livedcodex 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_idcleanup mismatch hypothesis discussed here. I also hit the companion symptom where explicit/stopdid not clear the stale entries; I am noting that separately on#12478because that seems like the idle-stop cleanup path failing on top of the stale state.