Repository navigation
fix(native-chat): a Codex Stop that Codex refuses or never answers ends the Codex process - #24334
Conversation
…ds the session A Codex Stop is the interrupt alone, so its background terminals keep running. When Codex refused the interrupt, or never answered it, the turn kept running and the chat said "Codex didn't stop" or "Cancellation was not confirmed." with no way to stop it short of closing the chat. Now, after an interrupt that failed, the Stop re-reads the conversation once Codex's frames already received have landed, and ends the child through the host's usual stop (proof of exit, then the lease release) when it still runs what the Stop was sent for. It skips a conversation at rest, a child whose exit already ended its turn, and one Codex has moved on to a different turn. The turn then reads as the user's cancellation. If the child's exit is not proven, the failure is reported and the Stop keeps its "didn't stop" or "not confirmed" row, which is still true. A Stop Codex took keeps the child, unchanged.
…iled-interrupt-ends-session
…wn, on the turn the Stop meant - Codex's invalid-request refusal (-32600: no active turn, another turn active, thread not loaded) states the named turn is not running, and can arrive before that turn's end frame. The Codex adapter now marks it `turnNotRunning`, and such a refusal never ends the child. Only an internal-error refusal (-32603), an unanswered interrupt, or a thrown cancel does. - A Stop that named a turn, or an unnamed Stop that read one, ends the child only when the journal, after draining received frames, still shows that same turn. A session working on a follow-up whose turn has not opened is no longer ended by a Stop of the finished turn. - When the child's exit was proven and only a later cleanup step failed, the Stop reads as requested; the failure is still reported. - `AgentSessionCancelOutcome` moves beside the adapter's other Stop members (re-exported), which keeps the adapter file within its line budget.
The frames Codex sent before the interrupt failed are held in the sink, so a read that skips the drain sees the stopped turn still running.
…er's acknowledgement is
… and keeps the child
…iled-interrupt-ends-session # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
… didn't stop, not that the turn finished The new branch has just read the turn running, so the named Stop's 'already finished' row was false.
Review status: ready for your merge decisionThe problem. In a Codex chat, pressing Stop sends Codex an interrupt for the running turn. When Codex never answered that interrupt (it times out after 30 s) or answered that it failed internally ("failed to interrupt turn"), Orca tried nothing else. The turn kept running, the chat said "Cancellation was not confirmed." or "Codex didn't stop: …", and the only way out was to close the chat. What changes for you. In those two cases, Stop now ends the Codex process if the turn you stopped is still running. The turn reads as interrupted, the row says "Cancellation requested.", and your next message starts Codex again on the same conversation. A Stop that Codex accepts still keeps the process and its background terminals, exactly as before. If Codex says the turn isn't running, or the turn you stopped has already finished, nothing is ended. Fixed during review (head
Deferred (P3, not fixed here):
Overlap with #24333: #24333 retries an owed stop before every operation, which also covers this PR's "exit couldn't be confirmed" case. Whichever lands second reconciles. Verified:
Not verified:
Already true before this PR, not changed:
|
|
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (3)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review. 📝 WalkthroughWalkthroughCodex interrupt refusals now identify cases where the targeted turn is no longer running. The host distinguishes those refusals from failed or unanswered interrupts. When a failed interrupt leaves the targeted turn running, the host attempts to end the child and checks whether the session released it. The adapter exposes the cancellation outcome type, and Stop tests cover these outcomes and related turn and session states. Priority: ⬇️ Low Merge Risk: ⚪ Minimal · up to When a Codex Stop fails or goes unanswered while the targeted turn is still running, the Codex process is now ended. When Codex reports that the turn is no longer running, the process is kept. One narrow edge case was already raised: a failed event flush during a named Stop could end a follow-up turn. No other blocking issue was found, and the change looks ready to merge. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to Stop now ends the chat’s agent when interruption fails, while checking that the requested turn is still running. No introduced security issue was established, but caller-access controls and shutdown behavior against a real agent remain partially verified. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: a70c84a7-152f-411c-9008-db1212990d23
📒 Files selected for processing (9)
src/main/codex/codex-structured-conversation-stop.test.tssrc/main/codex/codex-structured-session-adapter-fixture.tssrc/main/codex/codex-structured-turn-cancellation.tssrc/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.tssrc/main/native-chat/agent-session-wire/structured-agent-session-adapter.tssrc/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.tssrc/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.
| /** | ||
| * After an interrupt that failed: whether the session still runs what the Stop was sent for. One at | ||
| * rest, or running a different turn, is not the Stop's to end. A child that exited reads at rest: | ||
| * its exit ends its turn, and the host's own exit handling waits behind this step. | ||
| */ | ||
| async function stillRunsStoppedTurn( | ||
| ctx: Pick<AgentSessionTurnContext, 'journal' | 'fence' | 'flushStreamedEvents'>, | ||
| stoppedTurnId: string | null | ||
| ): Promise<boolean> { | ||
| if (!(await isMainAgentWorkingOnceFlushed(ctx))) { | ||
| return false | ||
| } | ||
| // Working with no turn open after the Stop's turn is a later send whose turn has not opened. |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
sed -n '1,80p' src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts
sed -n '140,270p' src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts
sed -n '80,125p' src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts
rg -n 'drained\(|flushEventSink|sink.*fail|sink.*error' src/main/native-chat/agent-session-wire/structured-agent-session*test.ts src/main/native-chat/agent-session-wire/structured-agent-session-event*Repository: stablyai/orca
Length of output: 19107
🏁 Script executed:
set -eu
printf '%s\n' '--- cancellation implementation ---'
sed -n '1,290p' src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts
printf '%s\n' '--- focused stop tests ---'
sed -n '330,430p' src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts
printf '%s\n' '--- cancellation test references ---'
rg -n -C 5 'stillRunsStoppedTurn|flushStreamedEvents|sink barrier|childReleased|onStopChildError|cancelUnconfirmed' src/main/native-chat/agent-session-wire/*cancel*.test.ts src/main/native-chat/agent-session-wire/*stop*.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts
printf '%s\n' '--- stopChild callback bindings ---'
rg -n -C 8 'stopChild:|onStopChildError|childReleased|stopAgent\\(' src/main/native-chat/agent-session-wire/structured-agent-session-*.tsRepository: stablyai/orca
Length of output: 42702
🏁 Script executed:
set -eu
printf '%s\n' '--- relevant cancellation code ---'
sed -n '25,55p' src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts
sed -n '180,275p' src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts
printf '%s\n' '--- focused sink-failure test ---'
sed -n '350,415p' src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts
printf '%s\n' '--- flush and journal bindings ---'
rg -n -C 10 'flushStreamedEvents|flushEventSink|drained\\(|journal.*append|append.*journal' src/main/native-chat/agent-session-wire/structured-agent-session-host*.ts src/main/native-chat/agent-session-wire/structured-agent-session-event-sink*.ts src/main/native-chat/agent-session-wire/structured-agent-session-turns*.ts
printf '%s\n' '--- barrier failure implementation/tests ---'
sed -n '130,235p' src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts
sed -n '180,225p' src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.tsRepository: stablyai/orca
Length of output: 7313
🏁 Script executed:
set -eu
printf '%s\n' '--- flush bindings ---'
rg -n -C 8 'flushStreamedEvents|flushEventSink|drained' src/main/native-chat/agent-session-wire
printf '%s\n' '--- notification bindings ---'
rg -n -C 8 'notify\\(|turn/started|turn/completed|turnLifecycle' src/main/native-chat/agent-session-wire/structured-agent-session-codex* src/main/native-chat/agent-session-wire/structured-agent-session-*-adapter* 2>/dev/null || true
printf '%s\n' '--- event sink implementation ---'
sed -n '1,230p' src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.tsRepository: stablyai/orca
Length of output: 45665
🏁 Script executed:
set -eu
printf '%s\n' '--- sink queue drain/failure semantics ---'
sed -n '1,280p' src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts
printf '%s\n' '--- lifecycle notification files ---'
rg -l 'turn/started|turn/completed|notify' src/main | head -40Repository: stablyai/orca
Length of output: 10912
Do not stop a named turn after a failed event flush.
When the provider interrupt throws after queuing completion for the stopped turn and the start of a follow-up turn, the sink queue can fail and clear the queued rows. The journal can still report the stopped turn. stillRunsStoppedTurn then matches the old ID, and stopChild calls stopAgent(sessionId), which can terminate the follow-up turn.
For a named Stop, return false when the flush fails. Preserve the current conservative behavior for an unnamed Stop.
Suggested fix
async function stillRunsStoppedTurn(
ctx: Pick<AgentSessionTurnContext, 'journal' | 'fence' | 'flushStreamedEvents'>,
stoppedTurnId: string | null
): Promise<boolean> {
+ if (stoppedTurnId !== null) {
+ try {
+ await ctx.flushStreamedEvents()
+ } catch {
+ return false
+ }
+ if (
+ !isStructuredAgentSessionMainAgentWorking(
+ ctx.journal.activeTurnId(),
+ ctx.journal.submissions(),
+ ctx.fence
+ )
+ ) {
+ return false
+ }
+ return ctx.journal.activeTurnId() === stoppedTurnId
+ }
if (!(await isMainAgentWorkingOnceFlushed(ctx))) {
return false
}
- // Working with no turn open after the Stop's turn is a later send whose turn has not opened.
- return stoppedTurnId === null || ctx.journal.activeTurnId() === stoppedTurnId
+ return true
}There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
The Codex Stop now ends the Codex child when the interrupt it sent either failed internally (-32603) or went unanswered, instead of leaving the turn running behind a "not confirmed" row. A Stop that Codex accepted, or refused with its "turn not running" code (-32600), still keeps the child and its background terminals.
performCancelfailed-interrupt branch — after the interrupt, if it failed while the session still runs the turn the Stop meant, ends the child through the existing proven-exitstopChildpath; a proven exit followed by a failed cleanup still reads "Cancellation requested", and an unproven exit keeps the row and the child for retry.stillRunsStoppedTurn— re-flushes received Codex frames before re-reading the journal, then requires the same turn (or a still-unopened send for an unnamed Stop) to be running.- Codex adapter classification —
-32600is carried asturnNotRunning, andAgentSessionCancelOutcomenow lives with the adapter-stop contract (re-exported unchanged). - Tests — real adapter + real journal + fake Codex in
structured-agent-session-codex-stop-row.test.tspin child-end viadisposeSession(..., 'user-stop'), plus host-level cases for proven/unproven exit and cleanup failure.
I traced the new inline stopChild against the session's non-reentrant serialize queue and the lifetime/eviction machinery: the eviction entry runs under-serialize and no awaited step re-enters the lane, and childReleased can only flip once the eviction's own proven-exit step has dropped the child, so neither a deadlock nor a false "exit proven" is reachable from this branch. The intentional decisions (broad -32600, keeping the child on a not-running refusal, ending the child on an unknown-effect interrupt) are documented in the PR body and consistent with the stated goal.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…iled-interrupt-ends-session # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run re-reviewed the commits since the prior pullfrog review (eacb88e). Since then the only new commit is a merge of main (no new PR-authored non-merge commits); the substantive delta is the merge's conflict resolution in performCancel plus the test rows it adjusts.
performCancel— named-Stop rule ordering — the merge added#24301's "a named Stop that stopped nothing writes no row" rule as the finalelse if (!cancelled && taken === false && input.turnId !== undefined), placed after this PR's failed-interrupt child-end branch rather than before it, so a named Stop still running behind an unproven exit keeps its "Codex didn't stop: …" row. Confirmed with an ablation: turning thatelse ifinto a standaloneiffails onlystructured-agent-session-codex-stop-row.test.ts→ "says Codex did not stop when a Stop naming the running turn could not prove the exit".- Test expectations — the host and Codex-row cases for a named Stop whose turn already ended now expect no status row (
[]) instead of "The provider had already finished this turn.", matching#24301; the unnamed-"not running" case keeps its "Codex didn't stop: …" row.
Focused tests pass locally: the Codex adapter stop test, the host conversation-stop test, and the Codex stop-row test (48 tests). Traced the final state end-to-end against #24301's branches — the unnamed Stop keeps its refusal/unconfirmed row, the named Stop falls through to no row when it stopped nothing, and the failed-interrupt row survives an unproven exit.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…rt-failure-on-message Brings in main's native-chat Stop work (#24333 owed stop retried by the next message, #24072 queue pause derived from Stop/Resume rows, #24334, #24437) and #24312 (structured chat failures go through the host logger). Conflicts, each keeping main's behaviour and this branch's design: - queued-message-pause-table.ts: deleted, as #24072 did. Its "a returned card holds the queue only when it was not a failed start" rule (queuedCardHoldsQueue) is ported into main's queued-message-pause.ts (nextSendableQueuedCard and resumableQueuePause); the drain's pick in queued-messages.ts takes main's pause-aware nextSendableQueuedCard. New test pins it. - delivery-loop: main's wind-down wait (#24333) goes into this branch's prepare step: a held owed stop records its waiting row and stops; a start refused because the previous exit is unverifiable waits the same way instead of being recorded as a failed start. onError becomes main's logger, including the deps type (now in delivery-loop-deps.ts). The retry-wake booking moved to structured-agent-session-start-retry-wakes.ts to stay under max-lines. - dead-generation settlement: main's { ok, error } result with this branch's unprovenStart (the delivery loop writes a failed start); unexpected-exit and host-lifetime pass unprovenStart and log through main's logger. - agent-start, attach-flow: main's logger calls; this branch's startPending preparation and at-rest create. - conversation-lifetime, provider-child, host, host-delivery: main's finishOwedWindDown and endedAt with this branch's comments and startFailureRecorded; conversation delivery now takes the attach context (one line in host.ts, which was over max-lines). - restart-continuation: both imports; the handed-over/verdict reading moved to structured-agent-session-continuation-verdict.ts for max-lines. - tests: main's logger added to this branch's hosts and attach inputs; the provider-start log expectation uses main's logger line.

What this fixes
The problem. In a Codex structured chat, pressing Stop sends Codex an "interrupt" request for the running turn. Codex answers that request only once the turn has actually ended, so its answer is what confirms the Stop. Two things can go wrong while the turn keeps running:
In both cases Orca tried nothing else. The turn kept running, the chat showed "Cancellation was not confirmed." or "Codex didn't stop: failed to interrupt turn: …", and the only way to stop the agent was to close the chat. Pressing Stop again hit the same wall.
These cases come from reading Codex's source. We have no captured Orca transcript of either one, and they can't be produced on demand against a live Codex (Visual Proof uses a stand-in Codex server).
Why it happened. A Codex Stop is deliberately just the interrupt. A Stop that Codex accepts keeps the Codex process alive, so the background terminals it started (dev servers, watchers) keep running. That was an earlier decision, and this PR keeps it. But nothing handled an interrupt that failed. Since #24235, a Claude Stop always ends Claude's process after the interrupt. A Codex Stop never did, even when the interrupt failed.
Scope.
ELI5
When you press Stop, Orca asks Codex to stop the turn. If Codex never answers, or says something broke while it tried, the turn used to keep going, and the chat only told you it hadn't stopped. Now Orca ends Codex's process instead, the same way a Claude Stop already does. Your next message starts it again on the same conversation. If Codex says the turn already finished, nothing is ended.
What changes for the user
Before: a Stop whose interrupt Codex never answered, or failed internally, left the turn running and showed "Cancellation was not confirmed." or "Codex didn't stop: …".
After: that Stop ends the Codex process.
Which turn a Stop is about. A Stop only ends the process when the turn it was meant for is still running:
When Codex says the turn isn't running. Codex can send this answer a moment before the message that tells Orca the turn ended. The process is kept, so a Stop pressed just as a turn finishes never kills the process and its background terminals.
When ending the process can't be confirmed. If Orca can't prove that the process exited, it logs the failure and shows "Codex didn't stop: …" (with Codex's reason) or "Cancellation was not confirmed." (when Codex never answered). This holds for a Stop that names its turn too. Since #24301, a named Stop that stopped nothing writes no row, but here Orca has just seen the turn still running, so the Stop says it didn't stop instead of saying nothing. That row is accurate, because the process may still be running. Orca keeps track of the process and retries ending it on the next Stop, or when the chat goes idle.
When the process exited but cleanup failed. If the process provably exited but a later bookkeeping step failed (for example, releasing the chat's claim on the process), the Stop still reads "Cancellation requested." The process is gone, so the turn did stop. The failure is logged, and the leftover cleanup is retried later.
How it works
The change sits in the chat Stop's shared step (
performCancelinstructured-agent-session-turns-cancel.ts). It runs after the interrupt attempt, in the same step on the session's queue, which handles one operation at a time. That means no send or exit handling can run in between.turnNotRunningon the cancel outcome. Codex sends that code for "no active turn", "another turn is active", and "thread not loaded". This uses the error code, not Codex's wording. A failure with that mark never ends the process.stopChild→context.stopAgent(sessionId)path, the same one fix(claude): a Stop ends Claude's process, and the next message resumes the conversation #24235 and the running-command Stop use. That path proves the process exited, records the cut turn as the user's cancellation, and releases the session's claim on the process.Why this approach.
Alternatives considered.
Mixed versions. Nothing changes on the wire, and
turnNotRunningnever leaves the host. An older client gets a Stop answer ofcancelled: true, a "Cancellation requested." row, an interrupted turn, and a session that reads not running. A Claude Stop already produces all of these, so older clients render them as they do today.Differences from the common pattern
Matches: the core rule matches the comparable hosts' interrupt-failure recovery: end the session only while it still runs the stopped turn.
No differences are temporary. Each one is intended:
structured-agent-session-eviction.ts:15-18). Orca never treats an unproven exit as a dead process.Testing
Tests with the real host, the real journal, and the real Codex adapter over a fake Codex connection (
structured-agent-session-codex-stop-row.test.ts):Host tests with a mock adapter (
structured-agent-session-conversation-stop.test.ts):Adapter test (
codex-structured-conversation-stop.test.ts): an "expected active turn id … but found …" refusal carriesturnNotRunning.Ablations. Before the merge of #24301, each guard was removed in turn and both host test files (42 tests) re-run:
After merging main (#24301), re-run on both host test files and the adapter test (48 tests):
Wider runs and checks:
Every test file that imports a changed module, plus the Codex and Claude stop and cancel tests, eviction, unexpected exit, and runtime exit: 23 files, 225 tests, all passing. The runtime integration, Codex turn-end settlement and Codex compact-exit tests: 4 files, 25 tests, all passing.
After merging main: the two host stop files, the adapter stop test, and every native-chat test file fix(native-chat): every chat action press is its own action (re-land #23916 on main) #24301 added or changed (14 files, 175 tests), all passing.
No real-CLI or real-binary tests were run.
oxlinton the changed files, the changed-code quality gate, and the anti-slop audit pass after the merge of main. The node typecheck passes after the merge too.I manually tested these changes (live QA on a separate Mac, see Visual Proof)
Automated tests added/updated
Linked Issue
Follow-up to #24235.
Visual Proof
Live QA on a separate Mac, running a dev build of this branch at efa1651 in the background with its own home folders. A structured Codex chat ran against a stand-in Codex server that can refuse or ignore the interrupt on demand. Later commits change only comments, the rare-path row, and merges of main (including #24301's Stop-note rules: a named Stop that stopped nothing writes no row, and a turn's Stop row is keyed to that turn). No real Codex binary was used.
Review
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)