Bug: Agent spawn slots leak across turns in persistent sessions (app-server / interactive CLI)

Resolved 💬 7 comments Opened Apr 17, 2026 by hac425xxx Closed Apr 22, 2026
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What version of Codex CLI is running?

codex-cli 0.121.0

What subscription do you have?

pro

Which model were you using?

_No response_

What platform is your computer?

_No response_

What terminal emulator and version are you using (if applicable)?

_No response_

What issue are you seeing?

Bug: Agent spawn slots leak across turns in persistent sessions (app-server / interactive CLI)

Codex version: b0324f9f0569ebfc5534fd6844971d9ae029c791

Summary

In persistent session modes (app-server transport, interactive CLI REPL), the AgentRegistry.total_count used to enforce agent_max_threads is only decremented when close_agent is explicitly called. If a spawned agent reaches a terminal state (Completed, Shutdown, etc.) but the model does not call close_agent before the turn ends, the spawn slot is permanently leaked. Over multiple turns, leaked slots accumulate until total_count >= max_threads, after which all spawn_agent calls fail with AgentLimitReached — even though zero agents are actually running.

This does not affect codex exec because each invocation starts a fresh process with a new AgentRegistry.

Reproduction

  1. Start a persistent session (app-server or interactive CLI).
  2. In Turn 1, have the model spawn several agents (e.g. 6+). Let some of them complete without the model calling close_agent on each one (this happens naturally when the model runs out of tool-call budget, the turn times out, or the context window fills up before cleanup).
  3. In Turn 2, attempt to spawn_agent — it fails with AgentLimitReached { max_threads: N } even though no agents are running.

Observed in production: Over 9 rounds in an app-server session, Round 1 spawned 24 agents but only closed 14. By Round 4, spawn_agent was permanently blocked (spawned=0, close=0) for the remainder of the session.

Round 1: spawned=24, close=14, live=7  → 10 slots leaked
Round 2: spawned=6,  close=4,  live=4  →  2 more leaked
Round 3: spawned=2,  close=2,  live=2  →  0 new leaked (but cumulative damage done)
Round 4–9: spawned=0, close=0          →  permanently stuck

Root Cause Analysis

1. total_count increment path

AgentRegistry::reserve_spawn_slot() increments total_count via try_increment_spawned():

// codex-rs/core/src/agent/registry.rs:80-97
pub(crate) fn reserve_spawn_slot(
    self: &Arc<Self>,
    max_threads: Option<usize>,
) -> Result<SpawnReservation> {
    if let Some(max_threads) = max_threads {
        if !self.try_increment_spawned(max_threads) {
            return Err(CodexErr::AgentLimitReached { max_threads });
        }
    } else {
        self.total_count.fetch_add(1, Ordering::AcqRel);
    }
    Ok(SpawnReservation { state: Arc::clone(self), active: true, ... })
}
2. total_count decrement path — only via explicit shutdown

release_spawned_thread() is the only path that decrements total_count:

// codex-rs/core/src/agent/registry.rs:99-119
pub(crate) fn release_spawned_thread(&self, thread_id: ThreadId) {
    let removed_counted_agent = { /* remove from agent_tree */ };
    if removed_counted_agent {
        self.total_count.fetch_sub(1, Ordering::AcqRel);
    }
}

This is called from exactly two places:

  • AgentControl::shutdown_live_agent() (control.rs:676) — called by close_agent tool handler
  • AgentControl::handle_thread_request_result() (control.rs:655) — only on InternalAgentDied
3. wait_agent does NOT release slots

Both v1 (multi_agents/wait.rs) and v2 (multi_agents_v2/wait.rs) wait_agent handlers only observe status — they never call release_spawned_thread or shutdown_live_agent.

4. Turn completion does NOT release slots

There is no turn-boundary cleanup hook. When a turn completes, the AgentRegistry state (including total_count and agent_tree) is carried forward unchanged into the next turn.

5. maybe_start_completion_watcher does NOT release slots

The completion watcher (control.rs:898-971) watches for agents reaching final status and notifies the parent thread, but it does not call release_spawned_thread.

6. Why codex exec is immune

Each codex exec invocation starts a fresh OS process → fresh AgentRegistrytotal_count starts at 0. Even with codex exec resume <thread_id>, the AgentRegistry is reconstructed from scratch (only agents with Open edge status are re-reserved via resume_agent_from_rollout).

Why the model doesn't always close_agent

The model is expected to call close_agent after wait_agent, but in practice this doesn't always happen:

  • The model may exhaust its tool-call budget before closing all agents
  • The turn may time out mid-execution
  • Context window compaction may drop the close_agent intent
  • With many concurrent agents (12+), the model may not track all IDs

This is especially problematic in automated/agentic workflows where turns are driven programmatically and agent counts are high.

Proposed Fix

Release spawn slots for agents in terminal state before enforcing the limit.

Modify AgentRegistry::reserve_spawn_slot() to reap finalized agents before checking the count:

pub(crate) fn reserve_spawn_slot(
    self: &Arc<Self>,
    max_threads: Option<usize>,
) -> Result<SpawnReservation> {
    if let Some(max_threads) = max_threads {
        if !self.try_increment_spawned(max_threads) {
            // Before failing, try to reclaim slots from finalized agents
            self.reap_finalized_agents();  // new method
            if !self.try_increment_spawned(max_threads) {
                return Err(CodexErr::AgentLimitReached { max_threads });
            }
        }
    } else {
        self.total_count.fetch_add(1, Ordering::AcqRel);
    }
    Ok(SpawnReservation { ... })
}

Alternative approaches (non-exclusive):

  1. Reap in reserve_spawn_slot (above) — minimal change, lazy cleanup only when needed.
  2. Auto-release on turn completion — add a turn-boundary hook that calls release_spawned_thread for all agents in agent_tree whose status is final (Completed, Shutdown, Errored, NotFound).
  3. Auto-release in maybe_start_completion_watcher — when the watcher observes a final status, also call release_spawned_thread (not just notify the parent).

Option 2 or 3 would keep total_count accurate at all times rather than only at spawn time.

Impact

  • Severity: High for persistent sessions (app-server, interactive REPL)
  • Scope: Any multi-turn workflow that spawns agents across turns without perfectly closing each one
  • Workaround: None available to API consumers — the AgentRegistry is internal and not exposed. The only current workaround is to destroy and recreate the entire session, losing all conversation context.

Files involved

| File | Role |
|---|---|
| codex-rs/core/src/agent/registry.rs | AgentRegistry, total_count, reserve_spawn_slot, release_spawned_thread |
| codex-rs/core/src/agent/control.rs | shutdown_live_agent, close_agent, maybe_start_completion_watcher |
| codex-rs/core/src/agent/status.rs | is_final() — defines terminal agent states |
| codex-rs/core/src/tools/handlers/multi_agents/wait.rs | v1 wait_agent — does not release slots |
| codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs | v1 close_agent — the only user-facing slot release path |
| codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs | v2 wait_agent — does not release slots |
| codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs | v2 close_agent — same as v1 |
| codex-rs/protocol/src/error.rs | CodexErr::AgentLimitReached definition |
| codex-rs/core/src/config/mod.rs | DEFAULT_AGENT_MAX_THREADS = Some(6) |

What steps can reproduce the bug?

n

What is the expected behavior?

_No response_

Additional information

_No response_

View original on GitHub ↗

7 Comments

github-actions[bot] contributor · 3 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #16328

Powered by Codex Action

jif-oai contributor · 3 months ago

Hi,

This feature is by design, an "open" agent should still count in the budget as its state is still persisted in memory. Completing a turn is different from being closed so none of your points 3 to 5 should release a spot.

A few questions though:

  1. "The model may exhaust its tool-call budget before closing all agents". Which budget are you talking about?
  2. "The turn may time out mid-execution". Which timeout? From the main agent or the sub-agent? If it times out, why would you need to be able to spawn a new agent?
  3. "Context window compaction may drop the close_agent intent". Good catch, this is a known issue and it will be fixed in multi-agent v2

Could you describe a bit your setup? What's the REPL you're mentioning

hac425xxx · 3 months ago

I'm using Codex in interactive CLI (REPL) mode with app-server transport. The REPL drives multi-turn conversations
programmatically.

Answering your questions:

  1. "Which budget?" — I'm referring to the model's tool-call limit per turn. When I ask the model to spawn 12 agents in a single turn, it may successfully spawn and

wait_agent for all of them, but by the time all agents complete, the model doesn't always generate close_agent calls for every single one before the turn ends. This
is especially true with 12+ agents — the model loses track of some agent IDs or simply stops generating tool calls.

  1. "Which timeout?" — The main agent's turn. In my case, the sub-agents finish successfully, but the main agent's response generation ends (either by the model

emitting a final text response, or by hitting output token limits) before it has closed all agents. The sub-agents are done — their work is complete — but the model
just didn't call close_agent on all of them.

  1. Context compaction — Glad this is being tracked for v2.

The core problem from a user's perspective:

I respectfully disagree that this is purely "by design." Here's the concrete scenario:

Turn 1: User asks model to spawn 12 agents for parallel tasks
→ Model spawns 12 agents, all complete successfully
→ Model calls close_agent on 8 of them, misses 4
→ Turn ends, Codex returns to waiting for input
→ 4 "phantom" slots are now permanently leaked

Turn 2: User asks model to spawn more agents
→ spawn_agent fails with AgentLimitReached
→ No agents are actually running — all completed in Turn 1
→ User has no way to recover without destroying the session

The key issue: the agents are in a terminal state (Completed). They are not "open" in any meaningful sense — they have no running process, no pending work, and no
state that would be lost by releasing their slots. The model simply forgot to call close_agent on them.

I understand that an agent in Open status with unfinished work should count against the budget. But an agent that has reached Completed status is effectively dead —
keeping its slot reserved serves no purpose and creates a resource leak that the user cannot recover from.

hac425xxx · 3 months ago

hello @jif-oai

jif-oai contributor · 3 months ago
and no state that would be lost by releasing their slots.

This is actually not true. They can have some background terminal processes running or some MCP connections open. And this is a central point here

The model simply forgot to call close_agent on them.

This will be fixed in multi-agent v2. As a quick solution, we could list the open agents to a model when it reaches the limit on the number of agents

vzd3v · 2 months ago

It’s hard for me to understand why this issue was closed. The same problem is still actively disrupting my work right now.
https://github.com/openai/codex/issues/19197

hac425xxx · 2 months ago

Has this bug been fixed? In which version was it fixed? @jif-oai