Skip to content

Commit 8035cb0

Browse files
authored
feat: make rollout recorder reliable against errors (openai#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.
1 parent 085ffb4 commit 8035cb0

10 files changed

Lines changed: 534 additions & 189 deletions

File tree

codex-rs/core/src/agent/control.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ impl AgentControl {
362362
.session
363363
.ensure_rollout_materialized()
364364
.await;
365-
parent_thread.codex.session.flush_rollout().await;
365+
parent_thread.codex.session.flush_rollout().await?;
366366
}
367367

368368
let rollout_path = parent_thread
@@ -663,7 +663,7 @@ impl AgentControl {
663663
let state = self.upgrade()?;
664664
let result = if let Ok(thread) = state.get_thread(agent_id).await {
665665
thread.codex.session.ensure_rollout_materialized().await;
666-
thread.codex.session.flush_rollout().await;
666+
thread.codex.session.flush_rollout().await?;
667667
if matches!(thread.agent_status().await, AgentStatus::Shutdown) {
668668
Ok(String::new())
669669
} else {

codex-rs/core/src/agent/control_tests.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,12 @@ async fn persist_thread_for_tree_resume(thread: &Arc<CodexThread>, message: &str
194194
.inject_user_message_without_turn(message.to_string())
195195
.await;
196196
thread.codex.session.ensure_rollout_materialized().await;
197-
thread.codex.session.flush_rollout().await;
197+
thread
198+
.codex
199+
.session
200+
.flush_rollout()
201+
.await
202+
.expect("test thread rollout should flush");
198203
}
199204

200205
async fn wait_for_live_thread_spawn_children(
@@ -624,7 +629,12 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
624629
.session
625630
.ensure_rollout_materialized()
626631
.await;
627-
parent_thread.codex.session.flush_rollout().await;
632+
parent_thread
633+
.codex
634+
.session
635+
.flush_rollout()
636+
.await
637+
.expect("parent rollout should flush");
628638

629639
let child_thread_id = harness
630640
.control
@@ -821,7 +831,12 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
821831
.session
822832
.ensure_rollout_materialized()
823833
.await;
824-
parent_thread.codex.session.flush_rollout().await;
834+
parent_thread
835+
.codex
836+
.session
837+
.flush_rollout()
838+
.await
839+
.expect("parent rollout should flush");
825840

826841
let child_thread_id = harness
827842
.control

codex-rs/core/src/codex.rs

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2216,16 +2216,16 @@ impl Session {
22162216
self.services.state_db.clone()
22172217
}
22182218

2219-
/// Ensure rollout file writes are durably flushed.
2220-
pub(crate) async fn flush_rollout(&self) {
2219+
/// Flush rollout writes and return the final durability-barrier result.
2220+
pub(crate) async fn flush_rollout(&self) -> std::io::Result<()> {
22212221
let recorder = {
22222222
let guard = self.services.rollout.lock().await;
22232223
guard.clone()
22242224
};
2225-
if let Some(rec) = recorder
2226-
&& let Err(e) = rec.flush().await
2227-
{
2228-
warn!("failed to flush rollout recorder: {e}");
2225+
if let Some(recorder) = recorder {
2226+
recorder.flush().await
2227+
} else {
2228+
Ok(())
22292229
}
22302230
}
22312231

@@ -2372,7 +2372,7 @@ impl Session {
23722372
// Defer seeding the session's initial context until the first turn starts so
23732373
// turn/start overrides can be merged before we write to the rollout.
23742374
if !is_subagent {
2375-
self.flush_rollout().await;
2375+
let _ = self.flush_rollout().await;
23762376
}
23772377
}
23782378
InitialHistory::Forked(rollout_items) => {
@@ -2396,7 +2396,7 @@ impl Session {
23962396

23972397
// Flush after seeding history and any persisted rollout copy.
23982398
if !is_subagent {
2399-
self.flush_rollout().await;
2399+
let _ = self.flush_rollout().await;
24002400
}
24012401
}
24022402
}
@@ -5600,13 +5600,24 @@ mod handlers {
56005600
.into_iter()
56015601
.chain(std::iter::once(RolloutItem::EventMsg(rollback_msg.clone())))
56025602
.collect::<Vec<_>>();
5603-
sess.persist_rollout_items(&[RolloutItem::EventMsg(rollback_msg.clone())])
5604-
.await;
5605-
sess.flush_rollout().await;
56065603
sess.apply_rollout_reconstruction(turn_context.as_ref(), replay_items.as_slice())
56075604
.await;
56085605
sess.recompute_token_usage(turn_context.as_ref()).await;
56095606

5607+
sess.persist_rollout_items(&[RolloutItem::EventMsg(rollback_msg.clone())])
5608+
.await;
5609+
if let Err(err) = sess.flush_rollout().await {
5610+
sess.send_event(
5611+
turn_context.as_ref(),
5612+
EventMsg::Warning(WarningEvent {
5613+
message: format!(
5614+
"Rolled the thread back, but failed to save the rollback marker. Codex will continue retrying. Error: {err}"
5615+
),
5616+
}),
5617+
)
5618+
.await;
5619+
}
5620+
56105621
sess.deliver_event_raw(Event {
56115622
id: turn_context.sub_id.clone(),
56125623
msg: rollback_msg,

codex-rs/core/src/codex_tests.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,7 +1313,11 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
13131313
// Forking reads the persisted rollout JSONL, so force the completed source turn to disk
13141314
// before snapshotting from it.
13151315
initial.codex.ensure_rollout_materialized().await;
1316-
initial.codex.flush_rollout().await;
1316+
initial
1317+
.codex
1318+
.flush_rollout()
1319+
.await
1320+
.expect("source rollout should flush before fork");
13171321

13181322
let mut fork_config = initial.config.clone();
13191323
fork_config.permissions.approval_policy =
@@ -2359,7 +2363,10 @@ async fn attach_rollout_recorder(session: &Arc<Session>) -> PathBuf {
23592363
*rollout = Some(recorder);
23602364
}
23612365
session.ensure_rollout_materialized().await;
2362-
session.flush_rollout().await;
2366+
session
2367+
.flush_rollout()
2368+
.await
2369+
.expect("attached rollout should flush");
23632370
rollout_path
23642371
}
23652372

@@ -4422,7 +4429,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline
44224429
.expect("serialize expected context item")
44234430
);
44244431
session.ensure_rollout_materialized().await;
4425-
session.flush_rollout().await;
4432+
session.flush_rollout().await.expect("rollout should flush");
44264433

44274434
let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path)
44284435
.await
@@ -4524,7 +4531,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei
45244531
.record_context_updates_and_set_reference_context_item(&turn_context)
45254532
.await;
45264533
session.ensure_rollout_materialized().await;
4527-
session.flush_rollout().await;
4534+
session.flush_rollout().await.expect("rollout should flush");
45284535

45294536
let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path)
45304537
.await

codex-rs/core/src/codex_thread.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ impl CodexThread {
8282
}
8383

8484
#[doc(hidden)]
85-
pub async fn flush_rollout(&self) {
86-
self.codex.session.flush_rollout().await;
85+
pub async fn flush_rollout(&self) -> std::io::Result<()> {
86+
self.codex.session.flush_rollout().await
8787
}
8888

8989
pub async fn submit_with_trace(

codex-rs/core/src/guardian/review_session.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,7 @@ async fn append_guardian_followup_reminder(review_session: &GuardianReviewSessio
564564
async fn load_rollout_items_for_fork(
565565
session: &Session,
566566
) -> anyhow::Result<Option<Vec<RolloutItem>>> {
567-
session.flush_rollout().await;
567+
session.flush_rollout().await?;
568568
let Some(rollout_path) = session.current_rollout_path().await else {
569569
return Ok(None);
570570
};

codex-rs/core/src/memories/tests.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -689,7 +689,12 @@ mod phase2 {
689689
other => panic!("unexpected sandbox policy: {other:?}"),
690690
}
691691
subagent.codex.session.ensure_rollout_materialized().await;
692-
subagent.codex.session.flush_rollout().await;
692+
subagent
693+
.codex
694+
.session
695+
.flush_rollout()
696+
.await
697+
.expect("subagent rollout should flush");
693698
let rollout_path = subagent
694699
.rollout_path()
695700
.expect("consolidation thread should have a rollout path");

codex-rs/core/src/tasks/mod.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ use codex_protocol::protocol::TokenUsage;
4646
use codex_protocol::protocol::TurnAbortReason;
4747
use codex_protocol::protocol::TurnAbortedEvent;
4848
use codex_protocol::protocol::TurnCompleteEvent;
49+
use codex_protocol::protocol::WarningEvent;
4950
use codex_protocol::user_input::UserInput;
5051

5152
use codex_features::Feature;
@@ -302,7 +303,18 @@ impl Session {
302303
)
303304
.await;
304305
let sess = session_ctx.clone_session();
305-
sess.flush_rollout().await;
306+
if let Err(err) = sess.flush_rollout().await {
307+
warn!("failed to flush rollout before completing turn: {err}");
308+
sess.send_event(
309+
ctx_for_finish.as_ref(),
310+
EventMsg::Warning(WarningEvent {
311+
message: format!(
312+
"Failed to save the conversation transcript; Codex will continue retrying. Error: {err}"
313+
),
314+
}),
315+
)
316+
.await;
317+
}
306318
if !task_cancellation_token.is_cancelled() {
307319
// Emit completion uniformly from spawn site so all tasks share the same lifecycle.
308320
sess.on_task_finished(Arc::clone(&ctx_for_finish), last_agent_message)
@@ -591,7 +603,9 @@ impl Session {
591603
.await;
592604
// Ensure the marker is durably visible before emitting TurnAborted: some clients
593605
// synchronously re-read the rollout on receipt of the abort event.
594-
self.flush_rollout().await;
606+
if let Err(err) = self.flush_rollout().await {
607+
warn!("failed to flush interrupted-turn marker before emitting TurnAborted: {err}");
608+
}
595609
}
596610

597611
let (completed_at, duration_ms) = task

0 commit comments

Comments
 (0)