From 551bd409ebf03fc6ea0dcad0915368d8a493f012 Mon Sep 17 00:00:00 2001 From: Eddie Chen <173840017+eddie-openai@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:33:36 +0000 Subject: [PATCH] Preserve turn attribution across recovery and compaction (#51402) ## Why Recovering an interrupted turn replaced its original trigger with `retry` and restored only its root lineage from model context. Turns suspended before that context was written could lose their attribution entirely. ## What changed - Persist regular-turn attribution in `TurnStartedEvent` and compaction resume metadata, including the trigger, parent and root turn IDs, and initiating agent path. - Restore matching attribution during recovery, keep unknown triggers absent, and retain the model-context root fallback for older records. - Reconstruct attribution from surviving turns across rollback and late completion or abort events, and clear inherited attribution when spawning a forked agent. ## Testing Add coverage for repeated cold recovery, suspension before context persistence, legacy rollouts, fresh attribution on subsequent turns, compaction checkpoints, rollback, and late terminal events. GitOrigin-RevId: 85b5763e8fbd48a1ea2c07cfda8196e63c2cd33a --- .../app-server-exports-stable.json.zst | Bin 158201 -> 158318 bytes .../src/protocol/item_builders_tests.rs | 1 + .../src/protocol/thread_history.rs | 40 +++ .../thread_history_projection_tests.rs | 1 + .../app-server/src/bespoke_event_handling.rs | 3 + .../thread_processor_tests.rs | 1 + .../app-server/src/turn_cost_worker_tests.rs | 1 + .../app-server/tests/suite/v2/thread_fork.rs | 3 + .../app-server/tests/suite/v2/thread_read.rs | 1 + .../tests/suite/v2/thread_resume.rs | 5 + .../tests/suite/v2/thread_timeline.rs | 1 + codex-rs/core/src/agent/control/spawn.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 13 +- codex-rs/core/src/codex_thread.rs | 9 +- .../realtime_conversation/transcript_tail.rs | 5 +- codex-rs/core/src/realtime_history_tests.rs | 2 + codex-rs/core/src/session/daemon_recovery.rs | 14 +- codex-rs/core/src/session/mod.rs | 7 +- .../src/session/rollout_reconstruction.rs | 93 +++++- .../session/rollout_reconstruction_tests.rs | 219 ++++++++++++- codex-rs/core/src/session/tests.rs | 92 +++++- codex-rs/core/src/session/turn_context.rs | 10 + codex-rs/core/src/session/turn_input.rs | 23 +- codex-rs/core/src/state/session.rs | 21 ++ codex-rs/core/src/tasks/compact.rs | 2 +- codex-rs/core/src/tasks/mod.rs | 6 +- codex-rs/core/src/tasks/regular.rs | 2 +- codex-rs/core/src/tasks/user_shell.rs | 4 +- codex-rs/core/src/thread_manager_tests.rs | 2 + .../src/thread_rollout_truncation_tests.rs | 1 + codex-rs/core/tests/suite/resume_warning.rs | 1 + .../core/tests/suite/turn_input_submission.rs | 308 +++++++++++++++++- .../src/sessions/export.rs | 1 + .../history/src/compaction_resume_metadata.rs | 4 + codex-rs/history/src/lib.rs | 1 + codex-rs/history/src/tests.rs | 2 + codex-rs/protocol/src/protocol.rs | 8 +- codex-rs/protocol/src/turn_input.rs | 26 ++ .../rollout/src/persistence_metrics_tests.rs | 1 + codex-rs/thread-store/src/local/mod.rs | 1 + .../src/local/model_context_tests.rs | 2 + .../src/local/revert_thread_tests.rs | 1 + .../local/rollout_migration/canonicalizer.rs | 1 + .../src/local/rollout_migration_tests.rs | 1 + .../thread_history_materialization_tests.rs | 1 + .../thread-store/src/thread_metadata_sync.rs | 1 + codex-rs/tui/src/app/tests.rs | 1 + .../src/app/tests/history_hydration_tests.rs | 1 + .../app/tests/pagination_completion_tests.rs | 1 + .../app/tests/session_lifecycle_requests.rs | 4 + 50 files changed, 912 insertions(+), 38 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index f64e9f9227c19710548a716f7f271e6132e781c9..668e51f34b6989f3fad2d75b9342e8e97e3eb93f 100644 GIT binary patch delta 22184 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 delta 22066 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 Result<()> { append_rollout_item_to_path( &source_path, &RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: unfinished_turn_id.to_string(), root_turn_id: None, trace_id: None, @@ -1524,6 +1525,7 @@ async fn thread_fork_creates_reference_backed_paginated_thread() -> Result<()> { ); for item in [ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: "turn-1".to_string(), root_turn_id: None, trace_id: None, @@ -1854,6 +1856,7 @@ async fn assert_thread_fork_freezes_active_paginated_turn_as_interrupted( append_rollout_item_to_path( source_path.as_path(), &RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: "active-turn".to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 3ac95063e4..58505d0d61 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -2426,6 +2426,7 @@ async fn read_items_page( fn paginated_turn_started(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 6f58231e30..6e339c75a5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -3915,6 +3915,7 @@ async fn cold_paginated_resume_restores_usage_without_loading_turns() -> Result< append_rollout_item_to_path( &path, &RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: canonical_turn_id.to_string(), root_turn_id: None, trace_id: None, @@ -4027,6 +4028,7 @@ async fn cold_paginated_resume_omits_usage_when_its_turn_is_ambiguous() -> Resul append_rollout_item_to_path( &path, &RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: interrupted_turn_id.to_string(), root_turn_id: None, trace_id: None, @@ -4170,6 +4172,7 @@ async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn() "timestamp": meta_rfc3339, "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: stale_turn_id.to_string(), root_turn_id: None, trace_id: None, @@ -4259,6 +4262,7 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re "timestamp": meta_rfc3339, "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: interrupted_turn_id.to_string(), root_turn_id: None, trace_id: None, @@ -4576,6 +4580,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "timestamp": meta_rfc3339, "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_timeline.rs b/codex-rs/app-server/tests/suite/v2/thread_timeline.rs index c0babef2bc..954f09eaf6 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_timeline.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_timeline.rs @@ -98,6 +98,7 @@ async fn timeline_pages_mix_items_and_resolve_the_opening_realtime_session() -> content: RealtimeItemContent::RealtimeSessionStarted, }), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: "turn-1".to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 901a8f153e..c5a17767da 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -1201,6 +1201,7 @@ impl LocalAgentControl { compacted.latest_token_usage_record = None; if let Some(resume_metadata) = &mut compacted.resume_metadata { resume_metadata.multi_agent_version = Some(multi_agent_version); + resume_metadata.turn_attribution = None; if !preserve_context_baselines { resume_metadata.previous_turn_settings = None; } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 7bb29d8e00..17404e0acc 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -565,6 +565,7 @@ async fn get_status_returns_not_found_without_manager() { #[tokio::test] async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: "turn-1".to_string(), root_turn_id: None, trace_id: None, @@ -2256,7 +2257,14 @@ async fn spawn_agent_fork_sanitizes_inherited_compaction_metadata() { let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await; let parent_resume_metadata = codex_history::CompactionResumeMetadata { multi_agent_version: Some(MultiAgentVersion::V2), - last_started_turn_id: Some("parent-turn".into()), + last_started_turn_id: Some("parent-turn".to_string()), + turn_attribution: Some(codex_history::TurnAttribution { + turn_id: "parent-turn".to_string(), + turn_trigger: Some("automation".to_string()), + parent_turn_id: Some("initiating-turn".to_string()), + initiating_agent_path: Some(codex_protocol::AgentPath::root()), + root_turn_id: Some("root-turn".to_string()), + }), previous_turn_settings: Some(codex_history::PreviousTurnSettings { model: "parent-model".into(), comp_hash: None, @@ -2382,6 +2390,7 @@ async fn spawn_agent_fork_sanitizes_inherited_compaction_metadata() { inherited_resume_metadata, &codex_history::CompactionResumeMetadata { multi_agent_version: Some(MultiAgentVersion::V1), + turn_attribution: None, ..parent_resume_metadata } ); @@ -3287,6 +3296,7 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc resume_metadata: Some(codex_history::CompactionResumeMetadata { multi_agent_version: Some(MultiAgentVersion::V2), last_started_turn_id: None, + turn_attribution: None, previous_turn_settings: Some(codex_history::PreviousTurnSettings { model: "parent-model".into(), comp_hash: None, @@ -3380,6 +3390,7 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc Some(codex_history::CompactionResumeMetadata { multi_agent_version: Some(MultiAgentVersion::V2), last_started_turn_id: None, + turn_attribution: None, previous_turn_settings: None, }) ); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 4000b257a1..7eb186016d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -474,16 +474,9 @@ impl CodexThread { trace, cyber_access_program, } = request; - let root_turn_id = self - .session - .reference_context_item() - .await - .filter(|context| context.turn_id.as_deref() == Some(turn_id.as_str())) - .and_then(|context| context.root_turn_id); let start_options = TurnStartOptions { cyber_access_program, - root_turn_id, - ..Default::default() + ..self.session.recovered_turn_start_options(&turn_id).await }; match self .io diff --git a/codex-rs/core/src/realtime_conversation/transcript_tail.rs b/codex-rs/core/src/realtime_conversation/transcript_tail.rs index 8bdb9ba22e..eb778e4a0b 100644 --- a/codex-rs/core/src/realtime_conversation/transcript_tail.rs +++ b/codex-rs/core/src/realtime_conversation/transcript_tail.rs @@ -37,6 +37,7 @@ pub(super) async fn record(session: &Session, text: String) -> std::io::Result<( let now = chrono::Utc::now(); if recording_turn { let started = EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: turn.sub_id.clone(), root_turn_id: Some(turn.sub_id.clone()), trace_id: turn.trace_id.clone(), @@ -47,7 +48,9 @@ pub(super) async fn record(session: &Session, text: String) -> std::io::Result<( session .persist_rollout_items(&[RolloutItem::EventMsg(started)]) .await; - session.record_started_turn(&turn.sub_id).await; + session + .record_started_turn(&turn.sub_id, /*attribution*/ None) + .await; } let item = session.response_item_from_user_input(content.clone()); session diff --git a/codex-rs/core/src/realtime_history_tests.rs b/codex-rs/core/src/realtime_history_tests.rs index fd163ec459..c6b0b977a6 100644 --- a/codex-rs/core/src/realtime_history_tests.rs +++ b/codex-rs/core/src/realtime_history_tests.rs @@ -28,6 +28,7 @@ fn started_state() -> RealtimeHistoryState { let mut state = RealtimeHistoryState::default(); state.observe(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: "turn-1".to_string(), root_turn_id: None, trace_id: None, @@ -129,6 +130,7 @@ fn interrupted_turn_is_not_associated_with_a_new_voice_session( let mut state = RealtimeHistoryState::default(); state.observe(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: "turn-1".to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/core/src/session/daemon_recovery.rs b/codex-rs/core/src/session/daemon_recovery.rs index 5fdefff870..bbf27dd367 100644 --- a/codex-rs/core/src/session/daemon_recovery.rs +++ b/codex-rs/core/src/session/daemon_recovery.rs @@ -1,5 +1,5 @@ -//! Captures recorded, uncanceled regular turns using one thread-configured local executor. -//! Callers must flush the rollout after capture before persisting the snapshot. +//! Captures interrupted turns for daemon recovery and restores persisted turn attribution. +//! Callers must flush the rollout after capturing a live turn before persisting its snapshot. use super::Session; use codex_protocol::protocol::EnvironmentConfigState; @@ -9,6 +9,16 @@ use codex_protocol::protocol::TurnEnvironmentSelection; pub(super) struct RecordedTurnInput; impl Session { + pub(crate) async fn recovered_turn_start_options( + &self, + turn_id: &str, + ) -> crate::TurnStartOptions { + self.state + .lock() + .await + .recovered_turn_start_options(turn_id) + } + /// Captures a regular turn only after its input is recorded. The caller must flush the rollout. pub(crate) async fn interrupted_turn( &self, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 427dfdcbd6..7f0bd90d16 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -318,6 +318,7 @@ use crate::state::PendingRequestPermissions; use crate::state::ReasoningEffortPin; use crate::state::SessionServices; use crate::state::SessionState; +use crate::state::TaskKind; #[cfg(test)] use crate::stream_events_utils::HandleOutputCtx; #[cfg(test)] @@ -1754,6 +1755,7 @@ impl Session { last_started_turn_id, previous_turn_settings, reference_context_item, + turn_attribution, world_state_baseline, window_number, first_window_id, @@ -1809,6 +1811,7 @@ impl Session { reviewer_compaction_hash.as_deref(), ); state.last_started_turn_id = last_started_turn_id; + state.turn_attribution = turn_attribution; if let Some(world_state) = world_state_baseline { state.history.set_world_state_baseline(world_state); } @@ -2259,8 +2262,9 @@ impl Session { .trusted_guardian_reviewer } - pub(crate) async fn emit_turn_started(&self, turn_context: &TurnContext) { + pub(crate) async fn emit_turn_started(&self, turn_context: &TurnContext, task_kind: TaskKind) { 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 @@ -4082,6 +4086,7 @@ impl Session { resume_metadata: Some(CompactionResumeMetadata { multi_agent_version: self.multi_agent_version(), last_started_turn_id: state.last_started_turn_id.clone(), + turn_attribution: state.turn_attribution.clone(), previous_turn_settings: state.previous_turn_settings(), }), } diff --git a/codex-rs/core/src/session/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs index ab20e5b30e..4ca5ce1498 100644 --- a/codex-rs/core/src/session/rollout_reconstruction.rs +++ b/codex-rs/core/src/session/rollout_reconstruction.rs @@ -2,6 +2,7 @@ use super::*; use crate::context::world_state::WorldStateSnapshot; use crate::context_manager::is_user_turn_boundary; use codex_history::ResponseItemEnvelope; +use codex_history::TurnAttribution; use codex_protocol::protocol::SessionContextWindow; use codex_protocol::protocol::ThreadHistoryMode; use uuid::Uuid; @@ -16,6 +17,7 @@ pub(super) struct RolloutReconstruction { pub(super) last_started_turn_id: Option, pub(super) previous_turn_settings: Option, pub(super) reference_context_item: Option, + pub(super) turn_attribution: Option, pub(super) world_state_baseline: Option, pub(super) window_number: u64, pub(super) first_window_id: Option, @@ -94,11 +96,25 @@ struct ActiveReplaySegment<'a> { counts_as_user_turn: bool, previous_turn_settings: Option, reference_context_item: TurnReferenceContextItem, + turn_attribution: Option, world_state_replay: Vec<&'a RolloutItem>, history_checkpoint: Option>, window: Option, } +impl ActiveReplaySegment<'_> { + fn take_checkpoint_attribution(&mut self, checkpoint: &mut Option) { + if checkpoint.as_ref().is_some_and(|attribution| { + self.turn_id + .as_ref() + .is_none_or(|id| id == &attribution.turn_id) + }) { + // Consume a matching checkpoint even when rollback discards this segment. + self.turn_attribution = self.turn_attribution.take().or(checkpoint.take()); + } + } +} + fn turn_ids_are_compatible(active_turn_id: Option<&str>, item_turn_id: Option<&str>) -> bool { active_turn_id .is_none_or(|turn_id| item_turn_id.is_none_or(|item_turn_id| item_turn_id == turn_id)) @@ -112,7 +128,7 @@ fn finalize_active_segment<'a>( world_state_replay: &mut Vec<&'a RolloutItem>, window: &mut Option, pending_rollback_turns: &mut usize, -) { +) -> Option { // Thread rollback drops the newest surviving real user-message boundaries. In replay, that // means skipping the next finalized segments that contain a non-contextual // `EventMsg::UserMessage`. @@ -120,7 +136,7 @@ fn finalize_active_segment<'a>( if active_segment.counts_as_user_turn { *pending_rollback_turns -= 1; } - return; + return None; } // Full world-state snapshots are persisted after installing initial context. They still @@ -163,6 +179,9 @@ fn finalize_active_segment<'a>( { *reference_context_item = active_segment.reference_context_item; } + // Automatic turns can persist attribution without establishing a model-context baseline. + // Select recovery metadata from the same surviving segments, independently of that baseline. + active_segment.turn_attribution } impl Session { @@ -197,11 +216,11 @@ impl Session { .and_then(|checkpoint| reconstructed_window_from_compaction(checkpoint.compacted)); // Scan the selected items backward to find the newest surviving turn state. - let last_started_turn_id = replay_items + let mut started_turn_ids = replay_items .iter() .rev() - .find_map(|item| match item { - RolloutItem::EventMsg(EventMsg::TurnStarted(event)) => Some(event.turn_id.clone()), + .filter_map(|item| match item { + RolloutItem::EventMsg(EventMsg::TurnStarted(event)) => Some(event.turn_id.as_str()), RolloutItem::SessionMeta(_) | RolloutItem::ResponseItem(_) | RolloutItem::InterAgentCommunication(_) @@ -215,19 +234,37 @@ impl Session { | RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) => None, }) + .peekable(); + let last_started_turn_id = started_turn_ids + .peek() + .map(|id| (*id).to_owned()) .or_else(|| resume_metadata.and_then(|metadata| metadata.last_started_turn_id.clone())); + // Saved attribution still identifies the turn when continuation eligibility was cleared. + let checkpoint_turn_id = resume_metadata.and_then(|metadata| { + metadata.last_started_turn_id.as_deref().or_else(|| { + metadata + .turn_attribution + .as_ref() + .map(|attribution| attribution.turn_id.as_str()) + }) + }); let mut previous_turn_settings = None; let mut reference_context_item = TurnReferenceContextItem::NeverSet; + let mut turn_attribution = None; let mut world_state_replay = Vec::new(); // Rollback is "drop the newest N user turns". While scanning in reverse, that becomes // "skip the next N user-turn segments we finalize". let mut pending_rollback_turns = 0usize; // Reverse replay accumulates rollout items into the newest in-progress turn segment until // we hit its matching `TurnStarted`, at which point the segment can be finalized. + let mut checkpoint_attribution = + resume_metadata.and_then(|metadata| metadata.turn_attribution.clone()); + let mut late_completed_turn_ids = HashSet::new(); let mut active_segment: Option> = None; for (index, item) in replay_items.iter().enumerate().rev() { + let preceding_turn_id = started_turn_ids.peek().copied().or(checkpoint_turn_id); match item { RolloutItem::Compacted(compacted) => { let active_segment = @@ -261,6 +298,11 @@ impl Session { .saturating_add(usize::try_from(rollback.num_turns).unwrap_or(usize::MAX)); } RolloutItem::EventMsg(EventMsg::TurnComplete(event)) => { + // Stop hooks can finish after a newer turn has started. + if !turn_ids_are_compatible(preceding_turn_id, Some(event.turn_id.as_str())) { + late_completed_turn_ids.insert(event.turn_id.as_str()); + continue; + } let active_segment = active_segment.get_or_insert_with(ActiveReplaySegment::default); active_segment.turn_completed = true; @@ -271,6 +313,9 @@ impl Session { } } RolloutItem::EventMsg(EventMsg::TurnAborted(event)) => { + if !turn_ids_are_compatible(preceding_turn_id, event.turn_id.as_deref()) { + continue; + } if let Some(active_segment) = active_segment.as_mut() { if active_segment.turn_id.is_none() && let Some(turn_id) = &event.turn_id @@ -322,15 +367,34 @@ impl Session { active_segment.world_state_replay.push(item); } RolloutItem::EventMsg(EventMsg::TurnStarted(event)) => { + started_turn_ids.next(); + // Startup can be suspended before any input or context is written. + // Explicit start attribution is authoritative, including unknown fields. + if let Some(attribution) = &event.turn_attribution + && attribution.turn_id == event.turn_id + { + let segment = + active_segment.get_or_insert_with(ActiveReplaySegment::default); + if turn_ids_are_compatible( + segment.turn_id.as_deref(), + Some(event.turn_id.as_str()), + ) { + segment.turn_attribution = Some(attribution.clone()); + } + } // `TurnStarted` is the oldest boundary of the active reverse segment. if active_segment.as_ref().is_some_and(|active_segment| { turn_ids_are_compatible( active_segment.turn_id.as_deref(), Some(event.turn_id.as_str()), ) - }) && let Some(active_segment) = active_segment.take() + }) && let Some(mut active_segment) = active_segment.take() { - finalize_active_segment( + active_segment + .turn_id + .get_or_insert_with(|| event.turn_id.clone()); + active_segment.take_checkpoint_attribution(&mut checkpoint_attribution); + let segment_context = finalize_active_segment( active_segment, &mut history_checkpoint, &mut previous_turn_settings, @@ -339,6 +403,7 @@ impl Session { &mut window, &mut pending_rollback_turns, ); + turn_attribution = turn_attribution.or(segment_context); } } RolloutItem::ResponseItem(response_item) => { @@ -363,12 +428,18 @@ impl Session { } if let Some(mut active_segment) = active_segment.take() { + active_segment.take_checkpoint_attribution(&mut checkpoint_attribution); + active_segment.turn_completed |= active_segment + .turn_id + .as_deref() + .or(checkpoint_turn_id) + .is_some_and(|turn_id| late_completed_turn_ids.contains(turn_id)); // A companion turn context only restores the context baseline. Once that turn // completes, its settings are newer than the compaction metadata. if resume_metadata.is_some() && !active_segment.turn_completed { active_segment.previous_turn_settings = None; } - finalize_active_segment( + let segment_context = finalize_active_segment( active_segment, &mut history_checkpoint, &mut previous_turn_settings, @@ -377,6 +448,11 @@ impl Session { &mut window, &mut pending_rollback_turns, ); + turn_attribution = turn_attribution.or(segment_context); + } + + if pending_rollback_turns == 0 { + turn_attribution = turn_attribution.or(checkpoint_attribution); } if previous_turn_settings.is_none() { @@ -527,6 +603,7 @@ impl Session { history: history.into_annotated_items(), previous_turn_settings, reference_context_item, + turn_attribution, world_state_baseline, window_number: window.number, first_window_id: window.first_id, diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 7b280be120..ddf567e159 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -260,6 +260,7 @@ fn completed_user_turn_rollout( let mut rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.clone(), root_turn_id: None, trace_id: None, @@ -524,6 +525,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.clone(), root_turn_id: None, trace_id: None, @@ -581,14 +583,22 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com let (session, turn_context) = make_session_and_context().await; let mut first_context_item = turn_context.to_turn_context_item(); first_context_item.cyber_access_program = Some(CyberAccessProgram::DaybreakBlue); + first_context_item.root_turn_id = Some("root-turn".to_owned()); let first_turn_id = first_context_item .turn_id .clone() .expect("turn context should have turn_id"); + let first_attribution: codex_history::TurnAttribution = object!({ + "turn_id": first_turn_id, + "turn_trigger": "composer", + "parent_turn_id": "parent-turn", + "root_turn_id": "root-turn" + }); let mut rolled_back_context_item = first_context_item.clone(); rolled_back_context_item.turn_id = Some("rolled-back-turn".to_string()); rolled_back_context_item.model = "rolled-back-model".to_string(); rolled_back_context_item.cyber_access_program = Some(CyberAccessProgram::DaybreakRed); + rolled_back_context_item.root_turn_id = Some("rolled-back-root".to_owned()); let rolled_back_turn_id = rolled_back_context_item .turn_id .clone() @@ -601,6 +611,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: Some(first_attribution.clone()), turn_id: first_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -638,6 +649,12 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: Some(object!({ + "turn_id": rolled_back_turn_id, + "turn_trigger": "automation", + "parent_turn_id": "rolled-back-parent", + "root_turn_id": "rolled-back-root" + })), turn_id: rolled_back_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -696,6 +713,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com "cyber_access_program": "daybreak_blue", }) ); + assert_eq!(reconstructed.turn_attribution, Some(first_attribution)); assert_eq!( serde_json::to_value(reconstructed.reference_context_item) .expect("serialize reconstructed reference context item"), @@ -726,6 +744,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: first_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -760,6 +779,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: incomplete_turn_id, root_turn_id: None, trace_id: None, @@ -829,6 +849,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: first_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -863,6 +884,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: second_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -896,6 +918,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: standalone_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -970,6 +993,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: first_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -1004,6 +1028,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: assistant_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -1070,6 +1095,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: only_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -1114,6 +1140,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding assert_eq!(reconstructed.history, Vec::new()); assert_eq!(reconstructed.previous_turn_settings, None); assert!(reconstructed.reference_context_item.is_none()); + assert!(reconstructed.turn_attribution.is_none()); } #[tokio::test] @@ -1128,6 +1155,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: user_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -1161,6 +1189,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: standalone_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -1211,6 +1240,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: previous_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -1243,6 +1273,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: incomplete_turn_id, root_turn_id: None, trace_id: None, @@ -1581,6 +1612,7 @@ async fn bounded_replay_matches_full_replay_after_empty_turn_compactions( resume_metadata: current.then(|| codex_history::CompactionResumeMetadata { multi_agent_version: None, last_started_turn_id: Some(format!("wake-{window_number}")), + turn_attribution: None, previous_turn_settings: Some(PreviousTurnSettings { model: format!("metadata-model-{window_number}"), comp_hash: Some(format!("metadata-hash-{window_number}")), @@ -1687,17 +1719,147 @@ async fn paginated_compaction_does_not_restore_missing_companions_from_older_his assert_eq!(reconstructed.previous_turn_settings, None); assert_eq!(reconstructed.reference_context_item, None); + assert_eq!(reconstructed.turn_attribution, None); assert_eq!(reconstructed.world_state_baseline, None); } } +#[derive(Clone, Copy)] +enum AttributionEvidence { + StartAndContext, + StartOnly, +} + +#[test_case(AttributionEvidence::StartAndContext; "late completion")] +#[test_case(AttributionEvidence::StartOnly; "late abort before current context")] #[tokio::test] -async fn completed_turn_suffix_after_compaction_overrides_resume_metadata() { +async fn reconstruct_attribution_ignores_previous_turn_terminal(evidence: AttributionEvidence) { + let (session, turn_context) = make_session_and_context().await; + let older_attribution = turn_context.attribution(); + let expected: codex_history::TurnAttribution = object!({ + "turn_id": "current-turn", + "turn_trigger": "automation", + "parent_turn_id": "calling-turn", + "root_turn_id": "root-turn", + "initiating_agent_path": "/root/requester" + }); + let mut current_context = turn_context.to_turn_context_item(); + current_context.turn_id = Some(expected.turn_id.clone()); + current_context.root_turn_id = expected.root_turn_id.clone(); + let mut rollout = completed_user_turn_rollout(turn_context.to_turn_context_item(), Vec::new()); + let RolloutItem::EventMsg(EventMsg::TurnStarted(start)) = &mut rollout[0] else { + panic!("expected previous turn start"); + }; + start.turn_attribution = Some(older_attribution.clone()); + let mut late_terminal = rollout.pop().expect("previous turn completion"); + let mut current = completed_user_turn_rollout(current_context, Vec::new()); + current.pop().expect("current turn completion"); + let RolloutItem::EventMsg(EventMsg::TurnStarted(start)) = &mut current[0] else { + panic!("expected current turn start"); + }; + start.turn_attribution = Some(expected.clone()); + if matches!(evidence, AttributionEvidence::StartOnly) { + current.truncate(1); + late_terminal = RolloutItem::EventMsg(EventMsg::TurnAborted(object!({ + "turn_id": older_attribution.turn_id, + "reason": "interrupted" + }))); + } + rollout.extend(current); + rollout.push(late_terminal); + + let reconstructed = session + .reconstruct_history_from_rollout(&turn_context, &rollout) + .await; + assert_eq!(reconstructed.turn_attribution, Some(expected)); + + if matches!(evidence, AttributionEvidence::StartAndContext) { + rollout.push(RolloutItem::EventMsg(EventMsg::ThreadRolledBack( + ThreadRolledBackEvent { num_turns: 1 }, + ))); + let rolled_back = session + .reconstruct_history_from_rollout(&turn_context, &rollout) + .await; + assert_eq!(rolled_back.turn_attribution, Some(older_attribution)); + } +} + +#[test_case(Some("current-turn"); "continuation marker retained")] +#[test_case(None; "continuation marker cleared")] +#[tokio::test] +async fn reconstruct_attribution_ignores_previous_terminal_after_checkpoint( + last_started_turn_id: Option<&str>, +) { + let (session, turn_context) = make_session_and_context().await; + let expected: codex_history::TurnAttribution = object!({ + "turn_id": "current-turn", + "turn_trigger": "automation", + "parent_turn_id": "calling-turn", + "root_turn_id": "root-turn" + }); + let mut context = turn_context.to_turn_context_item(); + context.turn_id = Some(expected.turn_id.clone()); + context.root_turn_id = expected.root_turn_id.clone(); + let settings: PreviousTurnSettings = object!({"model": "checkpoint-model"}); + let late_terminal = + completed_user_turn_rollout(turn_context.to_turn_context_item(), Vec::new()) + .pop() + .expect("previous turn completion"); + let mut rollout = vec![ + RolloutItem::Compacted(object!({ + "message": "summary", + "replacement_history": [user_message("original work")], + "window_number": 1, + "resume_metadata": { + "last_started_turn_id": last_started_turn_id, + "turn_attribution": expected, + "previous_turn_settings": settings + } + })), + RolloutItem::ResponseItem(user_message("continued work").into()), + RolloutItem::TurnContext(context.clone()), + late_terminal, + ]; + let reconstructed = session + .reconstruct_history_from_rollout(&turn_context, &rollout) + .await; + assert_eq!(reconstructed.turn_attribution, Some(expected)); + assert_eq!(reconstructed.reference_context_item, Some(context)); + assert_eq!(reconstructed.previous_turn_settings, Some(settings)); + assert_eq!( + reconstructed.last_started_turn_id.as_deref(), + last_started_turn_id + ); + + rollout.push(RolloutItem::EventMsg(EventMsg::ThreadRolledBack( + ThreadRolledBackEvent { num_turns: 1 }, + ))); + let rolled_back = session + .reconstruct_history_from_rollout(&turn_context, &rollout) + .await; + assert_eq!(rolled_back.turn_attribution, None); +} + +#[test_case(false, false; "completion before next turn")] +#[test_case(true, false; "completion after next turn starts")] +#[test_case(true, true; "late completion without checkpoint turn identity")] +#[tokio::test] +async fn completed_turn_suffix_after_compaction_overrides_resume_metadata( + late_completion: bool, + legacy_checkpoint: bool, +) { let (session, turn_context) = make_session_and_context().await; let mut newer_context = turn_context.to_turn_context_item(); newer_context.turn_id = Some("newer-turn".to_string()); newer_context.model = "newer-model".to_string(); newer_context.comp_hash = Some("newer-hash".to_string()); + let checkpoint_attribution = (!legacy_checkpoint).then(|| codex_history::TurnAttribution { + turn_id: "newer-turn".to_string(), + turn_trigger: Some("automation".to_string()), + parent_turn_id: None, + initiating_agent_path: None, + root_turn_id: None, + }); let expected_settings = PreviousTurnSettings { model: newer_context.model.clone(), comp_hash: newer_context.comp_hash.clone(), @@ -1710,6 +1872,8 @@ async fn completed_turn_suffix_after_compaction_overrides_resume_metadata() { "replacement_history": [user_message("seed"), assistant_message("summary")], "window_number": 1, "resume_metadata": { + "last_started_turn_id": checkpoint_attribution.as_ref().map(|value| value.turn_id.as_str()), + "turn_attribution": checkpoint_attribution, "previous_turn_settings": { "model": "metadata-model", "comp_hash": "metadata-hash" @@ -1725,6 +1889,17 @@ async fn completed_turn_suffix_after_compaction_overrides_resume_metadata() { .into_iter() .skip(2), ); + let next_attribution: Option = + late_completion.then(|| object!({"turn_id": "next-turn", "turn_trigger": "user"})); + if let Some(attribution) = &next_attribution { + rollout_items.insert( + rollout_items.len() - 1, + RolloutItem::EventMsg(EventMsg::TurnStarted(object!({ + "turn_id": attribution.turn_id, + "turn_attribution": attribution + }))), + ); + } let reconstructed = session .reconstruct_history_from_rollout(&turn_context, &rollout_items) @@ -1734,7 +1909,35 @@ async fn completed_turn_suffix_after_compaction_overrides_resume_metadata() { reconstructed.previous_turn_settings, Some(expected_settings) ); - assert_eq!(reconstructed.reference_context_item, Some(newer_context)); + assert_eq!( + reconstructed.turn_attribution, + next_attribution.or(checkpoint_attribution) + ); + assert_eq!( + reconstructed.reference_context_item, + Some(newer_context.clone()) + ); + + // A rollback of a newer user turn restores the checkpoint's older regular turn. + let RolloutItem::Compacted(checkpoint) = &mut rollout_items[0] else { + panic!("expected checkpoint"); + }; + let metadata = checkpoint + .resume_metadata + .as_mut() + .expect("resume metadata"); + let older_attribution: codex_history::TurnAttribution = + object!({"turn_id": "older-turn", "turn_trigger": "automation"}); + metadata.turn_attribution = Some(older_attribution.clone()); + rollout_items.truncate(1); + rollout_items.extend(completed_user_turn_rollout(newer_context, Vec::new())); + rollout_items.push(RolloutItem::EventMsg(EventMsg::ThreadRolledBack( + codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 }, + ))); + let rolled_back = session + .reconstruct_history_from_rollout(&turn_context, &rollout_items) + .await; + assert_eq!(rolled_back.turn_attribution, Some(older_attribution)); } #[tokio::test] @@ -1870,6 +2073,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear }), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: current_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -1948,6 +2152,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: previous_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -2088,6 +2293,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: previous_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -2120,6 +2326,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: aborted_turn_id, root_turn_id: None, trace_id: None, @@ -2228,6 +2435,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: previous_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -2260,6 +2468,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: current_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -2369,6 +2578,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: previous_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -2401,6 +2611,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: incomplete_turn_id, root_turn_id: None, trace_id: None, @@ -2468,6 +2679,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: current_turn_id, root_turn_id: None, trace_id: None, @@ -2557,6 +2769,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: previous_turn_id.clone(), root_turn_id: None, trace_id: None, @@ -2589,6 +2802,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: compacted_incomplete_turn_id, root_turn_id: None, trace_id: None, @@ -2625,6 +2839,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear // completion/abort for the old one. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: replacing_turn_id, root_turn_id: None, trace_id: None, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a9bcb0477d..81e5a2d050 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4338,6 +4338,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.clone(), root_turn_id: None, trace_id: None, @@ -6134,6 +6135,18 @@ async fn compaction_persists_resume_metadata_and_companion_records() { let (mut session, turn_context) = make_session_and_context().await; let rollout_path = attach_thread_persistence(&mut session).await; let turn_context = Arc::new(turn_context); + turn_context + .turn_metadata_state + .set_turn_trigger("automation".to_string()); + turn_context + .turn_metadata_state + .set_parent_turn_id("parent-turn".to_string()); + turn_context + .turn_metadata_state + .set_root_turn_id("root-turn".to_string()); + turn_context.turn_metadata_state.set_initiating_agent_path( + codex_protocol::AgentPath::try_from("/root/requester").expect("requester path"), + ); let turn_context_baseline = turn_context.to_turn_context_item(); let world_state = Arc::new(build_world_state_from_turn_context(&session, &turn_context).await); let previous_turn_settings = PreviousTurnSettings { @@ -6146,11 +6159,19 @@ async fn compaction_persists_resume_metadata_and_companion_records() { .set_previous_turn_settings(Some(previous_turn_settings.clone())) .await; - session.state.lock().await.last_started_turn_id = Some("checkpoint-turn".into()); session.multi_agent_version = std::sync::OnceLock::from(MultiAgentVersion::V2); let expected = CompactionResumeMetadata { multi_agent_version: Some(MultiAgentVersion::V2), - last_started_turn_id: Some("checkpoint-turn".into()), + last_started_turn_id: Some(turn_context.sub_id.clone()), + turn_attribution: Some(codex_history::TurnAttribution { + turn_id: turn_context.sub_id.clone(), + turn_trigger: Some("automation".to_string()), + parent_turn_id: Some("parent-turn".to_string()), + initiating_agent_path: Some( + codex_protocol::AgentPath::try_from("/root/requester").expect("requester path"), + ), + root_turn_id: turn_context_baseline.root_turn_id.clone(), + }), previous_turn_settings: Some(previous_turn_settings), }; let expected_settings = codex_protocol::protocol::ThreadSettingsAppliedEvent { @@ -6158,6 +6179,21 @@ async fn compaction_persists_resume_metadata_and_companion_records() { thread_settings: session.thread_settings_snapshot().await, }; + session + .record_started_turn(&turn_context.sub_id, Some(turn_context.attribution())) + .await; + let compact_context = session.new_default_turn().await; + session + .record_started_turn(&compact_context.sub_id, /*attribution*/ None) + .await; + assert_eq!( + session.state.lock().await.turn_attribution, + expected.turn_attribution + ); + + session + .record_started_turn(&turn_context.sub_id, Some(turn_context.attribution())) + .await; let input_goal_ids = crate::context::UserGoalUpdate::message_ids(session.clone_history().await.raw_items()); // The goal edit is accepted after the compaction input was captured. @@ -6217,6 +6253,58 @@ async fn compaction_persists_resume_metadata_and_companion_records() { ); assert_eq!(saved_turn_context, &turn_context_baseline); assert_eq!(saved_settings, &expected_settings); + for same_turn_input in [false, true] { + let mut rollback_items = vec![RolloutItem::Compacted(compacted.clone())]; + if same_turn_input { + rollback_items.push(RolloutItem::ResponseItem( + user_message("steered work").into(), + )); + } + rollback_items.push(RolloutItem::EventMsg(EventMsg::ThreadRolledBack( + codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 }, + ))); + let rolled_back = session + .reconstruct_history_from_rollout(&turn_context, &rollback_items) + .await; + assert_eq!(rolled_back.turn_attribution, None); + } + + // Settings invalidate continuation eligibility; standalone compaction replaces its ID. + // Neither changes the regular work whose attribution the checkpoint preserves. + for last_started_turn_id in [None, Some("compact-turn".to_string())] { + let mut checkpoint = compacted.clone(); + checkpoint + .resume_metadata + .as_mut() + .expect("resume metadata") + .last_started_turn_id = last_started_turn_id.clone(); + let mut items = vec![RolloutItem::Compacted(checkpoint)]; + if let Some(turn_id) = last_started_turn_id { + items.push(RolloutItem::EventMsg(EventMsg::TurnComplete( + TurnCompleteEvent { + turn_id, + last_agent_message: None, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }, + ))); + } + let reconstructed = session + .reconstruct_history_from_rollout(&turn_context, &items) + .await; + assert_eq!(reconstructed.turn_attribution, expected.turn_attribution); + assert_eq!(reconstructed.reference_context_item, None); + items.push(RolloutItem::EventMsg(EventMsg::ThreadRolledBack( + codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 }, + ))); + let rolled_back = session + .reconstruct_history_from_rollout(&turn_context, &items) + .await; + assert_eq!(rolled_back.turn_attribution, None); + } } #[tokio::test] diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index d58b7cd888..0c85aa0d7d 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -523,6 +523,16 @@ impl TurnContext { .strict_auto_review_enabled } + pub(crate) fn attribution(&self) -> codex_history::TurnAttribution { + codex_history::TurnAttribution { + turn_id: self.sub_id.clone(), + turn_trigger: self.turn_metadata_state.current_turn_trigger(), + parent_turn_id: self.turn_metadata_state.parent_turn_id(), + initiating_agent_path: self.turn_metadata_state.initiating_agent_path().cloned(), + root_turn_id: self.turn_metadata_state.root_turn_id(), + } + } + /// Captures current model metadata without preparing a step. pub(crate) fn capture_current_model_info(&self) -> Arc { Arc::clone(&self.next_step_settings.load().model_info) diff --git a/codex-rs/core/src/session/turn_input.rs b/codex-rs/core/src/session/turn_input.rs index f6e5c4895d..c1ce885c7c 100644 --- a/codex-rs/core/src/session/turn_input.rs +++ b/codex-rs/core/src/session/turn_input.rs @@ -138,6 +138,7 @@ impl PreparedTurnInputSettings { final_output_json_schema, service_tier, parent_turn_id, + initiating_agent_path, root_turn_id, cyber_access_program, } = self.start_options; @@ -183,6 +184,11 @@ impl PreparedTurnInputSettings { thread_settings::emit_applied(session, submission_id, settings_snapshot).await; } if let Some(parent_turn_id) = parent_turn_id { + if let Some(initiating_agent_path) = initiating_agent_path { + turn_context + .turn_metadata_state + .set_initiating_agent_path(initiating_agent_path); + } turn_context .turn_metadata_state .set_parent_turn_id(parent_turn_id); @@ -288,10 +294,7 @@ pub(super) async fn handle_recovery( } = thread_settings.into(); let request = TurnInputRequest::user_input(Vec::new()) .with_thread_settings(thread_settings) - .on_start(TurnStartOptions { - turn_trigger: Some("retry".to_string()), - ..start_options - }); + .on_start(start_options); let result = start_if_idle( session, WithTurnExtensionData { @@ -633,8 +636,16 @@ async fn steer( impl Session { /// Called under the active-turn lock before running any task or lifecycle callback. - pub(crate) async fn record_started_turn(&self, turn_id: &str) { - self.state.lock().await.last_started_turn_id = Some(turn_id.to_string()); + pub(crate) async fn record_started_turn( + &self, + turn_id: &str, + attribution: Option, + ) { + let mut state = self.state.lock().await; + state.last_started_turn_id = Some(turn_id.to_owned()); + if let Some(attribution) = attribution { + state.turn_attribution = Some(attribution); + } } pub(crate) async fn route_realtime_text_input( diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 6a93e160a4..53857e1cc2 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -11,6 +11,7 @@ use super::AdditionalContextStore; use super::auto_compact_window::AutoCompactWindow; use super::auto_compact_window::AutoCompactWindowIds; use super::auto_compact_window::AutoCompactWindowSnapshot; +use crate::TurnStartOptions; use crate::context_manager::ContextManager; use crate::context_manager::HistoryReplacement; use crate::session::PreviousTurnSettings; @@ -18,6 +19,7 @@ use crate::session::session::SessionConfiguration; use crate::session::startup_prewarm::SessionStartupPrewarmHandle; use crate::session::time_reminder::CurrentTimeReminderState; use codex_history::ResponseItemEnvelope; +use codex_history::TurnAttribution; use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::protocol::RateLimitSnapshot; @@ -84,6 +86,8 @@ pub(crate) struct SessionState { /// Latest task admitted in this runtime, retained across completion and history edits. /// Cleared by standalone settings changes to invalidate pending continuation. pub(crate) last_started_turn_id: Option, + /// Latest regular turn, retained across completion, settings changes, and compaction. + pub(crate) turn_attribution: Option, /// Runtime accounting state for the active auto-compaction window. auto_compact_window: AutoCompactWindow, /// Original request effort for the current model while configuration updates remain active. @@ -129,6 +133,7 @@ impl SessionState { additional_context: AdditionalContextStore::default(), previous_turn_settings: None, last_started_turn_id: None, + turn_attribution: None, auto_compact_window: AutoCompactWindow::new_with_ids(auto_compact_window_ids), reasoning_effort_pin: ReasoningEffortPin::Unset, shutting_down: false, @@ -191,6 +196,7 @@ impl SessionState { .history .replace_compacted(items, reviewer_compaction_hash.as_deref()), HistoryReplacement::Reset => { + self.turn_attribution = None; self.history.replace_annotated(items); true } @@ -253,6 +259,21 @@ impl SessionState { self.history.reference_context_item() } + pub(crate) fn recovered_turn_start_options(&self, turn_id: &str) -> TurnStartOptions { + self.turn_attribution + .as_ref() + .filter(|attribution| attribution.turn_id == turn_id) + .map(TurnAttribution::start_options) + .unwrap_or_else(|| TurnStartOptions { + // Older rollouts only persisted the root on the model-context record. + root_turn_id: self + .reference_context_item() + .filter(|context| context.turn_id.as_deref() == Some(turn_id)) + .and_then(|context| context.root_turn_id), + ..Default::default() + }) + } + // Token/rate limit helpers pub(crate) fn update_token_info_from_usage( &mut self, diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index 62808c9e60..823b064fee 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -41,7 +41,7 @@ impl SessionTask for CompactTask { turn.id = %ctx.sub_id, ); // Preparation errors must reach the task runner, which reports them to the client. - session.emit_turn_started(&ctx).await; + session.emit_turn_started(&ctx, TaskKind::Compact).await; let step_context = session .capture_step_context(Arc::clone(&ctx), &cancellation_token) .await?; diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5ace22c811..fc9272e911 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -315,7 +315,11 @@ impl Session { let (pending_items, _) = self.input_queue.drain_mailbox_input_items().await; let turn_state = { let mut active = self.active_turn.lock().await; - self.record_started_turn(&turn_context.sub_id).await; + self.record_started_turn( + &turn_context.sub_id, + (task_kind == TaskKind::Regular).then(|| turn_context.attribution()), + ) + .await; let turn = active.get_or_insert_with(ActiveTurn::default); debug_assert!(turn.task.is_none()); Arc::clone(&turn.turn_state) diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index bb8f00dc52..0eca40f3d4 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -48,7 +48,7 @@ impl SessionTask for RegularTask { // Regular turns emit `TurnStarted` inline so first-turn lifecycle does // not wait on startup prewarm resolution. let prewarmed_client_session = async { - sess.emit_turn_started(&ctx).await; + sess.emit_turn_started(&ctx, TaskKind::Regular).await; // Regular-start contributors run once, after the task is visible and interruptible. let prepares_mcp = sess .services diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index d4c76dc11c..f3915ed5c7 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -121,7 +121,9 @@ pub(crate) async fn execute_user_shell_command( // standalone lifecycle tasks (for example /shell, and review once it emits TurnStarted). // `/compact` is an intentional exception because compaction requests should not include // freshly reinjected context before the summary/replacement history is applied. - session.emit_turn_started(&turn_context).await; + session + .emit_turn_started(&turn_context, TaskKind::Regular) + .await; } let Some((turn_environment, environment_shell)) = turn_context diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 8a19d054e3..13e0b069bc 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -799,6 +799,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(user_msg("u1").into()), RolloutItem::ResponseItem(assistant_msg("a1").into()), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: "turn-2".to_string(), root_turn_id: None, trace_id: None, @@ -3125,6 +3126,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { history_mode: Some(ThreadHistoryMode::Legacy), initial_history: InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: "turn-explicit".to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index 9625f74677..1495a9dfe5 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -43,6 +43,7 @@ fn assistant_msg(text: &str) -> ResponseItem { fn turn_started(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index fd53c8cfe2..14a443f834 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -62,6 +62,7 @@ fn resume_history( conversation_id: ThreadId::default(), history: Arc::new(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.clone(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/core/tests/suite/turn_input_submission.rs b/codex-rs/core/tests/suite/turn_input_submission.rs index 2376325027..4686dba102 100644 --- a/codex-rs/core/tests/suite/turn_input_submission.rs +++ b/codex-rs/core/tests/suite/turn_input_submission.rs @@ -28,6 +28,7 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnEnvironmentSelections; +use codex_protocol::turn_input::TurnAttribution; use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_completed; @@ -663,13 +664,318 @@ async fn recover_turn_if_idle_preserves_id_and_resumes_plan_mode() { .expect("recovered turn should include turn metadata"), ) .expect("recovered turn metadata should be valid JSON"); - assert_eq!(turn_metadata["turn_trigger"].as_str(), Some("retry")); + assert_eq!( + ( + turn_metadata.get("turn_trigger"), + turn_metadata.get("parent_turn_id"), + turn_metadata["root_turn_id"].as_str(), + ), + (None, None, Some(turn_id)) + ); let user_input_groups = request.message_input_text_groups("user"); assert_eq!(user_input_groups.len(), 1); assert_eq!(user_input_groups[0].len(), 1); assert!(user_input_groups[0][0].starts_with("")); } +#[derive(Default)] +struct RecoveryLifecycleBarrier { + block_next: AtomicBool, + entered: tokio::sync::Notify, + block_stop: AtomicBool, + stop_entered: tokio::sync::Notify, + release_stop: tokio::sync::Notify, +} + +impl codex_extension_api::TurnLifecycleContributor for RecoveryLifecycleBarrier { + fn turn_start_phase( + &self, + _thread_store: &codex_extension_api::ExtensionData, + ) -> codex_extension_api::TurnStartPhase { + codex_extension_api::TurnStartPhase::RegularTaskStart + } + + fn on_turn_start<'a>( + &'a self, + _input: codex_extension_api::TurnStartInput<'a>, + ) -> codex_extension_api::ExtensionFuture<'a, ()> { + Box::pin(async move { + if self.block_next.swap(false, Ordering::SeqCst) { + self.entered.notify_one(); + std::future::pending::<()>().await; + } + }) + } + + fn on_turn_stop<'a>( + &'a self, + _input: codex_extension_api::TurnStopInput<'a>, + ) -> codex_extension_api::ExtensionFuture<'a, ()> { + Box::pin(async move { + if self.block_stop.swap(false, Ordering::SeqCst) { + self.stop_entered.notify_one(); + self.release_stop.notified().await; + } + }) + } +} + +enum RecoveryPause { + AfterContext, + BeforeContext, + AfterLateCompletion, + LegacyRollout, +} + +/// Cold recovery preserves attribution across repeated restarts, leaves unknown +/// triggers absent, and starts new turns with fresh attribution. +#[test_case(Some("automation"), RecoveryPause::AfterContext; "automation")] +#[test_case(None, RecoveryPause::AfterContext; "unknown_trigger")] +#[test_case(None, RecoveryPause::LegacyRollout; "legacy_root")] +#[test_case(Some("automation"), RecoveryPause::BeforeContext; "before_context_is_written")] +#[test_case(Some("composer"), RecoveryPause::AfterLateCompletion; "after_previous_turn_completes_late")] +#[tokio::test] +async fn recovery_preserves_durable_turn_attribution( + trigger: Option<&str>, + pause: RecoveryPause, +) -> anyhow::Result<()> { + let mut server = responses::start_mock_server().await; + responses::mount_sse_once(&server, responses::sse_completed("prior-user-turn")).await; + let barrier = Arc::new(RecoveryLifecycleBarrier::default()); + let mut extensions = ExtensionRegistryBuilder::new(); + extensions.turn_lifecycle_contributor(barrier.clone()); + let mut builder = test_codex().with_extensions(Arc::new(extensions.build())); + let mut test = builder.build_with_auto_env(&server).await?; + barrier.block_stop.store( + matches!(pause, RecoveryPause::AfterLateCompletion), + Ordering::SeqCst, + ); + let TurnInputSubmission::Started { + turn_id: previous_turn_id, + } = submit_user_message(&test.codex, "establish the initial context baseline").await? + else { + panic!("expected the initial turn"); + }; + if matches!(pause, RecoveryPause::AfterLateCompletion) { + timeout( + Duration::from_secs(/*secs*/ 10), + barrier.stop_entered.notified(), + ) + .await?; + } else { + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(event) if event.turn_id == previous_turn_id) + }) + .await; + } + let request_received = Arc::new(tokio::sync::Notify::new()); + if matches!(pause, RecoveryPause::BeforeContext) { + barrier.block_next.store(true, Ordering::SeqCst); + } else { + let notify = Arc::clone(&request_received); + responses::mount_response_once_match( + &server, + move |_: &wiremock::Request| { + notify.notify_one(); + true + }, + responses::sse_response(responses::sse_completed("initial")) + .set_delay(Duration::from_secs(/*secs*/ 60)), + ) + .await; + } + let parent = trigger.map(|_| "parent-turn"); + let initiating_agent_path = + parent.map(|_| AgentPath::root().join("requester").expect("requester path")); + let root = "root-turn"; + let request = if trigger == Some("automation") { + TurnInputRequest::user_input(Vec::new()) + } else { + user_message_request("recover this work") + }; + let request = request.on_start(TurnStartOptions { + turn_trigger: trigger.map(str::to_owned), + parent_turn_id: parent.map(str::to_owned), + root_turn_id: Some(root.to_owned()), + initiating_agent_path: initiating_agent_path.clone(), + ..Default::default() + }); + let turn_id = if matches!(pause, RecoveryPause::AfterLateCompletion) { + let TurnInputSubmission::Started { turn_id } = + test.codex.start_or_steer_turn(request).await? + else { + panic!("expected a new turn while the previous stop hook is pending"); + }; + turn_id + } else { + let StartIfIdleSubmission::Started { turn_id } = + test.codex.start_turn_if_idle(request).await? + else { + panic!("expected a new turn"); + }; + turn_id + }; + timeout(Duration::from_secs(/*secs*/ 10), async { + if matches!(pause, RecoveryPause::BeforeContext) { + barrier.entered.notified().await; + } else { + request_received.notified().await; + } + }) + .await?; + if matches!(pause, RecoveryPause::AfterLateCompletion) { + barrier.release_stop.notify_one(); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(event) if event.turn_id == previous_turn_id) + }) + .await; + } + let thread_settings = test.codex.restorable_thread_settings().await; + if let Some(url) = test.executor_environment().exec_server_url() { + builder = builder.with_exec_server_url(url); + } + + for attempt in 0..2 { + assert_eq!( + test.codex.suspend_turn_and_shutdown().await?, + codex_core::SuspendTurnOutcome::Suspended { + turn_id: turn_id.clone() + }, + ); + let rollout_path = test.codex.rollout_path().expect("durable rollout"); + test.thread_manager + .remove_thread(&test.session_configured.thread_id) + .await + .expect("suspended thread is loaded"); + if attempt == 0 && matches!(pause, RecoveryPause::LegacyRollout) { + let mut legacy_rollout = String::new(); + for line in std::fs::read_to_string(&rollout_path)?.lines() { + let mut item: Value = serde_json::from_str(line)?; + if let Some(payload) = item["payload"].as_object_mut() { + payload.remove("turn_attribution"); + } + legacy_rollout.push_str(&serde_json::to_string(&item)?); + legacy_rollout.push('\n'); + } + std::fs::write(&rollout_path, legacy_rollout)?; + } + server = responses::start_mock_server().await; + let notify = Arc::clone(&request_received); + let response = responses::mount_response_once_match( + &server, + move |_: &wiremock::Request| { + notify.notify_one(); + true + }, + responses::sse_response(responses::sse_completed("recovered")) + .set_delay(Duration::from_secs(/*secs*/ 60)), + ) + .await; + test = builder + .resume(&server, Arc::clone(&test.home), rollout_path) + .await?; + assert_eq!( + test.codex + .recover_turn_if_idle(RecoverTurnRequest { + turn_id: turn_id.clone(), + thread_settings: ThreadSettingsOverrides { + environments: thread_settings.environments.clone(), + ..Default::default() + }, + trace: None, + cyber_access_program: None, + }) + .await?, + StartIfIdleSubmission::Started { + turn_id: turn_id.clone() + }, + ); + let attribution = wait_for_event_match(&test.codex, |event| match event { + EventMsg::TurnStarted(event) if event.turn_id == turn_id => { + Some(event.turn_attribution.clone()) + } + _ => None, + }) + .await; + assert_eq!( + attribution, + Some(TurnAttribution { + turn_id: turn_id.clone(), + turn_trigger: trigger.map(str::to_owned), + parent_turn_id: parent.map(str::to_owned), + root_turn_id: Some(root.to_owned()), + initiating_agent_path: initiating_agent_path.clone(), + }), + ); + timeout( + Duration::from_secs(/*secs*/ 10), + request_received.notified(), + ) + .await?; + let request = response.single_request(); + let metadata: Value = serde_json::from_str( + &request + .header("x-codex-turn-metadata") + .expect("turn metadata"), + )?; + assert_eq!( + ( + metadata["turn_id"].as_str(), + metadata["turn_trigger"].as_str(), + metadata["parent_turn_id"].as_str(), + metadata["root_turn_id"].as_str(), + ), + (Some(turn_id.as_str()), trigger, parent, Some(root)), + "recovery attempt {attempt}", + ); + } + test.codex.submit(Op::Interrupt).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnAborted(_)) + }) + .await; + if trigger != Some("automation") { + return Ok(()); + } + let next_server = responses::start_mock_server().await; + test = builder.restart(&next_server, &test).await?; + test.codex.restore_thread_settings(thread_settings).await?; + let response = responses::mount_sse_once(&next_server, responses::sse_completed("new")).await; + let TurnInputSubmission::Started { + turn_id: next_turn_id, + } = test + .codex + .start_or_steer_turn( + user_message_request("new human work").on_start(TurnStartOptions { + turn_trigger: Some("composer".to_owned()), + ..Default::default() + }), + ) + .await? + else { + panic!("expected a new turn"); + }; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + let request = response.single_request(); + let metadata: Value = serde_json::from_str( + &request + .header("x-codex-turn-metadata") + .expect("turn metadata"), + )?; + assert_eq!( + ( + metadata["turn_trigger"].as_str(), + metadata.get("parent_turn_id"), + metadata["root_turn_id"].as_str() + ), + (Some("composer"), None, Some(next_turn_id.as_str())), + ); + Ok(()) +} + /// Internal continuation creates a new turn without adding user authorization. #[tokio::test] async fn continue_turn_if_idle_starts_new_turn_with_internal_input() { diff --git a/codex-rs/external-agent-migration/src/sessions/export.rs b/codex-rs/external-agent-migration/src/sessions/export.rs index e3b360da3a..fbe2e37003 100644 --- a/codex-rs/external-agent-migration/src/sessions/export.rs +++ b/codex-rs/external-agent-migration/src/sessions/export.rs @@ -105,6 +105,7 @@ pub(super) fn rollout_items_from_messages(messages: Vec) -> let turn_id = format!("external-import-turn-{user_turn_count}"); items.push(RolloutItem::EventMsg(EventMsg::TurnStarted( TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.clone(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/history/src/compaction_resume_metadata.rs b/codex-rs/history/src/compaction_resume_metadata.rs index 4886fcd52b..bd8eb7a267 100644 --- a/codex-rs/history/src/compaction_resume_metadata.rs +++ b/codex-rs/history/src/compaction_resume_metadata.rs @@ -3,6 +3,7 @@ use crate::RolloutItem; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::turn_input::CyberAccessProgram; +use codex_protocol::turn_input::TurnAttribution; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -17,6 +18,9 @@ pub struct CompactionResumeMetadata { pub multi_agent_version: Option, /// Turn identity used to admit continuations after cold resume. pub last_started_turn_id: Option, + /// Attribution of the latest regular turn, independent of continuation eligibility. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_attribution: Option, pub previous_turn_settings: Option, } diff --git a/codex-rs/history/src/lib.rs b/codex-rs/history/src/lib.rs index 5fb8de4290..41b99f95a0 100644 --- a/codex-rs/history/src/lib.rs +++ b/codex-rs/history/src/lib.rs @@ -6,6 +6,7 @@ pub use heartbeat::Heartbeat; pub use heartbeat::UserInputOrigin; mod compaction_resume_metadata; +pub use codex_protocol::turn_input::TurnAttribution; pub use compaction_resume_metadata::CompactionResumeMetadata; pub use compaction_resume_metadata::PreviousTurnSettings; pub use compaction_resume_metadata::resume_multi_agent_version; diff --git a/codex-rs/history/src/tests.rs b/codex-rs/history/src/tests.rs index 0b04ff6fd1..d021d687bb 100644 --- a/codex-rs/history/src/tests.rs +++ b/codex-rs/history/src/tests.rs @@ -580,6 +580,7 @@ fn compacted_resume_metadata_presence_round_trips_empty_values() -> Result<()> { let resume_metadata = CompactionResumeMetadata { multi_agent_version: None, last_started_turn_id: None, + turn_attribution: None, previous_turn_settings: None, }; let item = CompactedItem { @@ -690,6 +691,7 @@ fn compacted_metadata_remains_compatible_with_legacy_response_item_readers() -> resume_metadata: Some(CompactionResumeMetadata { multi_agent_version: Some(MultiAgentVersion::V2), last_started_turn_id: Some("turn-1".to_string()), + turn_attribution: None, previous_turn_settings: None, }), }))?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e486d66649..6e6b64817a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2156,6 +2156,11 @@ pub struct TurnCompleteEvent { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { + /// Provenance of a regular turn, persisted before startup work can be suspended. + /// Absent on older records and non-regular tasks. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub turn_attribution: Option, pub turn_id: String, /// ID of the originating turn in the root thread; equals `turn_id` for root turns. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3280,8 +3285,7 @@ pub struct TurnContextNetworkItem { pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub turn_id: Option, - /// Root turn that owns this subagent turn's attribution. - /// Only set for subagent turns; persisted so resume keeps the scope frozen at turn start. + /// Root turn that owns this turn's attribution, retained across recovery. #[serde(default, skip_serializing_if = "Option::is_none")] pub root_turn_id: Option, /// Plugin selection captured for this turn. Absent in older histories. diff --git a/codex-rs/protocol/src/turn_input.rs b/codex-rs/protocol/src/turn_input.rs index cb27cd4f96..c145e0543d 100644 --- a/codex-rs/protocol/src/turn_input.rs +++ b/codex-rs/protocol/src/turn_input.rs @@ -1,5 +1,6 @@ //! Turn-input request and result types shared by Core's submission APIs. +use crate::AgentPath; use crate::models::ResponseItem; use crate::protocol::AdditionalContextEntry; use crate::protocol::InterAgentCommunication; @@ -52,6 +53,29 @@ pub struct TurnInputRequest { pub trace: Option, } +/// Attribution retained across recovery, sleep, and model-context compaction. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +pub struct TurnAttribution { + pub turn_id: String, + pub turn_trigger: Option, + pub parent_turn_id: Option, + pub initiating_agent_path: Option, + pub root_turn_id: Option, +} + +impl TurnAttribution { + /// Restores provenance without changing the thread's execution settings. + pub fn start_options(&self) -> TurnStartOptions { + TurnStartOptions { + turn_trigger: self.turn_trigger.clone(), + parent_turn_id: self.parent_turn_id.clone(), + initiating_agent_path: self.initiating_agent_path.clone(), + root_turn_id: self.root_turn_id.clone(), + ..Default::default() + } + } +} + /// Request to resume sampling for an interrupted regular turn. /// /// Sampling restarts under `turn_id`, which must be the ID already recorded @@ -171,6 +195,8 @@ pub struct TurnStartOptions { pub service_tier: Option, /// Parent turn lineage recorded if this request starts a new turn. pub parent_turn_id: Option, + /// Agent owning the parent turn, retained when recovering delegated work. + pub initiating_agent_path: Option, /// Causal root turn lineage recorded if this request starts a new turn. pub root_turn_id: Option, /// Explicit cyber treatment for this turn. Omission preserves the backend's diff --git a/codex-rs/rollout/src/persistence_metrics_tests.rs b/codex-rs/rollout/src/persistence_metrics_tests.rs index f6af5af10d..48b32f712c 100644 --- a/codex-rs/rollout/src/persistence_metrics_tests.rs +++ b/codex-rs/rollout/src/persistence_metrics_tests.rs @@ -49,6 +49,7 @@ fn retained_message(text: &str) -> RolloutItem { fn turn_started(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index d7aa740270..e8dc915728 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1164,6 +1164,7 @@ mod tests { live_thread .append_items(&[RolloutItem::EventMsg(EventMsg::TurnStarted( TurnStartedEvent { + turn_attribution: None, turn_id: "turn-1".to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/thread-store/src/local/model_context_tests.rs b/codex-rs/thread-store/src/local/model_context_tests.rs index 7be08ce485..6b1fad47f8 100644 --- a/codex-rs/thread-store/src/local/model_context_tests.rs +++ b/codex-rs/thread-store/src/local/model_context_tests.rs @@ -219,6 +219,7 @@ async fn fork_version_stops_before_older_segments_once_resolved() { compacted.resume_metadata = Some(codex_rollout::CompactionResumeMetadata { multi_agent_version: Some(MultiAgentVersion::V2), last_started_turn_id: None, + turn_attribution: None, previous_turn_settings: None, }); append_items( @@ -616,6 +617,7 @@ fn append_items(path: &Path, items: impl IntoIterator) { fn turn_started(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/thread-store/src/local/revert_thread_tests.rs b/codex-rs/thread-store/src/local/revert_thread_tests.rs index fd9ca13c0c..827bf8c107 100644 --- a/codex-rs/thread-store/src/local/revert_thread_tests.rs +++ b/codex-rs/thread-store/src/local/revert_thread_tests.rs @@ -321,6 +321,7 @@ async fn turn_ids(store: &LocalThreadStore, thread_id: ThreadId) -> Vec fn turn_started(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs b/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs index 1575bcf090..37c353fa08 100644 --- a/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs +++ b/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs @@ -345,6 +345,7 @@ impl LegacyRolloutCanonicalizer { writer, timestamp, RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id, root_turn_id: None, trace_id: None, diff --git a/codex-rs/thread-store/src/local/rollout_migration_tests.rs b/codex-rs/thread-store/src/local/rollout_migration_tests.rs index 90e2f11512..589acf7508 100644 --- a/codex-rs/thread-store/src/local/rollout_migration_tests.rs +++ b/codex-rs/thread-store/src/local/rollout_migration_tests.rs @@ -227,6 +227,7 @@ fn item_completed(turn_id: &str, item_id: &str) -> RolloutItem { fn started(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs index b7c0658be6..8b691b6a2c 100644 --- a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs +++ b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs @@ -2832,6 +2832,7 @@ async fn create_paginated_subagent_thread( fn turn_started(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index cb3559c137..0351105fc1 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -712,6 +712,7 @@ mod tests { let update = sync .observe_appended_items(&[RolloutItem::EventMsg(EventMsg::TurnStarted( TurnStartedEvent { + turn_attribution: None, turn_id: "turn-1".to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 89d722bd9a..8ed3f84c85 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -8406,6 +8406,7 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res ); for item in [ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.to_string(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/tui/src/app/tests/history_hydration_tests.rs b/codex-rs/tui/src/app/tests/history_hydration_tests.rs index 9a5d34fae6..bd976a3dd5 100644 --- a/codex-rs/tui/src/app/tests/history_hydration_tests.rs +++ b/codex-rs/tui/src/app/tests/history_hydration_tests.rs @@ -55,6 +55,7 @@ async fn history_fixture( for (index, count) in item_counts.iter().enumerate() { let turn_id = format!("turn-{index}"); let mut events = vec![EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.clone(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/tui/src/app/tests/pagination_completion_tests.rs b/codex-rs/tui/src/app/tests/pagination_completion_tests.rs index d3d98496c6..5698e5d2eb 100644 --- a/codex-rs/tui/src/app/tests/pagination_completion_tests.rs +++ b/codex-rs/tui/src/app/tests/pagination_completion_tests.rs @@ -57,6 +57,7 @@ pub(super) async fn completed_history_app( let turn_id = format!("turn-{index}"); let finished = completed_at.timestamp() + index as i64 * 60; let mut events = vec![EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: turn_id.clone(), root_turn_id: None, trace_id: None, diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index cd366ae49f..2e518843b7 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -2320,6 +2320,7 @@ async fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> user_item("newer-visible-prompt", "newer visible prompt"), ]); let events = std::iter::once(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: "cross-page-review-turn".to_string(), root_turn_id: None, trace_id: None, @@ -2483,6 +2484,7 @@ async fn transcript_alt_beginning_loads_every_older_history_page() -> Result<()> .map(serde_json::from_str::) .collect::, _>>()?; let events = std::iter::once(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: "multi-page-turn".to_string(), root_turn_id: None, trace_id: None, @@ -2898,6 +2900,7 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri .map(serde_json::from_str::) .collect::, _>>()?; let events = std::iter::once(EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: "scrollback-pagination-turn".to_string(), root_turn_id: None, trace_id: None, @@ -4964,6 +4967,7 @@ async fn command_center_read_only_open_requests_and_failure_preservation() -> Re let first_ordinal = contents.lines().count(); let events = [ EventMsg::TurnStarted(TurnStartedEvent { + turn_attribution: None, turn_id: format!("saved-turn-{index}"), root_turn_id: None, trace_id: None,