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:
Eddie Chen
2026-10-06 16:56:39 +00:00
committed by copyberry
parent ff9ab4aed9
commit cfc946f4e1
149 changed files with 896 additions and 135 deletions
+1 -6
View File
@@ -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,
+4
View File
@@ -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,
+23 -11
View File
@@ -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()
}
);