Fix hang on turn/interrupt (#18392)

Fix a bug where the `turn/interrupt` RPC hangs when interrupting a turn
that has already completed.

Before this change, `turn/interrupt` requests were queued in app-server
and only answered when a later TurnAborted event arrived. If the target
turn was already complete, core treated Op::Interrupt as a no-op, so no
abort event was emitted and the RPC could hang indefinitely.

This change fixes that in two places:

* Reject turn/interrupt immediately with `INVALID_REQUEST` when the
requested turn is no longer the active turn.
* Resolve any already-accepted pending interrupt requests when the turn
reaches TurnComplete, covering the case where a turn finishes naturally
after the interrupt request is accepted but before it aborts.

I tested this by adding a failing test in
707487c0634834f6741986b64f61886c2dc10108. You may view the results here:
https://github.com/openai/codex/actions/runs/24585182419/

<img width="1512" height="310" alt="CleanShot 2026-04-17 at 16 33 30@2x"
src="https://github.com/user-attachments/assets/f4a88228-b2a4-41f4-9aaa-ec82814096af"
/>
This commit is contained in:
danwang-oai
2026-04-24 10:47:50 -04:00
committed by GitHub
Unverified
parent 28742866c7
commit 11806faf71
4 changed files with 164 additions and 41 deletions
@@ -7790,11 +7790,6 @@ impl CodexMessageProcessor {
async fn turn_interrupt(&self, request_id: ConnectionRequestId, params: TurnInterruptParams) {
let TurnInterruptParams { thread_id, turn_id } = params;
let is_startup_interrupt = turn_id.is_empty();
if !is_startup_interrupt {
self.outgoing
.record_request_turn_id(&request_id, &turn_id)
.await;
}
let (thread_uuid, thread) = match self.load_thread(&thread_id).await {
Ok(v) => v,
@@ -7808,10 +7803,40 @@ impl CodexMessageProcessor {
// interrupts do not have a turn and are acknowledged after submission.
if !is_startup_interrupt {
let thread_state = self.thread_state_manager.thread_state(thread_uuid).await;
let mut thread_state = thread_state.lock().await;
thread_state
.pending_interrupts
.push((request_id.clone(), ApiVersion::V2));
let is_running = matches!(thread.agent_status().await, AgentStatus::Running);
let interrupt_outcome = {
let mut thread_state = thread_state.lock().await;
if let Some(active_turn) = thread_state.active_turn_snapshot() {
if active_turn.id != turn_id {
Err(format!(
"expected active turn id {turn_id} but found {}",
active_turn.id
))
} else {
thread_state
.pending_interrupts
.push((request_id.clone(), ApiVersion::V2));
Ok(())
}
} else if thread_state.last_terminal_turn_id.as_deref() == Some(turn_id.as_str()) {
Err("no active turn to interrupt".to_string())
} else if is_running {
thread_state
.pending_interrupts
.push((request_id.clone(), ApiVersion::V2));
Ok(())
} else {
Err("no active turn to interrupt".to_string())
}
};
if let Err(message) = interrupt_outcome {
self.send_invalid_request_error(request_id, message).await;
return;
}
self.outgoing
.record_request_turn_id(&request_id, &turn_id)
.await;
}
// Submit the interrupt. Turn interrupts respond upon TurnAborted; startup
@@ -8074,7 +8099,7 @@ impl CodexMessageProcessor {
// opt-in stays synchronized with the conversation.
let raw_events_enabled = {
let mut thread_state = thread_state.lock().await;
thread_state.track_current_turn_event(&event.msg);
thread_state.track_current_turn_event(&event.id, &event.msg);
thread_state.experimental_raw_events
};
let subscribed_connection_ids = thread_state_manager
@@ -11237,14 +11262,15 @@ mod tests {
let state = manager.thread_state(thread_id).await;
let mut state = state.lock().await;
state.cancel_tx = Some(cancel_tx);
state.track_current_turn_event(&EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
state.track_current_turn_event(
"turn-1",
&EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
},
));
}),
);
}
manager.remove_thread_state(thread_id).await;