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

+14 -1
View File
@@ -1,7 +1,7 @@
//! Bounded, chronological review evidence independent of the parent's compaction.
//!
//! User messages and other entries have separate limits and keep their original order.
//! Hosts append original items and reset this history on rollback or reconstruction.
//! Hosts append original items, restore bounded checkpoints, and trim rolled-back turns.
//! Clones share immutable payloads; eviction changes the generation so readers cannot
//! reuse an offset into a different retained prefix. Prompt selection remains caller-owned.
@@ -142,6 +142,19 @@ impl TranscriptHistory {
}
}
/// Removes a rolled-back boundary and everything after it. If retention already
/// evicted the boundary, discard the remaining evidence rather than keep later grants.
pub fn truncate_before(&mut self, boundary: &ResponseItem) {
let index = self.items.iter().position(|(item, _)| {
item.id()
.zip(boundary.id())
.is_some_and(|(item_id, boundary_id)| item_id == boundary_id)
|| item.as_ref() == boundary
});
self.items.truncate(index.unwrap_or(0));
self.generation = self.generation.saturating_add(1);
}
/// Generation of the retained prefix, independent of parent compaction.
pub fn generation(&self) -> u64 {
self.generation
@@ -18,6 +18,19 @@ fn tool(text: &str) -> ResponseItem {
.unwrap()
}
#[test]
fn rollback_keeps_the_earlier_prefix_or_clears_an_evicted_boundary() {
let items = [message("keep"), message("roll back"), tool("later")];
let mut history = TranscriptHistory::default();
history.reset(items.iter());
let generation = history.generation();
history.truncate_before(&items[1]);
assert_eq!(history.items().collect::<Vec<_>>(), vec![&items[0]]);
assert!(history.generation() > generation);
history.truncate_before(&items[1]);
assert_eq!(history.items().count(), 0);
}
#[test]
fn each_kind_evicts_its_own_oldest_entries_without_reordering() {
let users = [message("first"), message("second"), message("third")];