Preserve independent Guardian history across parent compaction (#48779)

## Why

Guardian reviews need to retain original evidence across parent compaction when checkpoint reuse is disabled, including after resume and rollback.

## What changed

- Make disabling `guardian_reuse_parent_compaction` select a bounded, independent review transcript while preserving thread-owned authorization.
- Keep synchronous reviews and asynchronous scoring independent of parent checkpoints, and allow reviewer sessions to continue using transcript deltas across parent compaction.
- Persist transcript entries with rollback provenance while remaining compatible with older checkpoint readers and metadata-free checkpoints.
- Exclude compaction output from the transcript and synthetic summaries from rollback turn boundaries. Use acceptance ordering to remove rolled-back evidence even when persistence order differs.

## Testing

Add regression coverage for reviewer continuity, evidence retention across compaction and resume, rollback after local and remote compaction, and checkpoint serialization compatibility.

GitOrigin-RevId: fea0852cc5537372854f4a34bacb6bfda6d13e88
This commit is contained in:
felixxia-oai
2026-09-27 20:15:23 +00:00
committed by copyberry
parent 89bf86d0bd
commit 21eb35513d
29 changed files with 523 additions and 140 deletions
+58 -30
View File
@@ -76,7 +76,9 @@ use std::sync::OnceLock;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use crate::config::ManagedFeatures;
use crate::context::GuardianContextMode;
use codex_features::Feature;
static GUARDIAN_REVIEW_CONTEXT_REVISION: AtomicU64 = AtomicU64::new(/*v*/ 1);
@@ -152,6 +154,10 @@ impl ConversationHistorySnapshot for SharedConversationHistory {
self.guardian_review_mode == GuardianContextMode::ThreadOwned
}
fn uses_independent_review_history(&self) -> bool {
self.guardian_review_mode == GuardianContextMode::Independent
}
fn review_items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_> {
Box::new(self.review_items_with_sources().map(|(item, _)| item))
}
@@ -160,7 +166,7 @@ impl ConversationHistorySnapshot for SharedConversationHistory {
&self,
) -> Box<dyn Iterator<Item = (&ResponseItem, Option<&codex_history::RetainedSource>)> + Send + '_>
{
if self.guardian_review_mode == GuardianContextMode::Legacy {
if self.guardian_review_mode != GuardianContextMode::ThreadOwned {
let items = self
.review_history
.as_ref()
@@ -171,7 +177,7 @@ impl ConversationHistorySnapshot for SharedConversationHistory {
}
fn review_history_version(&self) -> u64 {
if self.guardian_review_mode == GuardianContextMode::Legacy {
if self.guardian_review_mode != GuardianContextMode::ThreadOwned {
return self
.review_history
.as_ref()
@@ -259,9 +265,16 @@ impl ContextManager {
&self.retained_context
}
pub(crate) fn for_session(source: &SessionSource) -> Self {
pub(crate) fn for_session(source: &SessionSource, features: &ManagedFeatures) -> Self {
let independent = !features.enabled(Feature::GuardianReuseParentCompaction);
Self {
retain_inherited_user_messages: !source.is_non_root_agent(),
guardian_review_mode: if independent {
GuardianContextMode::Independent
} else {
GuardianContextMode::ThreadOwned
},
review_history: independent.then(TranscriptHistory::default),
..Self::new()
}
}
@@ -294,8 +307,9 @@ impl ContextManager {
}
pub(crate) fn guardian_history_checkpoint(&self) -> Option<GuardianHistoryCheckpoint> {
self.guardian_history_items()
.map(|items| GuardianHistoryCheckpoint(items.cloned().collect()))
self.review_history
.as_ref()
.map(TranscriptHistory::checkpoint)
}
pub(crate) fn restore_review_context(
@@ -305,7 +319,8 @@ impl ContextManager {
reviewer_compaction_hash: Option<&str>,
) {
// A previously promoted checkpoint may have discarded the only complete transcript.
// Keep requiring parent context in that case; a compatibility failure must not turn
// Unless independent review was explicitly selected, keep requiring parent context;
// a compatibility failure must not turn
// a partial model window into a legacy fallback. Migrating checkpoints keep a backup
// and can expose retained facts independently of which transcript review uses.
let requires_parent_context = checkpoint.is_none()
@@ -349,7 +364,10 @@ impl ContextManager {
}
})
});
self.guardian_review_mode = if requires_parent_context {
self.guardian_review_mode = if self.guardian_review_mode == GuardianContextMode::Independent
{
GuardianContextMode::Independent
} else if requires_parent_context {
GuardianContextMode::ThreadOwned
} else {
GuardianContextMode::for_checkpoint(&self.items, reviewer_compaction_hash)
@@ -362,7 +380,7 @@ impl ContextManager {
// Prefer the backup over a compacted copy that retains the original ID.
let original = checkpoint
.into_iter()
.flat_map(|checkpoint| &checkpoint.0)
.flat_map(|checkpoint| checkpoint.0.iter().map(|entry| &entry.item))
.chain(items.iter().map(|envelope| &envelope.item))
.find(|item| item.id().is_some_and(|item_id| item_id.as_str() == id));
let Some(TurnItem::UserMessage(original)) = original.and_then(parse_turn_item) else {
@@ -392,10 +410,13 @@ impl ContextManager {
if let Some(checkpoint) = checkpoint {
history.reset(checkpoint.0.iter());
} else {
// Retain the legacy window through replay, including answers captured in its suffix.
// Old checkpoints may predate independent capture. Start with the surviving
// raw window; retained instructions/answers still report their own omissions.
// Subsequent checkpoints persist this bounded transcript across compaction.
history.reset(
self.raw_items()
.filter(|item| !is_guardian_context_message(item)),
self.annotated_items()
.iter()
.filter(|entry| !is_guardian_context_message(&entry.item)),
);
}
self.review_history = Some(history);
@@ -535,7 +556,7 @@ impl ContextManager {
if let Some(review_history) = &mut self.review_history
&& !is_guardian_context_message(item)
{
review_history.record(&processed.item);
review_history.record(&processed);
}
if let Some(metadata) = metadata
&& Arc::make_mut(&mut self.retained_context).record_sender_user_messages(metadata)
@@ -652,8 +673,7 @@ impl ContextManager {
review_history.reset(
items
.iter()
.map(|item| &item.item)
.filter(|item| !is_guardian_context_message(item)),
.filter(|entry| !is_guardian_context_message(&entry.item)),
);
}
self.items = Arc::new(items);
@@ -686,8 +706,9 @@ impl ContextManager {
{
let mut retained = TranscriptHistory::new(self.history_version.saturating_add(1));
for item in self
.raw_items()
.filter(|item| !is_guardian_context_message(item))
.annotated_items()
.iter()
.filter(|entry| !is_guardian_context_message(&entry.item))
{
retained.record(item);
}
@@ -745,24 +766,26 @@ impl ContextManager {
let source = RetainedInputSource::from(snapshot[cut_idx].metadata.as_ref());
let mut review_history = self.review_history.take();
if let Some(history) = &mut review_history {
history.truncate_before(&snapshot[cut_idx].item);
history.truncate_before(&snapshot[cut_idx]);
}
cut_idx =
self.trim_pre_turn_context_updates(&snapshot, first_instruction_turn_idx, cut_idx);
let mut retained_items = snapshot[..cut_idx].to_vec();
if let Some(boundary) = source.acceptance_order() {
// A later assistant item may have finished before an earlier-accepted
// steer was persisted. Drop its raw source too, so recovery cannot
// reintroduce context removed at the retained rollback boundary.
retained_items.retain(|envelope| {
!(matches!(&envelope.item, ResponseItem::Message { role, .. } if role == "assistant")
|| matches!(&envelope.item, ResponseItem::FunctionCall { .. }))
|| RetainedInputSource::from(envelope.metadata.as_ref())
.acceptance_order().is_none_or(|order| order < boundary)
});
}
// Apply the same acceptance boundary to the parent model window. The independent
// transcript owns its rollback provenance even after this window is compacted.
let mut retained_items = snapshot[..cut_idx]
.iter()
.filter(|envelope| {
!source.acceptance_order().is_some_and(|boundary| {
(matches!(&envelope.item, ResponseItem::Message { role, .. } if role == "assistant")
|| matches!(&envelope.item, ResponseItem::FunctionCall { .. }))
&& RetainedInputSource::from(envelope.metadata.as_ref())
.acceptance_order().is_some_and(|order| order >= boundary)
})
})
.cloned()
.collect::<Vec<_>>();
if cut_idx == first_instruction_turn_idx
&& let Some(first_turn_id) = snapshot[first_instruction_turn_idx].turn_id()
{
@@ -1316,7 +1339,12 @@ pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool {
return false;
};
(role == "user" && !is_contextual_user_message_content(content))
(role == "user"
&& !is_contextual_user_message_content(content)
// Local compaction persists its synthetic summary as a user-role message.
// It must not consume a rollback turn or become the transcript boundary.
&& !content.iter().any(|part| matches!(part,
ContentItem::InputText { text } if crate::compact::is_summary_message(text))))
|| (role == "assistant" && is_inter_agent_instruction_content(content))
}
@@ -112,6 +112,13 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem {
}
}
fn root_history() -> ContextManager {
ContextManager::for_session(
&SessionSource::Cli,
&ManagedFeatures::from(codex_features::Features::with_defaults()),
)
}
fn create_history_with_items(items: Vec<ResponseItem>) -> ContextManager {
let mut h = ContextManager::new();
// Use a generous but fixed token budget; tests only rely on truncation
@@ -220,7 +227,7 @@ fn conversation_history_snapshot_binds_review_mode_and_hash_to_the_latest_item(
..Default::default()
}),
};
let mut history = ContextManager::for_session(&codex_protocol::protocol::SessionSource::Cli);
let mut history = root_history();
history.replace_annotated(vec![checkpoint.clone()]);
history.restore_review_context(
/*retained_context*/ None,
@@ -304,7 +311,7 @@ fn conversation_history_snapshot_binds_review_mode_and_hash_to_the_latest_item(
}]
}); "retained assistant context")]
fn checkpoint_retained_evidence_survives_legacy_review(saved_context: serde_json::Value) {
let mut history = ContextManager::for_session(&codex_protocol::protocol::SessionSource::Cli);
let mut history = root_history();
history.replace_annotated(vec![ResponseItemEnvelope::new(
serde_json::from_value(serde_json::json!({
"type": "compaction", "id": "unknown", "encrypted_content": "opaque checkpoint"
@@ -316,9 +323,9 @@ fn checkpoint_retained_evidence_survives_legacy_review(saved_context: serde_json
for (checkpoint, expected_mode) in [
(None, GuardianContextMode::ThreadOwned),
(
Some(GuardianHistoryCheckpoint(vec![assistant_msg(
"Original transcript",
)])),
Some(GuardianHistoryCheckpoint(vec![
assistant_msg("Original transcript").into(),
])),
GuardianContextMode::Legacy,
),
] {
@@ -340,7 +347,7 @@ fn checkpoint_retained_evidence_survives_legacy_review(saved_context: serde_json
#[test]
fn checkpoint_replayed_messages_keep_legacy_review_when_the_source_survives() {
let mut history = ContextManager::for_session(&codex_protocol::protocol::SessionSource::Cli);
let mut history = root_history();
history.replace_annotated(vec![ResponseItemEnvelope::new(
serde_json::from_value(serde_json::json!({
"type": "compaction", "id": "unknown", "encrypted_content": "opaque checkpoint"
@@ -386,7 +393,7 @@ fn plaintext_checkpoint_without_backup_preserves_root_instructions(
.expect("old instruction without ordering metadata");
let saved_context: Option<RetainedContext> =
saved_context.map(|value| serde_json::from_value(value).expect("legacy retained context"));
let mut history = ContextManager::for_session(&SessionSource::Cli);
let mut history = root_history();
history.replace(vec![instruction]);
history.restore_review_context(
saved_context.as_ref(),
@@ -407,7 +414,7 @@ fn plaintext_checkpoint_without_backup_preserves_root_instructions(
}),
}];
history.replace_compacted(compacted.clone(), Some("reviewer"));
let mut resumed = ContextManager::for_session(&SessionSource::Cli);
let mut resumed = root_history();
resumed.replace_annotated(compacted.clone());
resumed.restore_review_context(
Some(history.retained_context()),
@@ -464,7 +471,7 @@ fn legacy_checkpoint_rollback_keeps_answers_before_a_same_turn_steer() {
.verified_answers()
.cloned()
.collect::<Vec<_>>();
let mut history = ContextManager::for_session(&SessionSource::Cli);
let mut history = root_history();
history.replace_annotated(checkpoint.replacement_history.take().unwrap());
history.restore_review_context(
checkpoint.retained_context.as_ref(),
@@ -1576,7 +1583,7 @@ fn drop_last_n_user_turns_preserves_prefix() {
// A steered message shares its source turn, but rollback must keep the earlier
// instruction and answer as complete evidence, including after the next compaction.
let mut history = ContextManager::for_session(&codex_protocol::protocol::SessionSource::Exec);
let mut history = root_history();
let mut expected = None;
for (id, text) in [
("restriction", "Never publish publicly."),
@@ -1626,7 +1633,7 @@ fn drop_last_n_user_turns_preserves_prefix() {
#[test]
fn rollback_removes_assistant_sources_recorded_ahead_of_queued_input() {
let mut history = ContextManager::for_session(&codex_protocol::protocol::SessionSource::Exec);
let mut history = root_history();
let original = user_msg("Staging only.");
let mut items = [
(original.clone(), 0),