App server: add an atomic thread-level interrupt API
Problem
A host UI Stop control cannot safely use the current strict turn/interrupt({ threadId, turnId }) API when the renderer-held turn id can become stale between the click and request handling. In that race, the host receives an error such as:
expected active turn id <old> but found <new>
Retrying from the renderer is not safe: selecting a newer turn after the original Stop request can cancel work that began after the user pressed Stop.
Requested API
Add a thread-level operation with server-owned selection, for example:
thread/interrupt({ threadId }) -> { turnId: string | null }
Required semantics:
- The app server atomically captures the active turn for the requested thread when it handles the request.
- If the thread is idle, respond immediately with
{ turnId: null }. - If a turn was captured, abort only that captured turn, guarded so a later turn is never aborted.
- Resolve the request only from that captured turn's terminal event.
This lets a UI reliably mean “stop the work that was active when the server processed Stop,” without parsing errors or guessing which turn to target. Existing turn/interrupt can remain for callers intentionally targeting a specific turn.
Relation to existing issue
This is related to #36926, which covers an interrupted turn incorrectly remaining active and leaving a repeated turn/interrupt pending. That issue is a distinct server-state bug. This request is about the API contract needed when a host has only a thread-level Stop action and its client-side turn snapshot is inherently stale-prone.
Implementation evidence
We implemented and tested this contract in a downstream Codex vendor fork: the app server captured the active turn, core abort was guarded to that captured ID, idle threads returned immediately, and integration tests covered active and idle cases. The fork history is divergent from current upstream, so it is not a directly applicable patch; this issue is intended to provide the behavior and rationale for a native upstream implementation.
1 Comment
Not a critique of the proposal — the stale-client-turn-id race is real, and a thread-level Stop is the right shape for it. One implementation note for whoever picks it up, since it lands on semantics (1) and (4).
Today the app-server's only turn-id-bearing notion of "the active turn" is
ThreadState::active_turn_snapshot(), backed byThreadHistoryBuilder(codex-rs/app-server/src/thread_state.rs:169).CodexThreaddoesn't expose one — core's guarded entry points (steer_turn,Session::abort_turn_if_active) take an expected turn id from the caller rather than handing one out. So a server-side capture that has to return aturnIdwill most likely read that projection.That projection currently keeps an already-aborted turn open.
ThreadHistoryBuilder::handle_turn_abortedsetsTurnStatus::Interruptedbut never callsfinish_current_turn(), unlike its siblinghandle_turn_complete(codex-rs/app-server-protocol/src/protocol/thread_history.rs:1210vs:1280).has_active_turn()therefore stays true, soThreadState::track_current_turn_eventnever resets its builder (thread_state.rs:197-203), andactive_turn_snapshot()keeps returning the finished turn until a laterTurnStarted, aThreadRolledBack, or listener teardown clears it.The hazard: if
thread/interrupttreats "nothing captured" as the idle test, a thread that is genuinely idle after an interrupt still captures the dead turn, and semantics (4) — resolve only from that turn's terminal event — waits for aTurnAbortedthatSession::abort_all_tasksdoes not emit when there is no task to abort (codex-rs/core/src/tasks/mod.rs:512-526; the event is emitted only insidehandle_task_abort). That is the stranded request from #36926, reachable without the client naming a turn id. Two things avoid it independently: deriving idleness fromagent_status(), which correctly reportsInterruptedhere and whichturn_interrupt_inneralready computes (turn_processor.rs:1604), and closing the turn in the reducer. I have a patch for the reducer half linked from #36926 if it's useful as a reference.