bug: Collab subagents leak spawn slots, lack cascading cancellation, and wait is wait-any
Summary
The collab multi-agent system has several lifecycle management issues that compound under real-world usage: completed agents permanently occupy spawn slots, parent cancellation doesn't propagate to children, and the wait tool only returns on the first agent completion rather than all.
Finding 1: Completed agents still occupy spawn slots
Files:
codex-rs/core/src/agent/control.rs:385-427(completion watcher)codex-rs/core/src/agent/guards.rs:68(release_spawned_thread)
The maybe_start_completion_watcher() spawns a detached task that injects a <subagent_notification> into the parent when a child agent reaches a final status. However, it does not call release_spawned_thread(). The spawn slot remains occupied until the model explicitly calls close_agent → shutdown_agent() (control.rs:290-296).
Impact: With the default agent_max_threads=6, if the model spawns 6 agents that all complete but doesn't close them, all subsequent spawn_agent calls fail with AgentLimitReached.
Finding 2: spawn_agent can permanently leak a spawn slot
File: codex-rs/core/src/agent/control.rs:177-190
line 180: reservation.commit() // slot is now permanently reserved
line 187: self.send_input(...) // can fail
line 188: self.maybe_start_completion_watcher(...)
commit() at line 180 sets SpawnReservation.active = false, disabling the RAII Drop cleanup. If send_input() at line 187 then fails for any reason other than InternalAgentDied, the slot is consumed forever:
- The thread is registered in
ThreadManagerStatebut may never complete. - No code path releases the slot.
- The only recovery is global
ThreadManagershutdown.
Finding 3: No cascading cancellation from parent to child
File: codex-rs/core/src/thread_manager.rs:366
When a parent session is interrupted or shut down, child agents are not automatically cancelled. Only ThreadManager::remove_and_close_all_threads() (the global shutdown path) cleans up all threads.
Impact: A parent agent that is interrupted mid-task leaves its children running, consuming API tokens and spawn slots, until the entire session ends.
Finding 4: wait implements wait-any, not wait-all
File: codex-rs/core/src/tools/handlers/multi_agents.rs:583-604
match timeout_at(deadline, futures.next()).await {
Ok(Some((thread_id, status))) => {
statuses.insert(thread_id, status);
break; // ← returns after FIRST completion
}
The wait tool returns as soon as any one of the listed agents reaches a final status. It then drains other immediately-available results with now_or_never() (line 604), but fundamentally the model must call wait repeatedly to collect all results. Combined with MIN_WAIT_TIMEOUT_MS = 10_000 (line 41), waiting for N agents takes at minimum N × 10 seconds of wall time.
Finding 5: No per-agent execution timeout
File: codex-rs/core/src/tools/handlers/multi_agents.rs:102-108
spawn_agent accepts no max_runtime parameter. Collab agents can run indefinitely. By contrast, batch jobs have DEFAULT_AGENT_JOB_ITEM_TIMEOUT = 30 minutes (agent_jobs.rs:40) and reap_stale_active_items() for enforcement.
Suggested improvements
- Have the completion watcher call
release_spawned_thread()when the child reaches a final status, or add a config option for auto-release. - On
send_inputfailure aftercommit(), explicitly release the slot. - Propagate interrupt/shutdown from parent session to child agents.
- Add a
wait_allmode ormode: "any" | "all"parameter to the wait tool. - Add an optional
max_runtime_msparameter tospawn_agent.
I have a fix ready and can submit a PR if invited.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗