TUI /agents return can leave stale busy spinner and active-turn inhibitor after subagent completion on Linux/tmux

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

What version of Codex CLI is running?

v0.118.0

What subscription do you have?

Pro

Which model were you using?

gpt-5.4 high

What platform is your computer?

Linux x86_64

What terminal emulator and version are you using (if applicable)?

tmux

What issue are you seeing?

I hit a bad TUI/runtime state around subagent switching.

The important operator action is that I was switching between the subagent and the main agent via /agents.

After the child subagent completed, the parent/main thread ended up in a stale busy-looking state instead of cleanly recovering.

From the user side this looked like the app hung or crashed while leaving the screen in a state that still suggested work was ongoing.

What I observed locally on the affected tmux pane:

  • the pane content still contained repeated Waited for background terminal lines from the earlier work
  • the final assistant response from the parent thread was already printed
  • the normal prompt was visible again at the bottom
  • but the tmux pane title remained stuck at ⠧ home (busy spinner title) and did not clear
  • the title stayed exactly ⠧ home across repeated checks over multiple seconds, instead of returning to a non-busy title
  • the Codex process remained alive
  • a stale child process was still present:
  • systemd-inhibit --what=idle --mode=block --who codex --why "Codex is running an active turn" -- sleep 2147483647
  • that inhibitor was still alive more than an hour after the turn had already completed

So even if the backend believes the turn finished, the TUI/runtime busy-state cleanup did not happen correctly.

This is not just "the turn completed and I did not want it to" (although from the operator side it did feel wrong). The concrete bug is that the CLI was left in a contradictory state:

  • visible prompt is back
  • parent turn has completed
  • but busy indicators and the active-turn idle inhibitor are still stuck as if a turn were running

What steps can reproduce the bug?

I do not yet have a minimal deterministic repro, but the sequence that triggered it was:

  1. Start Codex CLI in tmux.
  2. Use a workflow that spawns/uses a subagent.
  3. Switch between the child and main thread via /agents.
  4. Switch back to the main thread and wait for the child subagent to complete.
  5. Observe that after completion, the main thread can be left in a stale state:
  • the screen still looks like it was working recently (Waited for background terminal still visible)
  • the tmux pane title remains stuck on a busy spinner
  • an active turn systemd-inhibit child remains alive
  • the process is alive, but the session no longer feels cleanly interactive from the user perspective

Affected top-level thread ID:

  • 019d61b9-5b70-78e2-9a11-56ebcfd12bba

Child subagent completion was delivered into that parent thread via <subagent_notification>.

What is the expected behavior?

After subagent completion and return to the main thread, Codex should do one of these consistently:

  • keep the parent turn active and visibly keep waiting, or
  • complete the parent turn and fully clear all busy state

It should not leave the session in a contradictory half-finished state where:

  • the prompt is back,
  • the backend turn is complete,
  • but the busy spinner/title and the Codex is running an active turn inhibitor are still stuck.

Additional information

Concrete local evidence from this incident:

  • parent thread rollout recorded task_complete at 2026-04-06T10:55:05.491Z
  • affected tmux pane title remained:
  • ⠧ home
  • repeated checks over ~6 seconds showed the title was still unchanged:
  • t0: ⠧ home
  • t+3s: ⠧ home
  • t+6s: ⠧ home
  • the stale inhibitor process remained present long after completion:
systemd-inhibit --what=idle --mode=block --who codex --why Codex is running an active turn -- sleep 2147483647

Related but not identical issues:

  • #11472 (sub-agent shutdown / parent restoration)
  • #12033 / #14303 / #14314 (stale Waiting/Waited for background terminal state)
  • #12645 (tmux TUI/input stuck after completion)

My current guess is that this is another turn-lifecycle cleanup bug in the TUI/runtime layer, possibly triggered specifically by the /agents thread-switch path around subagent completion boundaries.

View original on GitHub ↗

6 Comments

github-actions[bot] contributor · 3 months ago

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

  • #16904

Powered by Codex Action

iqdoctor · 3 months ago

I dug further into the upstream TUI code and there is now a concrete code-level hypothesis for why /agents can resurrect a false running state after switching back.

Relevant paths:

  • codex-rs/tui/src/app.rs
  • store_active_thread_receiver() saves store.input_state = self.chat_widget.capture_thread_input_state() when leaving the active thread.
  • ThreadEventStore::push_notification() clears active_turn_id on TurnCompleted, but it does not sanitize the already-saved input_state.
  • codex-rs/tui/src/chatwidget.rs
  • capture_thread_input_state() persists both:
  • task_running: self.bottom_pane.is_task_running()
  • agent_turn_running: self.agent_turn_running
  • restore_thread_input_state() later restores self.agent_turn_running = input_state.agent_turn_running and immediately calls:
  • self.turn_sleep_inhibitor.set_turn_running(self.agent_turn_running)
  • self.update_task_running_state()

So the race/bug pattern looks like this:

  1. Main thread is running.
  2. User switches away via /agents.
  3. The TUI stores input_state with agent_turn_running=true / task_running=true.
  4. While that thread is inactive, a TurnCompleted notification arrives.
  5. ThreadEventStore::push_notification() clears only active_turn_id, but the stale saved input_state still says the thread is running.
  6. When switching back to that thread, replay_thread_snapshot() restores the stale input_state first.
  7. That immediately re-enables the busy UI state and the Linux sleep inhibitor before replay finishes.

This would explain the exact symptom cluster I saw:

  • prompt/composer is back
  • but busy/title state is still resurrected
  • and Linux still has
  • systemd-inhibit --why "Codex is running an active turn"

Even if replay later can clear the state when it sees a matching completion event, this code still allows /agents to restore a ghost-running state from stale input_state. If the correcting replay event is missing/missed/reordered, the false busy state can persist indefinitely.

So I suspect the fix is in one of these directions:

  • do not persist raw agent_turn_running / task_running across thread switches as authoritative state,
  • or sanitize stored input_state whenever an inactive thread receives TurnCompleted / ThreadClosed,
  • or derive restored running state from canonical turn lifecycle (active_turn_id / replayed turns) instead of the stale saved widget state.

I do not have a minimal deterministic repro yet, but this looks like a real logic hole rather than just user confusion.

iqdoctor · 3 months ago

I did a deeper source review against a local checkout under /home/tools/codex.

Version note first: the local checkout is not literally the packaged v0.118.0 source tree.

  • current local HEAD: c1d18ceb6f22ae3acd67bbd6badad0f475b31dfc
  • current workspace version in codex-rs/Cargo.toml: 0.0.0
  • nearest 0.118.x release-tagged tree available locally: rust-v0.118.0-alpha.3 (a5ea426b9a867ec64a41fa2d956bae552036cd4d)
  • current HEAD is 116 commits ahead of that alpha tag

That said, the relevant logic hole exists in both the current tree and the rust-v0.118.0-alpha.3 tree, so this does not look like a main-only regression.

Findings

HIGH: thread-switch snapshots persist running state as authoritative and re-arm the sleep inhibitor on restore

Current main:

  • codex-rs/tui/src/chatwidget.rs:3136-3206
  • capture_thread_input_state() persists:
  • task_running: self.bottom_pane.is_task_running()
  • agent_turn_running: self.agent_turn_running
  • restore_thread_input_state() then restores self.agent_turn_running = input_state.agent_turn_running and immediately does:
  • self.turn_sleep_inhibitor.set_turn_running(self.agent_turn_running)
  • self.update_task_running_state()

This means a stale snapshot from /agents thread switching can directly resurrect both the busy UI and Linux systemd-inhibit state before canonical replay finishes.

I confirmed the same capture/restore pattern already exists in rust-v0.118.0-alpha.3 as well.

HIGH: inactive-thread completion clears only active_turn_id, not the saved input_state

Current main:

  • codex-rs/tui/src/app.rs:577-588
  • ThreadEventStore::push_notification() clears active_turn_id on TurnCompleted, but does not invalidate or sanitize store.input_state
  • the saved input state itself is written in:
  • codex-rs/tui/src/app.rs:1569-1578

So if the user switches away while the main thread is running, the TUI saves agent_turn_running=true / task_running=true. If TurnCompleted arrives while that thread is inactive, the store only clears the cached turn id, but the saved running flags remain stale. On restore, those stale flags can still win.

MEDIUM: replay order widens the race window by restoring snapshot state before draining queued live events

Current main:

  • snapshot capture path: codex-rs/tui/src/app.rs:1585-1593
  • switch-back path: codex-rs/tui/src/app.rs:3141-3159

The thread switch path restores the snapshot first:

  • self.replay_thread_snapshot(snapshot, !is_replay_only);

and only after that drains the live receiver queue:

  • self.drain_active_thread_events(tui).await?;

So even if a TurnCompleted event is already queued on the receiver at switch time, stale input_state is restored first. If that later completion is missed, delayed, or reordered, the false running state can persist.

MEDIUM: the current tests encode the problematic behavior, but I did not find a regression test for "thread completed while inactive"

Current main:

  • codex-rs/tui/src/chatwidget/tests/composer_submission.rs:789-813

There is an explicit test asserting that restore_thread_input_state() should re-enable the sleep inhibitor when the snapshot says agent_turn_running=true.

That makes the behavior intentional at the unit-test level, but I did not find a corresponding regression test that covers:

  • switch away via /agents
  • thread completes while inactive
  • switch back
  • busy state and inhibitor must stay cleared

Bottom line

After reviewing the source, I think the issue is real and the logic hole is likely in the TUI state model itself:

  • running state is persisted in snapshot state,
  • inactive completion does not sanitize that snapshot state,
  • and thread restore applies snapshot running flags before live completion events are fully reconciled.

That seems fully compatible with the user-visible symptom I reported:

  • prompt is back
  • turn is complete
  • but busy/title/inhibitor still behave as though a turn is active

If helpful, the next thing I can do is reduce this into a deterministic integration test around thread switching and completion ordering.

iqdoctor · 3 months ago

Version correction after local audit:

  • The installed binary on this machine is /usr/bin/codex -> /usr/lib/node_modules/@openai/codex/bin/codex.js, and both codex --version and the installed package metadata report @openai/codex 0.118.0.
  • So the ticket version (0.118.0) is correct for the actual runtime artifact involved in the bug.
  • My earlier note about /home/tools/codex not matching v0.118.0 was about the local source checkout used for code review: that checkout does not currently have the exact 0.118.0 release tag fetched locally, and the nearest local 0.118.x tag visible there is rust-v0.118.0-alpha.3.
  • In other words: runtime artifact = 0.118.0; local source checkout tag availability = incomplete/misaligned.

This does not change the bug diagnosis, but it does matter for version attribution, so I wanted to correct the record.

ybratec · 3 months ago

This looks like a duplicate of #16904.

I have already copied the code-level hypothesis and the latest 0.119.0-alpha.11 source-level check into #16904, so I am treating #16904 as the canonical thread going forward unless there is a reason to keep both open.

Relevant follow-ups now live there.

iqdoctor · 3 months ago

Closing as duplicate of #16904. I copied the substantive technical follow-up there, including the source-level hypothesis and the latest 0.119.0-alpha.11 check.