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
2026-04-10 14:12:33 +01:00
committed by GitHub
Unverified
parent 085ffb4456
commit 8035cb03f1
10 changed files with 536 additions and 191 deletions
+2 -2
View File
@@ -362,7 +362,7 @@ impl AgentControl {
.session
.ensure_rollout_materialized()
.await;
parent_thread.codex.session.flush_rollout().await;
parent_thread.codex.session.flush_rollout().await?;
}
let rollout_path = parent_thread
@@ -663,7 +663,7 @@ impl AgentControl {
let state = self.upgrade()?;
let result = if let Ok(thread) = state.get_thread(agent_id).await {
thread.codex.session.ensure_rollout_materialized().await;
thread.codex.session.flush_rollout().await;
thread.codex.session.flush_rollout().await?;
if matches!(thread.agent_status().await, AgentStatus::Shutdown) {
Ok(String::new())
} else {
+18 -3
View File
@@ -194,7 +194,12 @@ async fn persist_thread_for_tree_resume(thread: &Arc<CodexThread>, message: &str
.inject_user_message_without_turn(message.to_string())
.await;
thread.codex.session.ensure_rollout_materialized().await;
thread.codex.session.flush_rollout().await;
thread
.codex
.session
.flush_rollout()
.await
.expect("test thread rollout should flush");
}
async fn wait_for_live_thread_spawn_children(
@@ -624,7 +629,12 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
.session
.ensure_rollout_materialized()
.await;
parent_thread.codex.session.flush_rollout().await;
parent_thread
.codex
.session
.flush_rollout()
.await
.expect("parent rollout should flush");
let child_thread_id = harness
.control
@@ -821,7 +831,12 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
.session
.ensure_rollout_materialized()
.await;
parent_thread.codex.session.flush_rollout().await;
parent_thread
.codex
.session
.flush_rollout()
.await
.expect("parent rollout should flush");
let child_thread_id = harness
.control
+22 -11
View File
@@ -2216,16 +2216,16 @@ impl Session {
self.services.state_db.clone()
}
/// Ensure rollout file writes are durably flushed.
pub(crate) async fn flush_rollout(&self) {
/// Flush rollout writes and return the final durability-barrier result.
pub(crate) async fn flush_rollout(&self) -> std::io::Result<()> {
let recorder = {
let guard = self.services.rollout.lock().await;
guard.clone()
};
if let Some(rec) = recorder
&& let Err(e) = rec.flush().await
{
warn!("failed to flush rollout recorder: {e}");
if let Some(recorder) = recorder {
recorder.flush().await
} else {
Ok(())
}
}
@@ -2372,7 +2372,7 @@ impl Session {
// Defer seeding the session's initial context until the first turn starts so
// turn/start overrides can be merged before we write to the rollout.
if !is_subagent {
self.flush_rollout().await;
let _ = self.flush_rollout().await;
}
}
InitialHistory::Forked(rollout_items) => {
@@ -2396,7 +2396,7 @@ impl Session {
// Flush after seeding history and any persisted rollout copy.
if !is_subagent {
self.flush_rollout().await;
let _ = self.flush_rollout().await;
}
}
}
@@ -5600,13 +5600,24 @@ mod handlers {
.into_iter()
.chain(std::iter::once(RolloutItem::EventMsg(rollback_msg.clone())))
.collect::<Vec<_>>();
sess.persist_rollout_items(&[RolloutItem::EventMsg(rollback_msg.clone())])
.await;
sess.flush_rollout().await;
sess.apply_rollout_reconstruction(turn_context.as_ref(), replay_items.as_slice())
.await;
sess.recompute_token_usage(turn_context.as_ref()).await;
sess.persist_rollout_items(&[RolloutItem::EventMsg(rollback_msg.clone())])
.await;
if let Err(err) = sess.flush_rollout().await {
sess.send_event(
turn_context.as_ref(),
EventMsg::Warning(WarningEvent {
message: format!(
"Rolled the thread back, but failed to save the rollback marker. Codex will continue retrying. Error: {err}"
),
}),
)
.await;
}
sess.deliver_event_raw(Event {
id: turn_context.sub_id.clone(),
msg: rollback_msg,
+11 -4
View File
@@ -1313,7 +1313,11 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
// Forking reads the persisted rollout JSONL, so force the completed source turn to disk
// before snapshotting from it.
initial.codex.ensure_rollout_materialized().await;
initial.codex.flush_rollout().await;
initial
.codex
.flush_rollout()
.await
.expect("source rollout should flush before fork");
let mut fork_config = initial.config.clone();
fork_config.permissions.approval_policy =
@@ -2359,7 +2363,10 @@ async fn attach_rollout_recorder(session: &Arc<Session>) -> PathBuf {
*rollout = Some(recorder);
}
session.ensure_rollout_materialized().await;
session.flush_rollout().await;
session
.flush_rollout()
.await
.expect("attached rollout should flush");
rollout_path
}
@@ -4422,7 +4429,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline
.expect("serialize expected context item")
);
session.ensure_rollout_materialized().await;
session.flush_rollout().await;
session.flush_rollout().await.expect("rollout should flush");
let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path)
.await
@@ -4524,7 +4531,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei
.record_context_updates_and_set_reference_context_item(&turn_context)
.await;
session.ensure_rollout_materialized().await;
session.flush_rollout().await;
session.flush_rollout().await.expect("rollout should flush");
let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path)
.await
+2 -2
View File
@@ -82,8 +82,8 @@ impl CodexThread {
}
#[doc(hidden)]
pub async fn flush_rollout(&self) {
self.codex.session.flush_rollout().await;
pub async fn flush_rollout(&self) -> std::io::Result<()> {
self.codex.session.flush_rollout().await
}
pub async fn submit_with_trace(
+1 -1
View File
@@ -564,7 +564,7 @@ async fn append_guardian_followup_reminder(review_session: &GuardianReviewSessio
async fn load_rollout_items_for_fork(
session: &Session,
) -> anyhow::Result<Option<Vec<RolloutItem>>> {
session.flush_rollout().await;
session.flush_rollout().await?;
let Some(rollout_path) = session.current_rollout_path().await else {
return Ok(None);
};
+6 -1
View File
@@ -689,7 +689,12 @@ mod phase2 {
other => panic!("unexpected sandbox policy: {other:?}"),
}
subagent.codex.session.ensure_rollout_materialized().await;
subagent.codex.session.flush_rollout().await;
subagent
.codex
.session
.flush_rollout()
.await
.expect("subagent rollout should flush");
let rollout_path = subagent
.rollout_path()
.expect("consolidation thread should have a rollout path");
+16 -2
View File
@@ -46,6 +46,7 @@ use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use codex_features::Feature;
@@ -302,7 +303,18 @@ impl Session {
)
.await;
let sess = session_ctx.clone_session();
sess.flush_rollout().await;
if let Err(err) = sess.flush_rollout().await {
warn!("failed to flush rollout before completing turn: {err}");
sess.send_event(
ctx_for_finish.as_ref(),
EventMsg::Warning(WarningEvent {
message: format!(
"Failed to save the conversation transcript; Codex will continue retrying. Error: {err}"
),
}),
)
.await;
}
if !task_cancellation_token.is_cancelled() {
// Emit completion uniformly from spawn site so all tasks share the same lifecycle.
sess.on_task_finished(Arc::clone(&ctx_for_finish), last_agent_message)
@@ -591,7 +603,9 @@ impl Session {
.await;
// Ensure the marker is durably visible before emitting TurnAborted: some clients
// synchronously re-read the rollout on receipt of the abort event.
self.flush_rollout().await;
if let Err(err) = self.flush_rollout().await {
warn!("failed to flush interrupted-turn marker before emitting TurnAborted: {err}");
}
}
let (completed_at, duration_ms) = task
+364 -157
View File
@@ -5,6 +5,8 @@ use std::fs::File;
use std::io::Error as IoError;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use chrono::SecondsFormat;
use chrono::Utc;
@@ -21,6 +23,8 @@ use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc;
use tokio::sync::mpsc::Sender;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use tracing::error;
use tracing::info;
use tracing::trace;
use tracing::warn;
@@ -70,6 +74,7 @@ use codex_utils_path as path_utils;
#[derive(Clone)]
pub struct RolloutRecorder {
tx: Sender<RolloutCmd>,
writer_task: Arc<RolloutWriterTask>,
pub(crate) rollout_path: PathBuf,
state_db: Option<StateDbHandle>,
event_persistence_mode: EventPersistenceMode,
@@ -98,13 +103,60 @@ enum RolloutCmd {
},
/// Ensure all prior writes are processed; respond when flushed.
Flush {
ack: oneshot::Sender<()>,
ack: oneshot::Sender<std::io::Result<()>>,
},
Shutdown {
ack: oneshot::Sender<()>,
ack: oneshot::Sender<std::io::Result<()>>,
},
}
/// Observable state for the background rollout writer task.
struct RolloutWriterTask {
handle: Mutex<Option<JoinHandle<()>>>,
terminal_failure: Mutex<Option<Arc<IoError>>>,
}
impl RolloutWriterTask {
/// Create task observability state before spawning the writer.
fn new() -> Self {
Self {
handle: Mutex::new(None),
terminal_failure: Mutex::new(None),
}
}
/// Store the spawned task handle so it remains owned for the lifetime of recorder clones.
fn set_handle(&self, handle: JoinHandle<()>) {
let mut guard = self
.handle
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = Some(handle);
}
/// Remember a terminal task failure for future recorder API calls.
fn mark_failed(&self, err: &IoError) {
let mut guard = self
.terminal_failure
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = Some(Arc::new(clone_io_error(err)));
}
/// Return the terminal writer-task failure, if the task exited with an error.
fn terminal_failure(&self) -> Option<IoError> {
let guard = self
.terminal_failure
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.as_ref().map(|err| clone_io_error(err.as_ref()))
}
}
fn clone_io_error(err: &IoError) -> IoError {
IoError::new(err.kind(), err.to_string())
}
impl RolloutRecorderParams {
pub fn new(
conversation_id: ThreadId,
@@ -472,21 +524,41 @@ impl RolloutRecorder {
// Spawn a Tokio task that owns the file handle and performs async
// writes. Using `tokio::fs::File` keeps everything on the async I/O
// driver instead of blocking the runtime.
tokio::task::spawn(rollout_writer(
file,
deferred_log_file_info,
rx,
meta,
cwd,
rollout_path.clone(),
state_db_ctx.clone(),
state_builder,
config.model_provider_id().to_string(),
config.generate_memories(),
));
let writer_task = Arc::new(RolloutWriterTask::new());
let writer_task_for_spawn = Arc::clone(&writer_task);
let rollout_path_for_spawn = rollout_path.clone();
let default_provider = config.model_provider_id().to_string();
let generate_memories = config.generate_memories();
let state_db_ctx_for_spawn = state_db_ctx.clone();
let handle = tokio::task::spawn(async move {
let result = rollout_writer(
file,
deferred_log_file_info,
rx,
meta,
cwd,
rollout_path_for_spawn.clone(),
state_db_ctx_for_spawn,
state_builder,
default_provider,
generate_memories,
)
.await;
if let Err(err) = result {
// This is the terminal background-task failure path. Normal I/O failures stay inside
// `rollout_writer`, are reported through command acks, and leave items buffered for retry.
error!(
"rollout writer task failed for {}: {err}",
rollout_path_for_spawn.display()
);
writer_task_for_spawn.mark_failed(&err);
}
});
writer_task.set_handle(handle);
Ok(Self {
tx,
writer_task,
rollout_path,
state_db: state_db_ctx,
event_persistence_mode,
@@ -520,31 +592,53 @@ impl RolloutRecorder {
self.tx
.send(RolloutCmd::AddItems(filtered))
.await
.map_err(|e| IoError::other(format!("failed to queue rollout items: {e}")))
.map_err(|e| {
self.writer_task.terminal_failure().unwrap_or_else(|| {
IoError::other(format!("failed to queue rollout items: {e}"))
})
})
}
/// Materialize the rollout file and persist all buffered items.
///
/// This is idempotent; after first materialization, repeated calls are no-ops.
/// This is idempotent. If materialization fails, the recorder keeps all pending items in memory
/// and a later `persist()` or `flush()` can retry opening and writing the rollout file.
pub async fn persist(&self) -> std::io::Result<()> {
let (tx, rx) = oneshot::channel();
self.tx
.send(RolloutCmd::Persist { ack: tx })
.await
.map_err(|e| IoError::other(format!("failed to queue rollout persist: {e}")))?;
rx.await
.map_err(|e| IoError::other(format!("failed waiting for rollout persist: {e}")))?
.map_err(|e| {
self.writer_task.terminal_failure().unwrap_or_else(|| {
IoError::other(format!("failed to queue rollout persist: {e}"))
})
})?;
rx.await.map_err(|e| {
self.writer_task.terminal_failure().unwrap_or_else(|| {
IoError::other(format!("failed waiting for rollout persist: {e}"))
})
})?
}
/// Flush all queued writes and wait until they are committed by the writer task.
///
/// If the first writer attempt fails, the writer drops and reopens the file handle before
/// retrying. This returns an error only when that retry also fails or the writer task is gone.
pub async fn flush(&self) -> std::io::Result<()> {
let (tx, rx) = oneshot::channel();
self.tx
.send(RolloutCmd::Flush { ack: tx })
.await
.map_err(|e| IoError::other(format!("failed to queue rollout flush: {e}")))?;
rx.await
.map_err(|e| IoError::other(format!("failed waiting for rollout flush: {e}")))
.map_err(|e| {
self.writer_task.terminal_failure().unwrap_or_else(|| {
IoError::other(format!("failed to queue rollout flush: {e}"))
})
})?;
rx.await.map_err(|e| {
self.writer_task
.terminal_failure()
.unwrap_or_else(|| IoError::other(format!("failed waiting for rollout flush: {e}")))
})?
}
pub async fn load_rollout_items(
@@ -629,13 +723,24 @@ impl RolloutRecorder {
}))
}
/// Drain pending items before stopping the writer task.
///
/// If draining fails, the writer stays alive so callers can continue retrying flush/shutdown.
pub async fn shutdown(&self) -> std::io::Result<()> {
let (tx_done, rx_done) = oneshot::channel();
match self.tx.send(RolloutCmd::Shutdown { ack: tx_done }).await {
Ok(_) => rx_done
.await
.map_err(|e| IoError::other(format!("failed waiting for rollout shutdown: {e}")))?,
Ok(_) => rx_done.await.map_err(|e| {
self.writer_task.terminal_failure().unwrap_or_else(|| {
IoError::other(format!("failed waiting for rollout shutdown: {e}"))
})
})??,
Err(e) => {
if let Some(err) = self.writer_task.terminal_failure() {
warn!(
"failed to send rollout shutdown command because writer task failed: {err}"
);
return Err(err);
}
warn!("failed to send rollout shutdown command: {e}");
return Err(IoError::other(format!(
"failed to send rollout shutdown command: {e}"
@@ -724,132 +829,259 @@ fn open_log_file(path: &Path) -> std::io::Result<File> {
.open(path)
}
/// Mutable state owned by the background rollout writer.
///
/// Items are first appended to `pending_items`; persist/flush/shutdown remove each item from that
/// queue only after it is written successfully. I/O failures drop the file handle but keep the
/// unwritten suffix so the next barrier can reopen the file and retry.
struct RolloutWriterState {
writer: Option<JsonlWriter>,
deferred_log_file_info: Option<LogFileInfo>,
pending_items: Vec<RolloutItem>,
meta: Option<SessionMeta>,
cwd: PathBuf,
rollout_path: PathBuf,
state_db_ctx: Option<StateDbHandle>,
state_builder: Option<ThreadMetadataBuilder>,
default_provider: String,
generate_memories: bool,
last_logged_error: Option<String>,
}
impl RolloutWriterState {
#[allow(clippy::too_many_arguments)]
fn new(
file: Option<tokio::fs::File>,
deferred_log_file_info: Option<LogFileInfo>,
meta: Option<SessionMeta>,
cwd: PathBuf,
rollout_path: PathBuf,
state_db_ctx: Option<StateDbHandle>,
mut state_builder: Option<ThreadMetadataBuilder>,
default_provider: String,
generate_memories: bool,
) -> Self {
if let Some(builder) = state_builder.as_mut() {
builder.rollout_path = rollout_path.clone();
}
Self {
writer: file.map(|file| JsonlWriter { file }),
deferred_log_file_info,
pending_items: Vec::new(),
meta,
cwd,
rollout_path,
state_db_ctx,
state_builder,
default_provider,
generate_memories,
last_logged_error: None,
}
}
fn add_items(&mut self, items: Vec<RolloutItem>) {
self.pending_items.extend(items);
}
async fn flush_if_materialized(&mut self) {
if self.is_deferred() {
return;
}
if let Err(err) = self.flush().await {
self.enter_recovery_mode(&err);
}
}
async fn persist(&mut self) -> std::io::Result<()> {
self.write_pending_with_recovery("persist").await
}
async fn flush(&mut self) -> std::io::Result<()> {
if self.is_deferred() && self.pending_items.is_empty() {
return Ok(());
}
self.write_pending_with_recovery("flush").await
}
async fn shutdown(&mut self) -> std::io::Result<()> {
if self.is_deferred() && self.pending_items.is_empty() {
return Ok(());
}
self.write_pending_with_recovery("shutdown").await
}
async fn write_pending_with_recovery(&mut self, operation: &str) -> std::io::Result<()> {
match self.write_pending_once().await {
Ok(()) => {
self.last_logged_error = None;
Ok(())
}
Err(first_err) => {
self.enter_recovery_mode(&first_err);
warn!("failed to {operation} rollout writer; reopening and retrying: {first_err}");
match self.write_pending_once().await {
Ok(()) => {
self.last_logged_error = None;
Ok(())
}
Err(second_err) => {
self.enter_recovery_mode(&second_err);
warn!(
"retrying rollout writer {operation} failed; first error: \
{first_err}; final error: {second_err}"
);
Err(second_err)
}
}
}
}
}
fn is_deferred(&self) -> bool {
self.writer.is_none() && self.deferred_log_file_info.is_some()
}
fn enter_recovery_mode(&mut self, err: &IoError) {
let message = err.to_string();
if self.last_logged_error.as_ref() != Some(&message) {
error!(
"rollout writer failed for {}; buffered rollout items will be retried: {err}",
self.rollout_path.display()
);
}
self.last_logged_error = Some(message);
self.writer = None;
}
async fn ensure_writer_open(&mut self) -> std::io::Result<()> {
if self.writer.is_some() {
return Ok(());
}
let path = self
.deferred_log_file_info
.as_ref()
.map(|info| info.path.as_path())
.unwrap_or(self.rollout_path.as_path());
let file = open_log_file(path)?;
self.writer = Some(JsonlWriter {
file: tokio::fs::File::from_std(file),
});
self.deferred_log_file_info = None;
Ok(())
}
async fn write_session_meta_if_needed(&mut self) -> std::io::Result<()> {
let Some(session_meta) = self.meta.as_ref().cloned() else {
return Ok(());
};
write_session_meta(
self.writer.as_mut(),
session_meta,
&self.cwd,
&self.rollout_path,
self.state_db_ctx.as_deref(),
&mut self.state_builder,
self.default_provider.as_str(),
self.generate_memories,
)
.await?;
self.meta = None;
Ok(())
}
async fn write_pending_once(&mut self) -> std::io::Result<()> {
self.ensure_writer_open().await?;
self.write_session_meta_if_needed().await?;
self.write_pending_items_once().await?;
if let Some(writer) = self.writer.as_mut() {
writer.file.flush().await?;
}
Ok(())
}
async fn write_pending_items_once(&mut self) -> std::io::Result<()> {
let Some(writer) = self.writer.as_mut() else {
return Err(IoError::other("rollout writer is not open"));
};
let mut written_count = 0usize;
let mut write_result = Ok(());
for item in &self.pending_items {
if let Err(err) = writer.write_rollout_item(item).await {
write_result = Err(err);
break;
}
written_count += 1;
}
if written_count > 0 {
let written_items: Vec<RolloutItem> =
self.pending_items.drain(..written_count).collect();
sync_thread_state_after_write(
self.state_db_ctx.as_deref(),
&self.rollout_path,
self.state_builder.as_ref(),
written_items.as_slice(),
self.default_provider.as_str(),
/*new_thread_memory_mode*/ None,
)
.await;
}
write_result
}
}
#[allow(clippy::too_many_arguments)]
async fn rollout_writer(
file: Option<tokio::fs::File>,
mut deferred_log_file_info: Option<LogFileInfo>,
deferred_log_file_info: Option<LogFileInfo>,
mut rx: mpsc::Receiver<RolloutCmd>,
mut meta: Option<SessionMeta>,
cwd: std::path::PathBuf,
meta: Option<SessionMeta>,
cwd: PathBuf,
rollout_path: PathBuf,
state_db_ctx: Option<StateDbHandle>,
mut state_builder: Option<ThreadMetadataBuilder>,
state_builder: Option<ThreadMetadataBuilder>,
default_provider: String,
generate_memories: bool,
) -> std::io::Result<()> {
let mut writer = file.map(|file| JsonlWriter { file });
let mut buffered_items = Vec::<RolloutItem>::new();
if let Some(builder) = state_builder.as_mut() {
builder.rollout_path = rollout_path.clone();
}
// Resumed sessions already have a file handle open, so session metadata can
// be written immediately if present.
if writer.is_some()
&& let Some(session_meta) = meta.take()
{
write_session_meta(
writer.as_mut(),
session_meta,
&cwd,
&rollout_path,
state_db_ctx.as_deref(),
&mut state_builder,
default_provider.as_str(),
generate_memories,
)
.await?;
}
let mut state = RolloutWriterState::new(
file,
deferred_log_file_info,
meta,
cwd,
rollout_path,
state_db_ctx,
state_builder,
default_provider,
generate_memories,
);
// Process rollout commands
while let Some(cmd) = rx.recv().await {
match cmd {
RolloutCmd::AddItems(items) => {
if items.is_empty() {
continue;
}
if writer.is_none() {
buffered_items.extend(items);
continue;
}
write_and_reconcile_items(
writer.as_mut(),
items.as_slice(),
&rollout_path,
state_db_ctx.as_deref(),
state_builder.as_ref(),
default_provider.as_str(),
)
.await?;
state.add_items(items);
state.flush_if_materialized().await;
}
RolloutCmd::Persist { ack } => {
if writer.is_none() {
let result = async {
let Some(log_file_info) = deferred_log_file_info.take() else {
return Err(IoError::other(
"deferred rollout recorder missing log file metadata",
));
};
let file = open_log_file(log_file_info.path.as_path())?;
writer = Some(JsonlWriter {
file: tokio::fs::File::from_std(file),
});
if let Some(session_meta) = meta.take() {
write_session_meta(
writer.as_mut(),
session_meta,
&cwd,
&rollout_path,
state_db_ctx.as_deref(),
&mut state_builder,
default_provider.as_str(),
generate_memories,
)
.await?;
}
if !buffered_items.is_empty() {
write_and_reconcile_items(
writer.as_mut(),
buffered_items.as_slice(),
&rollout_path,
state_db_ctx.as_deref(),
state_builder.as_ref(),
default_provider.as_str(),
)
.await?;
buffered_items.clear();
}
Ok(())
}
.await;
if let Err(err) = result {
let kind = err.kind();
let message = err.to_string();
let _ = ack.send(Err(IoError::new(kind, message.clone())));
return Err(IoError::new(kind, message));
}
}
let _ = ack.send(Ok(()));
let _ = ack.send(state.persist().await);
}
RolloutCmd::Flush { ack } => {
// Deferred fresh threads may not have an initialized file yet.
if let Some(writer) = writer.as_mut()
&& let Err(e) = writer.file.flush().await
{
let _ = ack.send(());
return Err(e);
let _ = ack.send(state.flush().await);
}
RolloutCmd::Shutdown { ack } => match state.shutdown().await {
Ok(()) => {
let _ = ack.send(Ok(()));
break;
}
let _ = ack.send(());
}
RolloutCmd::Shutdown { ack } => {
let _ = ack.send(());
}
Err(err) => {
let _ = ack.send(Err(err));
}
},
}
}
@@ -896,31 +1128,6 @@ async fn write_session_meta(
Ok(())
}
async fn write_and_reconcile_items(
mut writer: Option<&mut JsonlWriter>,
items: &[RolloutItem],
rollout_path: &Path,
state_db_ctx: Option<&StateRuntime>,
state_builder: Option<&ThreadMetadataBuilder>,
default_provider: &str,
) -> std::io::Result<()> {
if let Some(writer) = writer.as_mut() {
for item in items {
writer.write_rollout_item(item).await?;
}
}
sync_thread_state_after_write(
state_db_ctx,
rollout_path,
state_builder,
items,
default_provider,
/*new_thread_memory_mode*/ None,
)
.await;
Ok(())
}
async fn sync_thread_state_after_write(
state_db_ctx: Option<&StateRuntime>,
rollout_path: &Path,
+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");