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
This commit is contained in:
Eddie Chen
2026-10-06 15:50:27 +00:00
committed by copyberry
parent a4ebc509f4
commit 551bd409eb
50 changed files with 912 additions and 38 deletions
@@ -108,6 +108,7 @@ fn guardian_stdin_reviews_preserve_parent_command_history() {
};
let start_turn = |turn_id: &str| {
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: turn_id.into(),
root_turn_id: None,
trace_id: None,
@@ -2186,6 +2186,7 @@ mod tests {
let thread_id = ThreadId::new();
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: turn_id.to_string(),
root_turn_id: None,
trace_id: None,
@@ -2252,6 +2253,7 @@ mod tests {
}));
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: turn_id.to_string(),
root_turn_id: None,
trace_id: None,
@@ -2321,6 +2323,7 @@ mod tests {
let saved_path = test_path_buf("/tmp/image-1.png").abs();
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: turn_id.to_string(),
root_turn_id: None,
trace_id: None,
@@ -2414,6 +2417,7 @@ mod tests {
});
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: turn_id.to_string(),
root_turn_id: None,
trace_id: None,
@@ -2533,6 +2537,7 @@ mod tests {
let thread_id = ThreadId::new();
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: turn_id.to_string(),
root_turn_id: None,
trace_id: None,
@@ -2634,6 +2639,7 @@ mod tests {
fn replays_image_generation_end_events_into_turn_history() {
let items = vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-image".into(),
root_turn_id: None,
trace_id: None,
@@ -3029,6 +3035,7 @@ mod tests {
fn uses_explicit_turn_boundaries_for_mid_turn_steering() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -3097,6 +3104,7 @@ mod tests {
fn reconstructs_tool_items_from_persisted_completion_events() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-1".into(),
root_turn_id: None,
trace_id: None,
@@ -3235,6 +3243,7 @@ mod tests {
fn reconstructs_mcp_tool_result_meta_from_persisted_completion_events() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-1".into(),
root_turn_id: None,
trace_id: None,
@@ -3318,6 +3327,7 @@ mod tests {
fn reconstructs_dynamic_tool_items_from_request_and_response_events() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-1".into(),
root_turn_id: None,
trace_id: None,
@@ -3403,6 +3413,7 @@ mod tests {
fn reconstructs_declined_exec_and_patch_items() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-1".into(),
root_turn_id: None,
trace_id: None,
@@ -3499,6 +3510,7 @@ mod tests {
fn reconstructs_declined_guardian_command_item() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-1".into(),
root_turn_id: None,
trace_id: None,
@@ -3598,6 +3610,7 @@ mod tests {
fn reconstructs_in_progress_guardian_execve_item() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-1".into(),
root_turn_id: None,
trace_id: None,
@@ -3706,6 +3719,7 @@ mod tests {
};
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -3727,6 +3741,7 @@ mod tests {
time_to_first_token_ms: None,
}),
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-b".into(),
root_turn_id: None,
trace_id: None,
@@ -3767,6 +3782,7 @@ mod tests {
fn assigns_late_exec_completion_to_original_turn() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -3792,6 +3808,7 @@ mod tests {
time_to_first_token_ms: None,
}),
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-b".into(),
root_turn_id: None,
trace_id: None,
@@ -3874,6 +3891,7 @@ mod tests {
fn drops_late_turn_scoped_item_for_unknown_turn_id() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -3899,6 +3917,7 @@ mod tests {
time_to_first_token_ms: None,
}),
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-b".into(),
root_turn_id: None,
trace_id: None,
@@ -3975,6 +3994,7 @@ mod tests {
None
);
builder.handle_event(&EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-1".into(),
root_turn_id: None,
trace_id: None,
@@ -4044,6 +4064,7 @@ mod tests {
let mut builder = ThreadHistoryBuilder::new();
assert_eq!(builder.active_turn_metadata_snapshot(), None);
builder.handle_event(&EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-1".into(),
root_turn_id: Some("root-turn".into()),
trace_id: None,
@@ -4092,6 +4113,7 @@ mod tests {
let mut builder = ThreadHistoryBuilder::new();
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: turn_id.to_string(),
root_turn_id: None,
trace_id: None,
@@ -4161,6 +4183,7 @@ mod tests {
let mut builder = ThreadHistoryBuilder::new();
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: turn_id.to_string(),
root_turn_id: None,
trace_id: None,
@@ -4230,6 +4253,7 @@ mod tests {
fn late_turn_complete_does_not_close_active_turn() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -4255,6 +4279,7 @@ mod tests {
time_to_first_token_ms: None,
}),
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-b".into(),
root_turn_id: None,
trace_id: None,
@@ -4312,6 +4337,7 @@ mod tests {
fn late_turn_complete_with_embedded_error_preserves_active_turn() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -4328,6 +4354,7 @@ mod tests {
..Default::default()
}),
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-b".into(),
root_turn_id: None,
trace_id: None,
@@ -4416,6 +4443,7 @@ mod tests {
fn late_turn_aborted_does_not_interrupt_active_turn() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -4441,6 +4469,7 @@ mod tests {
time_to_first_token_ms: None,
}),
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-b".into(),
root_turn_id: None,
trace_id: None,
@@ -4489,6 +4518,7 @@ mod tests {
fn preserves_compaction_only_turn() {
let items = vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-compact".into(),
root_turn_id: None,
trace_id: None,
@@ -4763,6 +4793,7 @@ mod tests {
fn out_of_turn_error_does_not_create_or_fail_a_turn() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -4826,6 +4857,7 @@ mod tests {
fn error_then_turn_complete_preserves_failed_status() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -4886,6 +4918,7 @@ mod tests {
fn turn_complete_with_embedded_error_marks_turn_failed() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -4957,6 +4990,7 @@ mod tests {
.expect("hook prompt message");
let items = vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -5018,6 +5052,7 @@ mod tests {
let expected_item = ThreadItem::from(hook_prompt.clone());
let mut builder = ThreadHistoryBuilder::new();
builder.handle_event(&EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -5047,6 +5082,7 @@ mod tests {
.expect("worker path");
let items = vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -5095,6 +5131,7 @@ mod tests {
fn ignores_plain_user_response_items_in_rollout_replay() {
let items = vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -5256,6 +5293,7 @@ mod tests {
let start_changes = builder.handle_rollout_item_with_changes(&RolloutItem::EventMsg(
EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: Some("root-turn".into()),
trace_id: None,
@@ -5374,6 +5412,7 @@ mod tests {
let mut builder = ThreadHistoryBuilder::new();
let changes = builder.handle_rollout_items_with_changes(&[
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: Some("root-turn".into()),
trace_id: None,
@@ -5415,6 +5454,7 @@ mod tests {
let mut builder = ThreadHistoryBuilder::new();
let changes = builder.handle_rollout_items_with_changes(&[
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
@@ -26,6 +26,7 @@ use crate::protocol::v2::TurnError;
fn projects_turn_lifecycle_without_prior_builder_state() {
let started = project(RolloutItem::EventMsg(EventMsg::TurnStarted(
TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-1".to_string(),
root_turn_id: Some("root-turn".into()),
trace_id: None,