Persist Guardian review failures for reports across restarts (#51651)

## Why

Failed Guardian review evidence was process-local, so restarting Codex lost the context needed for later diagnostic reports.

## What changed

- Persist failed-review records in SQLite before reviewer cleanup, retaining at most 64 records globally, eight per thread, and 8 MiB of evidence. Delete persisted evidence when its reviewed thread is deleted.
- Recover and deduplicate persisted and in-memory records for the reported task tree when uploading `auto-review-failures.jsonl`.
- Bound persistence attempts to 250 ms and retain an in-memory fallback when storage is unavailable. Keep capture gated by user opt-in and non-ephemeral sessions, and export evidence only when logs are included.

## Testing

Add coverage for persistence across reopen and process restart, retention limits, cascading deletion, busy-storage fallback, and report uploads with and without logs, including SQLite log failures.

GitOrigin-RevId: ea2da1818d4a5da3da29f211042ca45e834342bc
This commit is contained in:
jif
2026-10-07 11:18:05 +00:00
committed by copyberry
parent 37eaae6eeb
commit 30bdfec59d
17 changed files with 442 additions and 74 deletions
+75 -29
View File
@@ -1,15 +1,20 @@
//! Bounded process-local failed-review evidence, shared by root and child reviewers.
//! Bounded durable failed-review evidence, with an in-memory fallback for storage failures.
//! Captures are exported only by a feedback request that includes logs.
use crate::FeedbackAttachment;
use codex_protocol::ThreadId;
pub use codex_state::GuardianReviewRecord;
use codex_state::MAX_GUARDIAN_REVIEW_BYTES as MAX_BYTES;
use codex_state::MAX_GUARDIAN_REVIEW_RECORDS as MAX_RECORDS;
use codex_state::MAX_GUARDIAN_REVIEW_RECORDS_PER_THREAD as MAX_RECORDS_PER_THREAD;
use codex_state::StateRuntime;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::sync::Mutex;
use std::time::Duration;
const PERSIST_TIMEOUT: Duration = Duration::from_millis(250);
const MAX_RECORDS: usize = 64;
const MAX_RECORDS_PER_THREAD: usize = 8;
const MAX_BYTES: usize = 8 * 1024 * 1024;
static RECORDS: Mutex<ReviewRecords> = Mutex::new(ReviewRecords {
records: VecDeque::new(),
bytes: 0,
@@ -25,47 +30,51 @@ pub struct GuardianReviewFailures {
pub process_discarded_records: usize,
}
#[derive(Default)]
#[derive(Clone, Default)]
struct ReviewRecords {
records: VecDeque<(ThreadId, Vec<u8>)>,
records: VecDeque<GuardianReviewRecord>,
bytes: usize,
discarded_records: usize,
}
impl ReviewRecords {
fn push(&mut self, thread_id: ThreadId, record: Vec<u8>) {
if record.len() + 1 > MAX_BYTES {
fn push(&mut self, record: GuardianReviewRecord) {
if record.record.len() + 1 > MAX_BYTES {
self.discarded_records = self.discarded_records.saturating_add(1);
return;
}
if self
.records
.iter()
.filter(|(id, _)| *id == thread_id)
.filter(|entry| entry.thread_id == record.thread_id)
.count()
>= MAX_RECORDS_PER_THREAD
&& let Some(index) = self.records.iter().position(|(id, _)| *id == thread_id)
&& let Some((_, removed)) = self.records.remove(index)
&& let Some(index) = self
.records
.iter()
.position(|entry| entry.thread_id == record.thread_id)
&& let Some(removed) = self.records.remove(index)
{
self.bytes -= removed.len() + 1;
self.bytes -= removed.record.len() + 1;
self.discarded_records = self.discarded_records.saturating_add(1);
}
while self.records.len() >= MAX_RECORDS || self.bytes + record.len() + 1 > MAX_BYTES {
if let Some((_, removed)) = self.records.pop_front() {
self.bytes -= removed.len() + 1;
while self.records.len() >= MAX_RECORDS || self.bytes + record.record.len() + 1 > MAX_BYTES
{
if let Some(removed) = self.records.pop_front() {
self.bytes -= removed.record.len() + 1;
self.discarded_records = self.discarded_records.saturating_add(1);
}
}
self.bytes += record.len() + 1;
self.records.push_back((thread_id, record));
self.bytes += record.record.len() + 1;
self.records.push_back(record);
}
fn snapshot(&self, thread_ids: &[ThreadId]) -> GuardianReviewFailures {
let thread_ids = thread_ids.iter().copied().collect::<HashSet<_>>();
let mut buffer = Vec::new();
for (thread_id, record) in &self.records {
if thread_ids.contains(thread_id) {
buffer.extend_from_slice(record);
for record in &self.records {
if thread_ids.contains(&record.thread_id) {
buffer.extend_from_slice(&record.record);
buffer.push(b'\n');
}
}
@@ -79,8 +88,10 @@ impl ReviewRecords {
.records
.iter()
.rev()
.filter(|(id, _)| thread_ids.contains(id) && seen.insert(*id))
.map(|(id, _)| *id)
.filter(|record| {
thread_ids.contains(&record.thread_id) && seen.insert(record.thread_id)
})
.map(|record| record.thread_id)
.collect();
GuardianReviewFailures {
attachment,
@@ -90,20 +101,55 @@ impl ReviewRecords {
}
}
/// Retain one complete, serialized failed-review JSON record for later opt-in feedback.
pub fn record_guardian_review_failure(thread_id: ThreadId, record: Vec<u8>) {
/// Retain a failed review in memory and persist it before its session is cleaned up.
pub async fn record_guardian_review_failure(
state_db: Option<&StateRuntime>,
record: GuardianReviewRecord,
) {
RECORDS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(thread_id, record);
.push(record.clone());
if let Some(state_db) = state_db
&& !matches!(
tokio::time::timeout(
PERSIST_TIMEOUT,
state_db.record_guardian_review_failure(&record)
)
.await,
Ok(Ok(()))
)
{
tracing::warn!("failed to persist Guardian feedback; retaining an in-memory fallback");
}
}
/// Snapshot failures for the reported task tree without including other tasks' records.
pub fn guardian_review_failures(thread_ids: &[ThreadId]) -> GuardianReviewFailures {
RECORDS
/// Recover retained failures before selecting rollout attachments, scoped to the reported task tree.
pub async fn guardian_review_failures(
state_db: Option<&StateRuntime>,
thread_ids: &[ThreadId],
) -> GuardianReviewFailures {
let fallback = RECORDS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.snapshot(thread_ids)
.clone();
let Some(state_db) = state_db else {
return fallback.snapshot(thread_ids);
};
let Ok(mut records) = state_db.list_guardian_review_records().await else {
tracing::warn!("failed to load persisted Guardian feedback");
return fallback.snapshot(thread_ids);
};
records.extend(fallback.records);
records.sort_unstable_by(|left, right| left.id.cmp(&right.id));
records.dedup_by(|left, right| left.id == right.id);
let mut retained = ReviewRecords::default();
for record in records {
retained.push(record);
}
// This legacy diagnostic describes only the in-memory fallback, not durable evictions.
retained.discarded_records = fallback.discarded_records;
retained.snapshot(thread_ids)
}
#[cfg(test)]
+42 -5
View File
@@ -7,10 +7,19 @@ fn records_keep_recent_failures_and_select_the_requested_tree() {
let root = ThreadId::new();
let child = ThreadId::new();
let unrelated = ThreadId::new();
records.push(child, br#"{"child":"denied"}"#.to_vec());
records.push(unrelated, br#"{"unrelated":"denied"}"#.to_vec());
records.push(GuardianReviewRecord::new(
child,
br#"{"child":"denied"}"#.to_vec(),
));
records.push(GuardianReviewRecord::new(
unrelated,
br#"{"unrelated":"denied"}"#.to_vec(),
));
for index in 0..12 {
records.push(root, format!(r#"{{"root":{index}}}"#).into_bytes());
records.push(GuardianReviewRecord::new(
root,
format!(r#"{{"root":{index}}}"#).into_bytes(),
));
}
let snapshot = records.snapshot(&[root, child]);
let attachment = snapshot.attachment.expect("task-tree records");
@@ -30,13 +39,13 @@ fn records_keep_recent_failures_and_select_the_requested_tree() {
fn record_count_and_bytes_are_bounded_across_threads() {
let mut records = ReviewRecords::default();
for _ in 0..MAX_RECORDS + 1 {
records.push(ThreadId::new(), b"{}".to_vec());
records.push(GuardianReviewRecord::new(ThreadId::new(), b"{}".to_vec()));
}
assert_eq!(records.records.len(), MAX_RECORDS);
let payload = vec![b' '; MAX_BYTES / 3];
let thread_id = ThreadId::new();
for _ in 0..4 {
records.push(thread_id, payload.clone());
records.push(GuardianReviewRecord::new(thread_id, payload.clone()));
}
assert!(records.bytes <= MAX_BYTES);
assert_eq!(records.records.len(), 2);
@@ -51,3 +60,31 @@ fn record_count_and_bytes_are_bounded_across_threads() {
);
assert_eq!(records.discarded_records, MAX_RECORDS + 3);
}
#[tokio::test]
async fn busy_storage_retains_feedback_without_delaying_review_completion() -> anyhow::Result<()> {
let home = tempfile::tempdir()?;
let sqlite = codex_state::SqliteConfig::new_for_testing(home.path().try_into()?);
let state = StateRuntime::init(sqlite.clone(), "test".to_string()).await?;
let thread_id = ThreadId::new();
let pool = sqlite.open_read_write_pool(&sqlite.state_db_path()).await?;
let transaction = pool.begin_with("BEGIN IMMEDIATE").await?;
tokio::time::timeout(
Duration::from_secs(/*secs*/ 1),
record_guardian_review_failure(
Some(&state),
GuardianReviewRecord::new(thread_id, br#"{"fallback":true}"#.to_vec()),
),
)
.await?;
transaction.rollback().await?;
let snapshot = guardian_review_failures(Some(&state), &[thread_id]).await;
assert_eq!(snapshot.thread_ids, vec![thread_id]);
assert_eq!(
snapshot.attachment.unwrap().buffer,
b"{\"fallback\":true}\n"
);
pool.close().await;
state.close().await;
Ok(())
}
+1
View File
@@ -42,6 +42,7 @@ pub use feedback_diagnostics::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME;
pub use feedback_diagnostics::FeedbackDiagnostic;
pub use feedback_diagnostics::FeedbackDiagnostics;
pub use guardian::GuardianReviewFailures;
pub use guardian::GuardianReviewRecord;
pub use guardian::guardian_review_failures;
pub use guardian::record_guardian_review_failure;
pub use report_upload::FeedbackDelivery;