Race condition between subagent async notification and main agent turn lifecycle
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
TurnContextand records the message directly into history.
Both outcomes are wrong:
- 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.
- 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
- I ask Codex to "refactor the auth module and run the test suite to make sure nothing breaks"
- Codex starts refactoring, and spawns a subagent to run tests in the background
- The main task finishes its current turn
- I then ask Codex to "update the documentation to reflect the API changes"
- While Codex is working on that new task, the test subagent finally completes
- The delayed completion notification is injected into the documentation update turn
- 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
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
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(...)
codex-rs/core/src/codex.rs:3768-3781-inject_response_items()
- Only checks whether some
active_turnexists - Does not verify that the injected message belongs to the turn that spawned the subagent
codex-rs/core/src/codex.rs:5595-5615- pending input drain duringrun_turn()
- Pending injected items are pulled into the current turn and recorded as model-visible conversation items
codex-rs/core/src/tasks/mod.rs:220-246-on_task_finished()
- Clears
active_turnwhen 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/coremulti-agent system - Files involved:
control.rs,codex_thread.rs,codex.rs,tasks/mod.rs
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗