mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user