App server: add an atomic thread-level interrupt API

Open 💬 1 comment Opened Aug 26, 2026 by davidfant

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:

  1. The app server atomically captures the active turn for the requested thread when it handles the request.
  2. If the thread is idle, respond immediately with { turnId: null }.
  3. If a turn was captured, abort only that captured turn, guarded so a later turn is never aborted.
  4. 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.

View original on GitHub ↗

1 Comment

matias-casal · 6 hours ago

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 by ThreadHistoryBuilder (codex-rs/app-server/src/thread_state.rs:169). CodexThread doesn'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 a turnId will most likely read that projection.

That projection currently keeps an already-aborted turn open. ThreadHistoryBuilder::handle_turn_aborted sets TurnStatus::Interrupted but never calls finish_current_turn(), unlike its sibling handle_turn_complete (codex-rs/app-server-protocol/src/protocol/thread_history.rs:1210 vs :1280). has_active_turn() therefore stays true, so ThreadState::track_current_turn_event never resets its builder (thread_state.rs:197-203), and active_turn_snapshot() keeps returning the finished turn until a later TurnStarted, a ThreadRolledBack, or listener teardown clears it.

The hazard: if thread/interrupt treats "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 a TurnAborted that Session::abort_all_tasks does not emit when there is no task to abort (codex-rs/core/src/tasks/mod.rs:512-526; the event is emitted only inside handle_task_abort). That is the stranded request from #36926, reachable without the client naming a turn id. Two things avoid it independently: deriving idleness from agent_status(), which correctly reports Interrupted here and which turn_interrupt_inner already 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.