mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
Expose and persist turn lineage across the app server (#51415)
## Why Clients starting delegated work need to attribute descendant turns to the original turn and retain that attribution through completion and history reads. ## What changed - Accept optional `parentTurnId` and `rootTurnId` in `turn/start`, and expose `rootTurnId` on returned turns and lifecycle notifications. - Default new turns to their own root when none is supplied. Preserve the active turn's root when steering, ignoring incoming lineage fields. - Carry root IDs through completion, interruption, recovery, and fork boundaries, and persist them for paginated history reads. Leave unknown roots null in older history. - Update protocol schemas and generated TypeScript and Python bindings. ## Testing Extend coverage for default and explicit roots, steering without changing lineage, interrupted and recovered turns, history reads after restart in both history modes, nested fork pagination, and compatibility with older database writers. GitOrigin-RevId: f1a570a12bd11742929fd2e8b017e5d35aa07dcb
This commit is contained in:
@@ -2266,12 +2266,7 @@ impl Session {
|
||||
let event = TurnStartedEvent {
|
||||
turn_attribution: (task_kind == TaskKind::Regular).then(|| turn_context.attribution()),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
root_turn_id: Some(
|
||||
turn_context
|
||||
.turn_metadata_state
|
||||
.root_turn_id()
|
||||
.unwrap_or_else(|| turn_context.sub_id.clone()),
|
||||
),
|
||||
root_turn_id: Some(turn_context.root_turn_id()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
|
||||
model_context_window: turn_context.model_context_window(),
|
||||
|
||||
@@ -284,6 +284,7 @@ fn completed_user_turn_rollout(
|
||||
rollout_items.extend(items);
|
||||
rollout_items.push(RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id,
|
||||
last_agent_message: None,
|
||||
error: None,
|
||||
@@ -547,6 +548,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id,
|
||||
last_agent_message: None,
|
||||
error: None,
|
||||
@@ -638,6 +640,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
|
||||
RolloutItem::ResponseItem(turn_one_assistant.clone().into()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: first_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -681,6 +684,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
|
||||
RolloutItem::ResponseItem(turn_two_assistant.into()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: rolled_back_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -768,6 +772,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
|
||||
RolloutItem::ResponseItem(turn_one_assistant.clone().into()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: first_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -873,6 +878,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
|
||||
RolloutItem::ResponseItem(turn_one_assistant.clone().into()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: first_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -907,6 +913,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
|
||||
RolloutItem::ResponseItem(turn_two_assistant.into()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: second_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -935,6 +942,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
|
||||
}),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: standalone_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -1017,6 +1025,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
|
||||
RolloutItem::ResponseItem(assistant_message("turn 1 assistant").into()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: first_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -1042,6 +1051,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
|
||||
RolloutItem::ResponseItem(assistant_reply.into()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: assistant_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -1119,6 +1129,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding
|
||||
RolloutItem::ResponseItem(assistant_message("only assistant").into()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: only_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -1177,6 +1188,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: user_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -1200,6 +1212,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: standalone_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -1262,6 +1275,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
|
||||
RolloutItem::TurnContext(previous_context_item.clone()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: previous_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -1640,6 +1654,7 @@ async fn bounded_replay_matches_full_replay_after_empty_turn_compactions(
|
||||
});
|
||||
wake_items.push(RolloutItem::EventMsg(EventMsg::TurnAborted(
|
||||
codex_protocol::protocol::TurnAbortedEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: Some(format!("wake-{window_number}")),
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
error: None,
|
||||
@@ -2095,6 +2110,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
|
||||
RolloutItem::TurnContext(current_context_item),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: current_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -2189,6 +2205,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: previous_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -2315,6 +2332,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: previous_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -2347,6 +2365,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::TurnAborted(
|
||||
codex_protocol::protocol::TurnAbortedEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: None,
|
||||
started_at: None,
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
@@ -2457,6 +2476,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: previous_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -2489,6 +2509,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::TurnAborted(
|
||||
codex_protocol::protocol::TurnAbortedEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: Some(unmatched_abort_turn_id),
|
||||
started_at: None,
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
@@ -2500,6 +2521,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
RolloutItem::TurnContext(current_context_item.clone()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: current_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -2600,6 +2622,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: previous_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
@@ -2791,6 +2814,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id: previous_turn_id,
|
||||
started_at: None,
|
||||
last_agent_message: None,
|
||||
|
||||
@@ -816,6 +816,7 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted
|
||||
.expect("expected turn aborted event")
|
||||
.expect("channel open");
|
||||
let EventMsg::TurnAborted(TurnAbortedEvent {
|
||||
root_turn_id,
|
||||
turn_id,
|
||||
reason,
|
||||
error: _,
|
||||
@@ -826,6 +827,7 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted
|
||||
else {
|
||||
panic!("expected turn aborted event");
|
||||
};
|
||||
assert_eq!(root_turn_id, Some(tc.root_turn_id()));
|
||||
assert_eq!(turn_id, Some(tc.sub_id.clone()));
|
||||
assert_eq!(reason, TurnAbortReason::Interrupted);
|
||||
assert!(started_at.is_some());
|
||||
@@ -4360,6 +4362,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
RolloutItem::TurnContext(previous_context_item.clone()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id,
|
||||
last_agent_message: None,
|
||||
error: None,
|
||||
@@ -6282,6 +6285,7 @@ async fn compaction_persists_resume_metadata_and_companion_records() {
|
||||
if let Some(turn_id) = last_started_turn_id {
|
||||
items.push(RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
TurnCompleteEvent {
|
||||
root_turn_id: None,
|
||||
turn_id,
|
||||
last_agent_message: None,
|
||||
error: None,
|
||||
|
||||
@@ -399,6 +399,13 @@ enum TurnContextBuildMode {
|
||||
}
|
||||
|
||||
impl TurnContext {
|
||||
/// Returns the resolved causal root for this admitted turn.
|
||||
pub(crate) fn root_turn_id(&self) -> String {
|
||||
self.turn_metadata_state
|
||||
.root_turn_id()
|
||||
.unwrap_or_else(|| self.sub_id.clone())
|
||||
}
|
||||
|
||||
/// Builds a review turn with shared session grants and fresh turn-local state.
|
||||
pub(super) fn for_review(
|
||||
&self,
|
||||
|
||||
@@ -268,8 +268,9 @@ pub(super) async fn handle(
|
||||
}
|
||||
};
|
||||
// Link this request's trace to the accepted turn, which may have an older trace.
|
||||
if let Ok(TurnInputSubmission::Started { turn_id } | TurnInputSubmission::Steered { turn_id }) =
|
||||
&result
|
||||
if let Ok(
|
||||
TurnInputSubmission::Started { turn_id, .. } | TurnInputSubmission::Steered { turn_id, .. },
|
||||
) = &result
|
||||
{
|
||||
tracing::Span::current().record("turn.id", turn_id);
|
||||
}
|
||||
@@ -306,7 +307,7 @@ pub(super) async fn handle_recovery(
|
||||
/*expected_previous_turn_id*/ None,
|
||||
)
|
||||
.await;
|
||||
if let Ok(TurnInputSubmission::Started { turn_id }) = &result {
|
||||
if let Ok(TurnInputSubmission::Started { turn_id, .. }) = &result {
|
||||
tracing::Span::current().record("turn.id", turn_id);
|
||||
}
|
||||
result
|
||||
@@ -363,9 +364,12 @@ async fn start_or_steer(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(turn_id) => {
|
||||
Ok((turn_id, root_turn_id)) => {
|
||||
settings.apply_steered(session, submission_id).await?;
|
||||
Ok(TurnInputSubmission::Steered { turn_id })
|
||||
Ok(TurnInputSubmission::Steered {
|
||||
turn_id,
|
||||
root_turn_id,
|
||||
})
|
||||
}
|
||||
Err(NotSubmittedReason::NoActiveTurn) => {
|
||||
// MAv1 sends explicit input to spawned agents as part of an existing
|
||||
@@ -413,10 +417,11 @@ async fn start_or_steer(
|
||||
.push(pending_turn_input(session, input, &turn_context.sub_id, origin).await);
|
||||
}
|
||||
session
|
||||
.spawn_task(turn_context, task_input, RegularTask::new())
|
||||
.spawn_task(Arc::clone(&turn_context), task_input, RegularTask::new())
|
||||
.await;
|
||||
Ok(TurnInputSubmission::Started {
|
||||
turn_id: submission_id,
|
||||
root_turn_id: turn_context.root_turn_id(),
|
||||
})
|
||||
}
|
||||
Err(reason) => Ok(TurnInputSubmission::NotSubmitted { reason }),
|
||||
@@ -575,10 +580,11 @@ async fn start_if_idle(
|
||||
}
|
||||
}
|
||||
session
|
||||
.start_task(turn_context, task_input, RegularTask::new())
|
||||
.start_task(Arc::clone(&turn_context), task_input, RegularTask::new())
|
||||
.await;
|
||||
Ok(TurnInputSubmission::Started {
|
||||
turn_id: submission_id,
|
||||
root_turn_id: turn_context.root_turn_id(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -626,9 +632,12 @@ async fn steer(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(turn_id) => {
|
||||
Ok((turn_id, root_turn_id)) => {
|
||||
settings.apply_steered(session, submission_id).await?;
|
||||
Ok(TurnInputSubmission::Steered { turn_id })
|
||||
Ok(TurnInputSubmission::Steered {
|
||||
turn_id,
|
||||
root_turn_id,
|
||||
})
|
||||
}
|
||||
Err(reason) => Ok(TurnInputSubmission::NotSubmitted { reason }),
|
||||
}
|
||||
@@ -721,7 +730,7 @@ impl Session {
|
||||
required_final_output_json_schema: Option<&Value>,
|
||||
responsesapi_client_metadata: Option<HashMap<String, String>>,
|
||||
origin: UserInputOrigin,
|
||||
) -> Result<String, NotSubmittedReason> {
|
||||
) -> Result<(String, String), NotSubmittedReason> {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
let Some(active_turn) = active.as_mut() else {
|
||||
return Err(NotSubmittedReason::NoActiveTurn);
|
||||
@@ -799,7 +808,10 @@ impl Session {
|
||||
pending_input,
|
||||
)
|
||||
.await;
|
||||
Ok(active_turn_id.clone())
|
||||
Ok((
|
||||
active_turn_id.clone(),
|
||||
active_task.turn_context.root_turn_id(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ async fn steering_does_not_wait_for_realtime_history() {
|
||||
assert_eq!(
|
||||
submission,
|
||||
TurnInputSubmission::Steered {
|
||||
root_turn_id: turn_context.sub_id.clone(),
|
||||
turn_id: turn_context.sub_id.clone()
|
||||
}
|
||||
);
|
||||
@@ -922,6 +923,7 @@ async fn steer_only_enforces_expected_turn_id() {
|
||||
assert_eq!(
|
||||
submission,
|
||||
TurnInputSubmission::Steered {
|
||||
root_turn_id: turn_context.sub_id.clone(),
|
||||
turn_id: turn_context.sub_id.clone()
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user