close_agent hangs on stale completed/interrupted child agents that remain listed as live
[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:
- A planning agent spawned child agents under
/root/landing_plan/*. - One child ended as
interrupted; another child ended ascompleted. list_agentscontinued to show both children as live:
/root/landing_plan/internal_landing_archwith statusinterrupted/root/landing_plan/internal_test_infrawith statuscompleted
- Calling
close_agenton the parent/root/landing_planhung until the user manually aborted the turn. - Calling
close_agenton both exact child paths in parallel also hung until the user manually aborted. - Sending a direct terminate-style assignment to the interrupted child changed only
last_task_message; the status remainedinterrupted. - Calling
close_agentwith only the basenameinternal_landing_archfailed as/root/internal_landing_archnot found, so the recognized targets were the stale exact paths that hung.
Expected Behavior
close_agentshould be idempotent for agents that are alreadycompleted,interrupted, orshutdown.close_agentshould not wait forever for a child session loop that does not terminate afterOp::Shutdown.- If shutdown cannot complete, the tool should return a bounded structured result such as
shutdown_timed_outor partial cleanup, rather than blocking the parent turn indefinitely. list_agentsshould 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.rscodex-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.rscodex-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.rscodex-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:
- Add a close-specific terminal predicate, for example
is_closeable_terminal, that includesCompleted,Interrupted,Shutdown, and perhapsNotFound. - Make
close_agentidempotent for those states instead of always entering an unbounded shutdown wait. - Add a bounded timeout around live-thread shutdown, especially around
thread.wait_until_terminated(). - On timeout, return a structured result or partial-success error and release/detach registry state where safe.
- 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_agentreturns promptly for a still-loaded child whose status is alreadyCompleted.close_agentreturns promptly for a still-loaded child whose status isInterrupted.close_agentdoes not hang whenOp::Shutdownis sent but the termination future never resolves.- MultiAgentV2 path targets such as
/root/landing_plan/internal_landing_archcan be cleaned up without hanging when their child status is completed/interrupted. - Repeated
close_agentcalls 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
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗