close_agent hangs on stale completed/interrupted child agents that remain listed as live

Open 💬 3 comments Opened Jun 2, 2026 by code-yeongyu

[sisyphus-bot]

Summary

close_agent can hang indefinitely when a child agent remains listed as live after its turn has already reached a terminal-ish state such as completed or interrupted. This was observed from Codex Desktop native subagents during a LazyCodex / OMO ultrawork planning flow, but the investigated code path appears to be in Codex core rather than in the LazyCodex plugin/distribution layer.

Related issues:

  • #25426 looks closely related and may be the same underlying bug.
  • #23700 reports stale subagents more broadly.

I am filing this separately because the observed case involved path-addressed child agents under a parent task, one completed and one interrupted, both still shown by list_agents, and close_agent hanging on both the parent and exact child paths.

Observed Behavior

In a Codex Desktop session using native subagents:

  1. A planning agent spawned child agents under /root/landing_plan/*.
  2. One child ended as interrupted; another child ended as completed.
  3. list_agents continued to show both children as live:
  • /root/landing_plan/internal_landing_arch with status interrupted
  • /root/landing_plan/internal_test_infra with status completed
  1. Calling close_agent on the parent /root/landing_plan hung until the user manually aborted the turn.
  2. Calling close_agent on both exact child paths in parallel also hung until the user manually aborted.
  3. Sending a direct terminate-style assignment to the interrupted child changed only last_task_message; the status remained interrupted.
  4. Calling close_agent with only the basename internal_landing_arch failed as /root/internal_landing_arch not found, so the recognized targets were the stale exact paths that hung.

Expected Behavior

  • close_agent should be idempotent for agents that are already completed, interrupted, or shutdown.
  • close_agent should not wait forever for a child session loop that does not terminate after Op::Shutdown.
  • If shutdown cannot complete, the tool should return a bounded structured result such as shutdown_timed_out or partial cleanup, rather than blocking the parent turn indefinitely.
  • list_agents should either omit terminal children or mark them as cleanup-only / non-blocking.

Static Investigation

I looked at the local Codex source tree and the hang appears explainable from the current shutdown path.

close_agent reads status but still unconditionally awaits shutdown

Both v1 and v2 handlers subscribe to/read the target status, but then still call AgentControl.close_agent(agent_id).await without a fast path for already terminal-ish states.

Relevant files:

  • codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs
  • codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs

The important shape is:

// status is read for event/reporting
let result = Box::pin(session.services.agent_control.close_agent(agent_id))
    .await
    .map_err(|err| collab_agent_error(agent_id, err))
    .map(|_| ());

The close-end event is emitted only after that await returns. If agent_control.close_agent(...) never returns, the tool call remains in progress.

AgentControl waits for termination without a timeout

AgentControl.close_agent(...) marks the spawn edge closed when possible, then calls shutdown_agent_tree(...). That calls shutdown_live_agent(...), which sends Op::Shutdown and then awaits thread termination without a timeout.

Relevant files:

  • codex-rs/core/src/agent/control.rs
  • codex-rs/core/src/codex_thread.rs

The important shape is:

let result = if matches!(thread.agent_status().await, AgentStatus::Shutdown) {
    Ok(String::new())
} else {
    state.send_op(agent_id, Op::Shutdown {}).await
};
thread.wait_until_terminated().await;

wait_until_terminated() itself awaits the session loop termination future directly. If the child loop never reaches that termination future after the new shutdown op, close can hang forever.

Interrupted is not treated as final

In codex-rs/core/src/agent/status.rs, is_final(...) treats Completed, Errored, Shutdown, and NotFound as final, but not Interrupted.

That means an interrupted child can remain non-final for wait/parent-notification purposes even though users experience it as terminal for cleanup. In the MultiAgentV2 parent notification path, terminal child forwarding is gated through final status checks, so an interrupted child may not get forwarded as terminal to the parent.

Relevant files:

  • codex-rs/core/src/agent/status.rs
  • codex-rs/core/src/session/mod.rs

Why This Matters

Long-running orchestrator flows rely on subagents and reviewer agents. If a completed/interrupted child stays visible as live and close_agent hangs, the parent cannot reliably satisfy cleanup gates. Users then have to manually abort or restart the thread, and agent slots may remain counted or appear blocked.

Suggested Fix Direction

A conservative fix would probably separate wait semantics from close semantics:

  1. Add a close-specific terminal predicate, for example is_closeable_terminal, that includes Completed, Interrupted, Shutdown, and perhaps NotFound.
  2. Make close_agent idempotent for those states instead of always entering an unbounded shutdown wait.
  3. Add a bounded timeout around live-thread shutdown, especially around thread.wait_until_terminated().
  4. On timeout, return a structured result or partial-success error and release/detach registry state where safe.
  5. Ensure the close-end event is emitted even when shutdown times out, so the tool call does not remain open forever.

Regression Coverage To Add

  • close_agent returns promptly for a still-loaded child whose status is already Completed.
  • close_agent returns promptly for a still-loaded child whose status is Interrupted.
  • close_agent does not hang when Op::Shutdown is sent but the termination future never resolves.
  • MultiAgentV2 path targets such as /root/landing_plan/internal_landing_arch can be cleaned up without hanging when their child status is completed/interrupted.
  • Repeated close_agent calls against an already-closed or already-cleaned child are idempotent.

Environment

  • Codex Desktop with native subagents
  • LazyCodex / OMO ultrawork planning flow
  • Observed: 2026-06-02 KST
  • Investigated against local Codex source tree after the session hang

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗