Recover Codex sessions with missing rollouts

This commit is contained in:
iris-ai committed 2026-09-14 15:01:47 -04:00
1 parent 3af2502982
commit 46831520e3
3 files changed
+158 -9

No files matched your search

+106
View File
@@ -894,6 +894,41 @@ fn handle_response(inner: &Arc<Inner>, line: &Value) {
if state.thread_request.as_deref() == Some(id) {
state.thread_request = None;
if let Some(error) = response_error(line) {
// `thread/start` returns an id before the first turn creates its rollout. If the
// app-server is restarted in that window, the id we correctly persisted is not one
// Codex can resume. A rollout can also disappear independently of ai-app. In either
// case our common transcript still exists, so start a blank Codex thread and mark the
// point where the model's context stopped instead of stranding every queued message.
if missing_thread(&error) && read_thread(&inner.session_dir).is_some() {
protocol_error(inner, error.clone());
let path = inner.session_dir.join(THREAD_FILE);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
drop(state);
protocol_error(
inner,
format!("couldn't forget the missing Codex thread: {err}"),
);
return;
}
}
let request = request_id();
state.thread_request = Some(request.clone());
drop(state);
save_state(inner);
send_json(
inner,
json!({
"id": request,
"method": "thread/start",
"params": thread_params(inner)
}),
);
let _ = inner.sink.send(Event::Cleared);
return;
}
drop(state);
protocol_error(inner, error);
return;
@@ -989,6 +1024,11 @@ fn response_error(line: &Value) -> Option<String> {
.map(str::to_string)
}
fn missing_thread(message: &str) -> bool {
let message = message.to_ascii_lowercase();
message.contains("thread not found") || message.contains("no rollout found for thread id")
}
fn active_turn_not_steerable(line: &Value) -> bool {
line.pointer("/error/data/codexErrorInfo/activeTurnNotSteerable")
.is_some()
@@ -1239,4 +1279,70 @@ mod tests {
);
assert_eq!(context_from_rollout(""), None);
}
#[test]
fn both_missing_thread_failures_are_recognised() {
assert!(missing_thread(
"thread not found: 01a099bd-2e0f-7442-b750-a9f33998c8c5"
));
assert!(missing_thread(
"no rollout found for thread id 01a099bd-2e0f-7442-b750-a9f33998c8c5"
));
assert!(!missing_thread("thread is already running"));
}
#[test]
fn a_missing_thread_is_reported_then_replaced() {
let dir = tempfile::tempdir().expect("tempdir");
write_thread(dir.path(), "missing-thread");
let (sink, mut events) = mpsc::unbounded_channel();
let (to_child, mut requests) = mpsc::unbounded_channel();
let inner = Arc::new(Inner {
sink,
state: Mutex::new(ProtocolState {
thread_request: Some("resume-request".to_string()),
..ProtocolState::default()
}),
settings: Mutex::new(Settings {
model: None,
permission_mode: None,
effort: None,
}),
to_child,
transport: Transport::Here,
session_dir: dir.path().to_path_buf(),
subagents: Arc::new(Subagents::new(dir.path().to_path_buf())),
reading: AtomicBool::new(true),
});
handle_response(
&inner,
&json!({
"id": "resume-request",
"error": {"message": "thread not found: missing-thread"}
}),
);
assert!(matches!(
events.try_recv().expect("visible refusal"),
Event::Error { message } if message ==
"Codex refused a request: thread not found: missing-thread"
));
assert_eq!(events.try_recv().expect("context boundary"), Event::Cleared);
assert_eq!(read_thread(dir.path()), None);
let request: Value =
serde_json::from_str(&requests.try_recv().expect("replacement thread request"))
.expect("request json");
assert_eq!(request["method"], "thread/start");
let request_id = request["id"].as_str().expect("request id");
handle_response(
&inner,
&json!({"id": request_id, "result": {"thread": {"id": "replacement-thread"}}}),
);
assert_eq!(
read_thread(dir.path()).as_deref(),
Some("replacement-thread")
);
}
}