bug: Collab subagents leak spawn slots, lack cascading cancellation, and wait is wait-any

Resolved 💬 3 comments Opened Mar 8, 2026 by William17738 Closed Mar 16, 2026

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_agentshutdown_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 ThreadManagerState but may never complete.
  • No code path releases the slot.
  • The only recovery is global ThreadManager shutdown.

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

  1. Have the completion watcher call release_spawned_thread() when the child reaches a final status, or add a config option for auto-release.
  2. On send_input failure after commit(), explicitly release the slot.
  3. Propagate interrupt/shutdown from parent session to child agents.
  4. Add a wait_all mode or mode: "any" | "all" parameter to the wait tool.
  5. Add an optional max_runtime_ms parameter to spawn_agent.

I have a fix ready and can submit a PR if invited.

View original on GitHub ↗

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