Bound v1 close_agent shutdown waits for unresponsive child agents

Open 💬 4 comments Opened Jun 25, 2026 by alygg77
💡 Likely answer: A maintainer (guidedways, contributor) responded on this thread — see the highlighted reply below.

Problem

multi_agent_v1.close_agent can block the parent turn indefinitely when a child agent accepts Op::Shutdown but its session loop never reaches termination.

This makes cleanup unsafe to put on the critical path: a parent that is otherwise done can become stuck for hours just trying to close an unresponsive child.

Related existing issues:

  • #25870
  • #24389

Fresh reproduction evidence

Observed in a local native multi-agent Codex session on 2026-06-24.

Environment:

  • parent session: 019ef523-5927-74e2-b687-369f17ae6bbc
  • child session: 019ef7e2-c130-72d1-9b3e-280ff9c36e90
  • child role/nickname: native explorer, Hilbert
  • platform: macOS arm64
  • parent CLI around run: 0.138.0, child CLI around run: 0.141.0, current local CLI after update: 0.142.0

Timeline from local rollout JSONL:

2026-06-24T04:28:15Z spawn_agent -> Hilbert 019ef7e2-c130-72d1-9b3e-280ff9c36e90
2026-06-24T04:48:42Z wait_agent timeout_ms=1000  -> {"status":{},"timed_out":true}
2026-06-24T04:50:29Z wait_agent timeout_ms=10000 -> {"status":{},"timed_out":true}
2026-06-24T04:50:46Z wait_agent timeout_ms=60000 -> {"status":{},"timed_out":true}
2026-06-24T04:51:56Z close_agent Hilbert, plus cleanup closes for two sibling agents
2026-06-24T11:56:27Z close_agent returned only after user abort

The tool outputs reported approximately:

aborted by user after 24930.6s
aborted by user after 24930.5s

So the close path occupied about 7h04m wall-clock and only ended because the user aborted it.

The child transcript contained session metadata, task start/abort lifecycle, and no useful assistant/tool result.

Current code path

Current main still appears to have this shape:

state.send_op(agent_id, Op::Shutdown {}).await
thread.wait_until_terminated().await

The wait on thread.wait_until_terminated() is unbounded. If shutdown/task abort/lifecycle cleanup never completes, close_agent blocks the parent tool call indefinitely.

Desired behavior

close_agent should not wait forever for a child runtime to finish shutdown.

Suggested behavior:

  • submit shutdown as today
  • bound the wait for session-loop termination
  • if the timeout fires, return a structured timeout/error to the parent instead of blocking indefinitely
  • do not remove/release/forget the child if termination was not observed, so the still-live thread remains inspectable/retryable and its slot is not falsely freed
  • normal completed shutdown should keep existing cleanup behavior

Candidate patch

Because external code contributions are by invitation only, I am filing this as an issue/proposal rather than expecting an unsolicited PR to be accepted.

I pushed a minimal candidate implementation in a public fork for reference:

The candidate patch:

  • adds a bounded v1 close-agent termination wait
  • returns the existing request-timeout error on termination timeout
  • leaves the live thread tracked on timeout
  • adds a deterministic regression test with a child task whose abort hook blocks

Validation on candidate patch

Commands run locally:

cargo fmt --check
cargo test -p codex-core close_agent_times_out_when_child_session_does_not_terminate
cargo test -p codex-core close_agent_submits_shutdown_and_returns_previous_status
RUST_MIN_STACK=8388608 cargo test -p codex-core shutdown_agent_tree_closes
RUST_MIN_STACK=8388608 cargo test -p codex-core tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtrees_closed
RUST_MIN_STACK=8388608 cargo test -p codex-core spawn_agent_releases_slot_after_shutdown

Notes:

  • cargo fmt --check passed, with stable rustfmt warnings about the repo config key imports_granularity being unstable.
  • On this local macOS/Rust 1.95 setup, some existing agent-control tests stack-overflow without RUST_MIN_STACK; I verified upstream main shows the same stack overflow for spawn_agent_releases_slot_after_shutdown, so that appears pre-existing in this environment rather than introduced by the candidate patch.

Why this matters

Cleanup should not be able to consume hours of wall-clock time after useful work is done. Once wait_agent has already timed out repeatedly, calling close_agent should be a bounded cleanup operation, not a potentially indefinite parent-turn blocker.

View original on GitHub ↗

4 Comments

guidedways contributor · 2 months ago

Seeing this every time, but the larger issue remains - agents get 'stuck' randomly and often: https://github.com/openai/codex/issues/30400

shahidbeig-a11y · 1 month ago

Same issue

What issue are you seeing?

A Codex Desktop subagent can stall during startup/tool discovery, then later be marked closed in the spawn-edge table while its child thread remains visible/unarchived and incomplete. After that, close_agent hangs instead of treating the agent as already closed or cleaning up the orphaned child thread.

This creates a confusing state for the user: another agent cannot close the subagent, the child appears live/stuck, but the evidence shows it never actually ran user work.

Environment

  • Product: Codex Desktop
  • Platform: macOS
  • Bundled Codex version seen in thread metadata: 0.142.4
  • Model: gpt-5.5
  • Affected child thread / agent id: 019f195b-3609-76a0-bc62-a65e208feee0
  • Parent thread id: 019f1684-c843-7ad1-94f0-bf77bd5acbb2
  • Child nickname: Singer
  • Child role: explorer

Steps to reproduce

  1. From a Codex Desktop thread, spawn a read-only explorer subagent.
  2. The child starts while MCP/tool discovery is still initializing.
  3. The child stalls before producing any assistant/tool output.
  4. Later, shutdown/close is attempted.
  5. The spawn edge becomes closed, but the child thread row remains unarchived and visible.
  6. A later close_agent call against the same child id hangs.

Actual behavior

The child thread rollout contains only four records:

session_meta
task_started
response_item message: <turn_aborted>
event_msg turn_aborted
LmyNBL · 1 month ago

Adding #31036 as additional corroborating Desktop/app-server evidence for this same failure mode.

I rechecked current main, and the relevant v1 close path still appears unbounded:

  • multi_agent_v1.close_agent awaits session.services.agent_control.close_agent(agent_id).
  • AgentControl::shutdown_live_agent in codex-rs/core/src/agent/control/legacy.rs sends Op::Shutdown and then awaits thread.wait_until_terminated() without a timeout.

So #31036 looks like another instance of the same root problem: once an interrupted/nonresponsive subagent never reaches termination, close_agent can block the parent turn indefinitely.

I agree with the bounded shutdown-wait direction. The main semantic question seems to be timeout behavior: should close_agent return a timeout while leaving the live thread tracked for retry/inspection, or should it continue through cleanup/removal after the bounded wait? Either would be much better than an unbounded parent-turn block; keeping the thread tracked seems safer if termination was not actually observed.

tachyon-beep · 1 month ago

This might be the root cause of an FD exhaustion on linux bug. Subagents aren't being cleaned up and holding their FD's open.

It’s the Codex app-server, PID 2877. Not the repo, and not global Linux exhaustion.
Live evidence:
Soft limit: 1,024 FDs
Observed: 928–930 open
619 pipes — every one maps to child-process stdin/stdout/stderr
207 pidfd handles
204 duplicate MCP-server processes:
[snip MCP list]

Dozens of old session JSONL files also remain open.

Ed:

OK, Codex kept investigating on its own (so grain of salt etc):
Because Codex’s cleanup is tied to thread shutdown, not turn/subagent completion—and that shutdown transition is not happening.

The chain is:

task_complete / turn_aborted → thread remains loaded and subscribed → per-thread McpConnectionManager remains alive → Codex retains the three stdio pipe ends and PID handle → MCP process never receives EOF → nothing exits.

Live evidence from this app-server:

  • 34 loaded threads
  • 30 have already completed or aborted
  • 27 are subagents
  • Only one thread/unsubscribe request was received
  • All 34 remain unarchived

Codex 0.145.0 automatically subscribes clients when starting/resuming threads. Its documented 30-minute unload timer only begins after the last subscriber unsubscribes. Turn completion does not count. [Exact 0.145.0 lifecycle documentation](https://github.com/openai/codex/blob/rust-v0.145.0/codex-rs/app-server/README.md)

The MCP shutdown implementation itself was fixed to terminate children when session shutdown actually runs. [Merged shutdown fix](https://github.com/openai/codex/pull/19753) But completed subagents are not reliably reaching that shutdown path—an acknowledged lifecycle bug that remains open upstream. [Subagent MCP leak](https://github.com/openai/codex/issues/17574)

So the short answer is: Codex marks the work finished but keeps the owning session object alive. The MCP processes and pipes therefore still have a legitimate live owner and cannot clean themselves up.