Preserve Guardian history across thread reconstruction (#42065)

## Why

Guardian review evidence must remain available after a compacted thread is resumed or forked, while rolled-back or parent-local evidence must not grant authorization in a different context.

## What changed

- Store the bounded, model-invisible Guardian transcript alongside compacted rollout history and restore it from the newest surviving checkpoint during replay.
- Trim Guardian history at rollback boundaries, clearing it when the boundary has already been evicted.
- Preserve the checkpoint for user-initiated forks, but remove it when spawning a subagent so parent review evidence is not inherited.
- Keep the new rollout field optional for compatibility with existing compacted records and legacy readers.

## Testing

- Cover Guardian history across compaction, restart, paginated and pathless stores, user forks, rollback, bounded replay, serialization, and subagent forks.

GitOrigin-RevId: 6ab076d5e0c3aac4d6a41ebd50ba2b200c90213e
This commit is contained in:
jif authored and copyberry committed 2026-09-01 14:33:58 +00:00
1 parent 3a04482645
commit 28097e98eb
23 files changed
+369 -27

No files matched your search

+3
View File
@@ -996,6 +996,9 @@ impl AgentControl {
RolloutItem::Compacted(compacted) => {
// This checkpoint belongs to the inherited parent prefix.
compacted.latest_token_usage_record = None;
// Parent-local review evidence must not become the child's authorization.
// Root user authorization is collected separately by the host.
compacted.guardian_history = None;
if let Some(replacement_history) = compacted.replacement_history.as_mut() {
// Matches before this checkpoint cannot survive its replacement history.
replaced_parent_developer_instructions = false;
+14
View File
@@ -1410,6 +1410,7 @@ async fn spawn_agent_fork_drops_inherited_token_usage_state() {
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(vec![user_message("compacted parent context").into()]),
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
@@ -1525,6 +1526,7 @@ async fn spawn_agent_numeric_fork_from_compacted_paginated_parent_clamps_to_prov
}
.into(),
]),
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
@@ -2023,6 +2025,9 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
replacement_history: Some(
replacement_history.into_iter().map(Into::into).collect(),
),
guardian_history: Some(codex_history::GuardianHistoryCheckpoint(vec![
user_message("Parent-local approval must not be inherited."),
])),
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
@@ -2079,6 +2084,13 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
.await
.expect("child thread should be registered");
let history = child_thread.session.clone_history().await;
assert!(
!history_contains_text(
history.conversation_history_snapshot().review_items(),
"Parent-local approval must not be inherited.",
),
"a subagent must not inherit its parent review checkpoint",
);
assert!(
history_contains_text(history.raw_items(), "compacted parent summary"),
"forked child history should retain compacted non-hint content"
@@ -2207,6 +2219,7 @@ async fn spawn_agent_full_fork_restores_instructions_after_compaction_discards_p
replacement_history: Some(
replacement_history.into_iter().map(Into::into).collect(),
),
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
@@ -2362,6 +2375,7 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc
RolloutItem::Compacted(CompactedItem {
message: "legacy compacted summary".to_string(),
replacement_history: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
+30 -1
View File
@@ -1,5 +1,5 @@
//! Model history and bounded original evidence for approval review.
//! Compaction replaces only model history; explicit resets also replace retained evidence.
//! Compaction replaces only model history; replay restores retained evidence and rollback trims it.
use crate::context::ContextualUserFragment;
use crate::context::ModelSwitchInstructions;
@@ -20,6 +20,7 @@ use codex_extension_api::ConversationHistorySnapshot;
use codex_guardian_context::SectionHistory;
use codex_guardian_context::TranscriptHistory;
use codex_history::CodexHarnessMetadata;
use codex_history::GuardianHistoryCheckpoint;
use codex_history::ResponseItemEnvelope;
use codex_protocol::models::AgentMessageInputContent;
use codex_protocol::models::BaseInstructions;
@@ -149,6 +150,28 @@ impl ContextManager {
})
}
pub(crate) fn guardian_history_checkpoint(&self) -> Option<GuardianHistoryCheckpoint> {
self.review_history
.as_ref()
.map(|history| GuardianHistoryCheckpoint(history.items().cloned().collect()))
}
pub(crate) fn restore_guardian_history(
&mut self,
checkpoint: Option<&GuardianHistoryCheckpoint>,
) {
let generation = self
.review_history
.as_ref()
.map_or(self.history_version, TranscriptHistory::generation)
.saturating_add(1);
self.review_history = checkpoint.map(|checkpoint| {
let mut history = TranscriptHistory::new(generation);
history.reset(checkpoint.0.iter());
history
});
}
pub(crate) fn token_info(&self) -> Option<TokenUsageInfo> {
self.token_info.clone()
}
@@ -420,6 +443,11 @@ impl ContextManager {
user_positions[user_positions.len() - n_from_end]
};
let mut review_history = self.review_history.take();
if let Some(history) = &mut review_history {
history.truncate_before(&snapshot[cut_idx].item);
}
cut_idx =
self.trim_pre_turn_context_updates(&snapshot, first_instruction_turn_idx, cut_idx);
@@ -451,6 +479,7 @@ impl ContextManager {
}
self.replace_annotated(retained_items);
self.review_history = review_history;
}
pub(crate) fn update_token_info(
+7 -1
View File
@@ -1536,6 +1536,7 @@ impl Session {
) -> Option<PreviousTurnSettings> {
let rollout_reconstruction::RolloutReconstruction {
mut history,
guardian_history,
previous_turn_settings,
reference_context_item,
world_state_baseline,
@@ -1578,6 +1579,9 @@ impl Session {
reference_context_item,
HistoryReplacement::Reset,
);
state
.history
.restore_guardian_history(guardian_history.as_ref());
if let Some(world_state) = world_state_baseline {
state.history.set_world_state_baseline(world_state);
}
@@ -3711,9 +3715,10 @@ impl Session {
for envelope in &mut items {
Self::assign_missing_response_item_id(&mut envelope.item);
}
let compacted_item = CompactedItem {
let mut compacted_item = CompactedItem {
message: metadata.message,
replacement_history: Some(items.clone()),
guardian_history: None,
mcp_resource_origins: self.services.mcp_runtime.resource_origin_checkpoint(),
window_number: Some(metadata.window_number),
first_window_id: Some(metadata.window_ids.first_window_id.to_string()),
@@ -3737,6 +3742,7 @@ impl Session {
reference_context_item.clone(),
HistoryReplacement::Compaction,
);
compacted_item.guardian_history = state.history.guardian_history_checkpoint();
if let Some(world_state) = world_state_baseline {
let snapshot = world_state.snapshot();
world_state_item = Some(WorldStateItem::full(snapshot.clone().into_object()));
@@ -10,6 +10,7 @@ use uuid::Uuid;
#[derive(Debug)]
pub(super) struct RolloutReconstruction {
pub(super) history: Vec<ResponseItemEnvelope>,
pub(super) guardian_history: Option<codex_history::GuardianHistoryCheckpoint>,
pub(super) previous_turn_settings: Option<PreviousTurnSettings>,
pub(super) reference_context_item: Option<TurnContextItem>,
pub(super) world_state_baseline: Option<WorldStateSnapshot>,
@@ -43,6 +44,13 @@ enum TurnReferenceContextItem {
Latest(Box<TurnContextItem>),
}
#[derive(Debug, Clone, Copy)]
// The selected checkpoint and its replay tail must belong to the same surviving segment.
struct ReplayCheckpoint<'a> {
compacted: &'a CompactedItem,
suffix: &'a [RolloutItem],
}
#[derive(Debug, Default)]
struct ActiveReplaySegment<'a> {
turn_id: Option<String>,
@@ -50,7 +58,7 @@ struct ActiveReplaySegment<'a> {
previous_turn_settings: Option<PreviousTurnSettings>,
reference_context_item: TurnReferenceContextItem,
world_state_replay: Vec<&'a RolloutItem>,
base_replacement_history: Option<&'a [ResponseItemEnvelope]>,
base_compaction: Option<ReplayCheckpoint<'a>>,
window: Option<ReconstructedWindow>,
}
@@ -61,7 +69,7 @@ fn turn_ids_are_compatible(active_turn_id: Option<&str>, item_turn_id: Option<&s
fn finalize_active_segment<'a>(
active_segment: ActiveReplaySegment<'a>,
base_replacement_history: &mut Option<&'a [ResponseItemEnvelope]>,
base_compaction: &mut Option<ReplayCheckpoint<'a>>,
previous_turn_settings: &mut Option<PreviousTurnSettings>,
reference_context_item: &mut TurnReferenceContextItem,
world_state_replay: &mut Vec<&'a RolloutItem>,
@@ -92,10 +100,10 @@ fn finalize_active_segment<'a>(
// A surviving replacement-history checkpoint is a complete history base. Once we
// know the newest surviving one, older rollout items do not affect rebuilt history.
if base_replacement_history.is_none()
&& let Some(segment_base_replacement_history) = active_segment.base_replacement_history
if base_compaction.is_none()
&& let Some(segment_base_compaction) = active_segment.base_compaction
{
*base_replacement_history = Some(segment_base_replacement_history);
*base_compaction = Some(segment_base_compaction);
}
if window.is_none() {
@@ -147,7 +155,7 @@ impl Session {
_ => None,
})
};
let mut base_replacement_history: Option<&[ResponseItemEnvelope]> = None;
let mut base_compaction = None;
let mut previous_turn_settings = None;
let mut reference_context_item = TurnReferenceContextItem::NeverSet;
let mut world_state_replay = Vec::new();
@@ -155,9 +163,6 @@ impl Session {
// 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;
// Borrowed suffix of rollout items newer than the newest surviving replacement-history
// checkpoint. If no such checkpoint exists, this remains the full rollout.
let mut rollout_suffix = rollout_items;
// 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 active_segment: Option<ActiveReplaySegment<'_>> = None;
@@ -189,11 +194,13 @@ impl Session {
) {
active_segment.reference_context_item = TurnReferenceContextItem::Cleared;
}
if active_segment.base_replacement_history.is_none()
&& let Some(replacement_history) = &compacted.replacement_history
if active_segment.base_compaction.is_none()
&& compacted.replacement_history.is_some()
{
active_segment.base_replacement_history = Some(replacement_history);
rollout_suffix = &rollout_items[index + 1..];
active_segment.base_compaction = Some(ReplayCheckpoint {
compacted,
suffix: &rollout_items[index + 1..],
});
}
}
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => {
@@ -270,7 +277,7 @@ impl Session {
{
finalize_active_segment(
active_segment,
&mut base_replacement_history,
&mut base_compaction,
&mut previous_turn_settings,
&mut reference_context_item,
&mut world_state_replay,
@@ -298,7 +305,7 @@ impl Session {
| RolloutItem::InterAgentCommunicationMetadata { .. } => {}
}
if base_replacement_history.is_some()
if base_compaction.is_some()
&& previous_turn_settings.is_some()
&& !matches!(reference_context_item, TurnReferenceContextItem::NeverSet)
{
@@ -312,7 +319,7 @@ impl Session {
if let Some(active_segment) = active_segment.take() {
finalize_active_segment(
active_segment,
&mut base_replacement_history,
&mut base_compaction,
&mut previous_turn_settings,
&mut reference_context_item,
&mut world_state_replay,
@@ -331,12 +338,16 @@ impl Session {
let mut history = ContextManager::new();
let mut saw_legacy_compaction_without_replacement_history = false;
if let Some(base_replacement_history) = base_replacement_history {
history.replace_annotated(base_replacement_history.to_vec());
if let Some(checkpoint) = base_compaction
&& let Some(items) = &checkpoint.compacted.replacement_history
{
history.replace_annotated(items.clone());
history.restore_guardian_history(checkpoint.compacted.guardian_history.as_ref());
}
// Materialize exact history semantics from the replay-derived suffix. The eventual lazy
// design should keep this same replay shape, but drive it from a resumable reverse source
// instead of an eagerly loaded `&[RolloutItem]`.
let rollout_suffix = base_compaction.map_or(rollout_items, |checkpoint| checkpoint.suffix);
for item in rollout_suffix {
match item {
RolloutItem::ResponseItem(response_item) => {
@@ -354,11 +365,10 @@ impl Session {
}
RolloutItem::InterAgentCommunicationMetadata { .. } => {}
RolloutItem::Compacted(compacted) => {
if let Some(replacement_history) = &compacted.replacement_history {
// This should actually never happen, because the reverse loop above (to build rollout_suffix)
// should stop before any compaction that has Some replacement_history
history.replace_annotated(replacement_history.clone());
} else {
// Reverse replay already chose the newest surviving checkpoint. Any newer
// replacement checkpoint belongs to a rolled-back turn; replay its original
// items so the rollback can still find the removed user boundary.
if compacted.replacement_history.is_none() {
saw_legacy_compaction_without_replacement_history = true;
// Legacy rollouts without `replacement_history` should rebuild the
// historical TurnContext at the correct insertion point from persisted
@@ -441,6 +451,7 @@ impl Session {
id: None,
});
RolloutReconstruction {
guardian_history: history.guardian_history_checkpoint(),
history: history.into_annotated_items(),
previous_turn_settings,
reference_context_item,
@@ -1061,6 +1061,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
@@ -1123,6 +1124,7 @@ async fn record_initial_history_requires_surviving_full_snapshot_without_user_tu
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
@@ -1157,6 +1159,7 @@ async fn record_initial_history_resumed_does_not_seed_reference_context_item_aft
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
@@ -1229,6 +1232,7 @@ async fn reconstruct_history_prefers_compacted_window_over_session_meta() {
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(2),
first_window_id: Some(compacted_first_window_id.to_string()),
@@ -1267,6 +1271,7 @@ async fn reconstruct_history_replays_world_state_from_latest_compaction_window()
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(1),
first_window_id: None,
@@ -1331,6 +1336,9 @@ async fn bounded_replay_matches_full_replay_after_empty_turn_compactions() {
replacement_history: Some(annotated(vec![assistant_message(&format!(
"summary-{window_number}"
))])),
guardian_history: Some(codex_history::GuardianHistoryCheckpoint(vec![
user_message("original task"),
])),
mcp_resource_origins: None,
window_number: Some(window_number as u64),
first_window_id: Some(window_ids[0].to_string()),
@@ -1385,8 +1393,16 @@ async fn bounded_replay_matches_full_replay_after_empty_turn_compactions() {
let bounded = session
.reconstruct_history_from_rollout(&turn_context, &bounded_items)
.await;
assert_eq!(
bounded.guardian_history.as_ref(),
Some(&codex_history::GuardianHistoryCheckpoint(vec![
user_message("original task"),
assistant_message("continued working"),
])),
);
assert_eq!(
(
bounded.guardian_history,
bounded.history,
bounded.previous_turn_settings,
bounded.reference_context_item,
@@ -1397,6 +1413,7 @@ async fn bounded_replay_matches_full_replay_after_empty_turn_compactions() {
bounded.window_id,
),
(
full.guardian_history,
full.history,
full.previous_turn_settings,
full.reference_context_item,
@@ -1429,6 +1446,7 @@ async fn reconstruct_history_preserves_legacy_compaction_count_with_session_meta
RolloutItem::Compacted(CompactedItem {
message: "legacy summary".to_string(),
replacement_history: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
@@ -1459,6 +1477,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_does_
RolloutItem::Compacted(CompactedItem {
message: "legacy summary".to_string(),
replacement_history: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
@@ -1497,6 +1516,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
RolloutItem::Compacted(CompactedItem {
message: "legacy summary".to_string(),
replacement_history: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
@@ -1604,6 +1624,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
@@ -1775,6 +1796,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
@@ -2032,6 +2054,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
@@ -2209,6 +2232,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
+5
View File
@@ -2223,6 +2223,7 @@ async fn reconstruct_history_uses_replacement_history_verbatim() {
let rollout_items = vec![RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(replacement_history.clone()),
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(42),
first_window_id: Some(first_window_id.to_string()),
@@ -2903,6 +2904,7 @@ fn latest_token_usage_record_stops_at_compaction_checkpoint() {
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
@@ -4167,6 +4169,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
.map(ResponseItemEnvelope::new)
.collect(),
),
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(7),
first_window_id: Some(first_window_id.to_string()),
@@ -12401,6 +12404,7 @@ async fn sample_rollout(
rollout_items.push(RolloutItem::Compacted(CompactedItem {
message: summary1.to_string(),
replacement_history: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(window_number),
first_window_id: Some(window_ids.first_window_id.to_string()),
@@ -12433,6 +12437,7 @@ async fn sample_rollout(
rollout_items.push(RolloutItem::Compacted(CompactedItem {
message: summary2.to_string(),
replacement_history: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(window_number),
first_window_id: Some(window_ids.first_window_id.to_string()),