Race condition between subagent async notification and main agent turn lifecycle

Resolved 💬 1 comment Opened Mar 11, 2026 by lovingfish Closed Mar 16, 2026

Summary

There is a race condition in parent-thread subagent completion notifications.

When a subagent finishes, the parent thread receives a model-visible contextual user message via inject_user_message_without_turn(). That callback does not preserve the parent turn that originally spawned the subagent, so a delayed completion notification can be attached to the wrong part of the parent conversation history.

Problem Description

When a subagent is spawned to do background work, maybe_start_completion_watcher() waits for it to finish and then sends a completion notification back to the parent thread.

The problem is that the notification is delivered without any binding to the original parent turn. At delivery time, inject_user_message_without_turn() only checks whether the parent thread has some active turn right now:

  • If the parent does have an active turn, the notification is injected into that turn's pending input.
  • If the parent does not have an active turn, the code creates a fresh default TurnContext and records the message directly into history.

Both outcomes are wrong:

  1. Wrong-active-turn pollution

If the parent has already moved on to a new task, the delayed notification is injected into that new task's turn and becomes model-visible input for the wrong turn.

  1. Between-turn history pollution

If the parent is idle between turns, the delayed notification is still appended to history, so the next turn sees an out-of-band subagent completion message in the wrong place.

So the bug is broader than just the NoActiveTurn fallback: the more direct failure mode is actually that a late notification can be injected into whatever parent turn happens to be active when the child finishes.

Real-world Scenario

  1. I ask Codex to "refactor the auth module and run the test suite to make sure nothing breaks"
  2. Codex starts refactoring, and spawns a subagent to run tests in the background
  3. The main task finishes its current turn
  4. I then ask Codex to "update the documentation to reflect the API changes"
  5. While Codex is working on that new task, the test subagent finally completes
  6. The delayed completion notification is injected into the documentation update turn
  7. Codex now sees a model-visible message like "tests passed / refactor complete" during the docs task and may incorrectly reason that the current task is done or that the latest work item has already been validated

If the notification arrives slightly earlier or later, it may instead land as a free-floating history item between turns, which is also misleading for subsequent reasoning.

Code Analysis

Key Files

  1. codex-rs/core/src/agent/control.rs:421-464 - maybe_start_completion_watcher()
  • Spawns an async watcher for child thread completion
  • Calls parent_thread.inject_user_message_without_turn(...) when the child finishes
  • Does not carry the originating parent turn id into this callback
  1. codex-rs/core/src/codex_thread.rs:109-131 - inject_user_message_without_turn()
  • First tries session.inject_response_items(...)
  • If there is an active turn, the notification is queued into that active turn
  • Otherwise it falls back to new_default_turn() + record_conversation_items(...)
  1. codex-rs/core/src/codex.rs:3768-3781 - inject_response_items()
  • Only checks whether some active_turn exists
  • Does not verify that the injected message belongs to the turn that spawned the subagent
  1. codex-rs/core/src/codex.rs:5595-5615 - pending input drain during run_turn()
  • Pending injected items are pulled into the current turn and recorded as model-visible conversation items
  1. codex-rs/core/src/tasks/mod.rs:220-246 - on_task_finished()
  • Clears active_turn when the current task finishes, creating the timing window for the fallback path

Critical Behavior

pub(crate) async fn inject_user_message_without_turn(&self, message: String) {
    let pending_item = ResponseInputItem::Message {
        role: "user".to_string(),
        content: vec![ContentItem::InputText { text: message }],
    };
    let pending_items = vec![pending_item];
    let Err(items_without_active_turn) = self
        .codex
        .session
        .inject_response_items(pending_items)
        .await
    else {
        return;
    };

    let turn_context = self.codex.session.new_default_turn().await;
    let items: Vec<ResponseItem> = items_without_active_turn
        .into_iter()
        .map(ResponseItem::from)
        .collect();
    self.codex
        .session
        .record_conversation_items(turn_context.as_ref(), &items)
        .await;
}

The key issue is that success here means "there is a current active turn", not "this is the correct original parent turn".

Race Condition Sequences

Case A: Parent already started a new turn

Time →

Parent:    [Turn 1: refactor] ---- spawn child ---- [Turn 1 complete] ---- [Turn 2: update docs active]
Child:                      [running tests ..............................................] [complete]
                                                                                             |
                                                                                             v
Parent:                                                                inject_response_items() succeeds
                                                                        because Turn 2 is active
                                                                                             |
                                                                                             v
Parent:                                                                notification becomes pending input
                                                                        for Turn 2

Case B: Parent has no active turn yet

Time →

Parent:    [Turn 1: refactor] ---- spawn child ---- [Turn 1 complete] ---- [idle]
Child:                      [running tests ..............................] [complete]
                                                                        |
                                                                        v
Parent:                                                        no active turn
                                                                        |
                                                                        v
Parent:                                                        new_default_turn() +
                                                               record_conversation_items()
                                                               appends orphaned notification
                                                               into history

Impact

  • Delayed subagent completion can pollute the wrong parent task
  • The model can misinterpret background completion as progress for the current task
  • The notification can appear in misleading history order even when no turn is active

Existing Coverage Gap

There are tests that verify the parent eventually receives a subagent notification in history, but they do not verify that the notification is attached to the correct originating turn or prevented from polluting a later turn.

Environment

  • Codex version: current main at 2cfa106
  • Component: codex-rs/core multi-agent system
  • Files involved: control.rs, codex_thread.rs, codex.rs, tasks/mod.rs

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗