feat: make rollout recorder reliable against errors (#17214)

The rollout writer now keeps an owned/monitored task handle, returns
real Result acks for flush/persist/shutdown, retries failed flushes by
reopening the rollout file, and keeps buffered items until they are
successfully written. Session flushes are now real durability barriers
for fork/rollback/read-after-write paths, while turn completion surfaces
a warning if the rollout still cannot be saved after recovery.
This commit is contained in:
jif-oai authored and GitHub committed 2026-04-10 14:12:33 +01:00
1 parent 085ffb4456
commit 8035cb03f1
10 files changed
+536 -191

No files matched your search

+94 -8
View File
@@ -63,7 +63,7 @@ fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result<Path
}
#[tokio::test]
async fn recorder_materializes_only_after_explicit_persist() -> std::io::Result<()> {
async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result<()> {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let thread_id = ThreadId::new();
@@ -85,7 +85,7 @@ async fn recorder_materializes_only_after_explicit_persist() -> std::io::Result<
let rollout_path = recorder.rollout_path().to_path_buf();
assert!(
!rollout_path.exists(),
"rollout file should not exist before first user message"
"rollout file should not exist before the first recordable item"
);
recorder
@@ -99,8 +99,8 @@ async fn recorder_materializes_only_after_explicit_persist() -> std::io::Result<
.await?;
recorder.flush().await?;
assert!(
!rollout_path.exists(),
"rollout file should remain deferred before first user message"
rollout_path.exists(),
"flush with pending items should materialize the rollout"
);
recorder
@@ -114,10 +114,6 @@ async fn recorder_materializes_only_after_explicit_persist() -> std::io::Result<
))])
.await?;
recorder.flush().await?;
assert!(
!rollout_path.exists(),
"user-message-like items should not materialize without explicit persist"
);
recorder.persist().await?;
// Second call verifies `persist()` is idempotent after materialization.
@@ -146,6 +142,96 @@ async fn recorder_materializes_only_after_explicit_persist() -> std::io::Result<
Ok(())
}
#[tokio::test]
async fn persist_reports_filesystem_error_and_retries_buffered_items() -> std::io::Result<()> {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let thread_id = ThreadId::new();
let recorder = RolloutRecorder::new(
&config,
RolloutRecorderParams::new(
thread_id,
/*forked_from_id*/ None,
SessionSource::Exec,
BaseInstructions::default(),
Vec::new(),
EventPersistenceMode::Limited,
),
/*state_db_ctx*/ None,
/*state_builder*/ None,
)
.await?;
let rollout_path = recorder.rollout_path().to_path_buf();
recorder
.record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage(
AgentMessageEvent {
message: "buffered-before-persist".to_string(),
phase: None,
memory_citation: None,
},
))])
.await?;
let sessions_blocker_path = home.path().join("sessions");
File::create(&sessions_blocker_path)?;
let err = recorder
.persist()
.await
.expect_err("blocked sessions directory should fail persist");
assert_ne!(err.kind(), std::io::ErrorKind::Interrupted);
assert!(
!rollout_path.exists(),
"failed persist should keep the rollout deferred"
);
fs::remove_file(sessions_blocker_path)?;
recorder.flush().await?;
let text = std::fs::read_to_string(&rollout_path)?;
assert!(
text.contains("buffered-before-persist"),
"retry should preserve items buffered before the failed persist"
);
recorder.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn writer_state_retries_write_error_before_reporting_flush_success() -> std::io::Result<()> {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let rollout_path = home.path().join("rollout.jsonl");
File::create(&rollout_path)?;
let read_only_file = std::fs::OpenOptions::new().read(true).open(&rollout_path)?;
let mut state = RolloutWriterState::new(
Some(tokio::fs::File::from_std(read_only_file)),
/*deferred_log_file_info*/ None,
/*meta*/ None,
home.path().to_path_buf(),
rollout_path.clone(),
/*state_db_ctx*/ None,
/*state_builder*/ None,
config.model_provider_id.clone(),
config.generate_memories,
);
state.add_items(vec![RolloutItem::EventMsg(EventMsg::AgentMessage(
AgentMessageEvent {
message: "queued-after-writer-error".to_string(),
phase: None,
memory_citation: None,
},
))]);
state.flush().await?;
let text_after_retry = std::fs::read_to_string(&rollout_path)?;
assert!(
text_after_retry.contains("queued-after-writer-error"),
"flush should retry after reopening and write buffered items"
);
Ok(())
}
#[tokio::test]
async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Result<()> {
let home = TempDir::new().expect("temp dir");