Repository navigation
fix(native-chat): a Stop still reads as yours after Orca restarts, because the turn's end reads the Stop's event - #24311
Conversation
…keeps it paused Red on main: Codex's turn end withdraws the steered hand-off and the queue sends the card again as a new host turn, with no pause recorded.
… still unanswered A card sent now into the running turn was still pending when Stop judged the pause, so nothing was recorded; the interrupt then withdrew the hand-off, the card went back to waiting unpaused, and the queue sent it again as a new turn. The pause now counts a hand-off that may still return to waiting, judged with the appended row applied, so a withdrawal lands under the pause and an acceptance retires it in that same write. Codex and Claude both hit it.
…the test's budget
…ry path that ends it The provider's answer, the provider dying, the chat closing, a restart, a withdrawal still owed at open, and a /clear (refused until the hand-off ends, then carrying every waiting card paused 'cleared'); each ends with the queue sending again.
…op-keeps-steered-card
… queued receipt's card
…op-keeps-steered-card
…journal rows Stop now appends one journal row where it takes effect, before the interrupt, whatever the queue holds; Resume appends its own. The pause is a pure function of the fold: the latest Stop with no later Resume and no later accepted turn a person asked for. A /clear's carried cards name their source, which is the replacement's 'cleared' pause. Host-origin turns never lift either. One predicate decides which cards a pause holds; by default every waiting card without a hold of its own, including one queued after the Stop. The drain's consume re-judges it inside its own transaction. The rows are tombstones of an id no item takes, carrying the mark: a released host reads an unknown row kind as corruption and truncates the journal there. Deletes the stored pause (recordPause, the retire hook on every appended row, the settle-before-record step, mayReturnToWaiting and its row overlay) and the tests that only proved it retires. The queued_message_pauses table stays in the schema, unread and unwritten, for downgrade safety.
…ead of held ones A Stop's pause now holds only the cards queued before its row, plus a steer it withdrew, which returns to its own place. Each card records the journal position it was queued at, and the one hold rule compares that with the Stop row. A card queued after the Stop is a new instruction: it sends as usual, but the drain still stops at the first held card, so it never overtakes them. /clear's pause holds the cards it carried. Holding every card again is a one-line switch in that rule.
… in its transaction The drain's pick and its consume now read one function, nextSendableQueuedCard, so a Stop row that lands between them holds a newer card behind an older held one exactly as the pick would. Notes why Stop and Resume ride a tombstone row.
…op-keeps-steered-card
The queue's pause is derived from journal rows, so nothing reads or writes queued_message_pauses. It was still created on every open "for downgrade safety", but an older build creates it itself when it opens the database, so the table only sat empty in every new database. The tests now pin that no pause table exists.
A Stop holds only the cards queued before it. The pause derivation still returned the Stop alone whenever it was in force, so the restart pause was never considered: a card queued after the Stop, written by a host process that has since exited, sent by itself after Orca restarted, with no pause header and no Resume. A /clear pause that held nothing could hide it the same way. Every pause in force is now derived. A card is held if any of them holds it, and it names the first that does. The drain's pick, the consume transaction's re-check and the published header all read that one rule; the header names the pause holding the first card Resume would send.
- The Claude and Codex Stop-withdraws-a-steer tests checked "not sent again" at one instant, before a queue ignoring the pause re-sends. They now wait for the stopped turn to end and re-check after a quiet window. - The deleted-card test read a card queued after the Stop, which sends whether or not a person's turn lifts it; it now reads the Stop's pause before and after that turn. - Unit cases pin that a Stop holds a card with no recorded position and one queued before a rewind.
…op-keeps-steered-card
…turn and caller
The Stop row that paused the queue becomes the general Stop event
{ reason, turnId?, at, caller? }, whose reason is the host's existing stop
cause. It still rides a tombstone of a host-only id (a released host deletes
the journal from the first unknown row kind), and Resume keeps its own marker
on its own id. Only a person's Stop (reason user-stop) pauses the queue.
…states the same Stop event
… the at-start stop Through the real host: the event names the turn and who asked and is in the journal when the interrupt reaches the agent; at an agent still starting it is there before the start is ended and holds a card queued before it; an idle Stop writes one only when it withdrew a send; and the queue's claim re-judges a pause that landed after its pick.
…the Stop's timing Also says precisely what the claim's in-transaction pause check defends against: the Stop and the drain share one serialized lane.
… Stop events Replays this build's rows from the released build's own journal database: every row is kept, the history after the Stop still folds, and an older client is sent only removed ids no item uses.
…op-keeps-steered-card
…op-keeps-steered-card
… a writable downgrade The Stop-event downgrade test ran in no CI lane: unit shards exclude the cross-version folder, and the cross-version lane runs a fixed file list that did not name it. It is now on that list. Its only case replayed the rows into a release's own fresh database, because that release cannot open the current host database. A second case opens the journal this build wrote with a main build that shares the database: it opens writable, keeps every row, appends, and this build then reopens it with the person's Stop still pausing the queue.
A Stop reaching a running agent wrote a Stop event on every press. Two presses before the first interrupt landed wrote two events, so a card queued between them counted as before the latest Stop and was held, though a card queued after a Stop should send normally. A Stop naming a turn that had already ended, as a phone sends late, also wrote an event for a turn it never stopped. It now writes one only when it withdrew a queued send, or stops something no event records yet: not a turn the journal no longer runs, and not the live turn a Stop still in force already names, unless a card was handed over into it since, which this Stop's interrupt sends back and must hold. The interrupt and the "already finished" note are unchanged. A Stop at a starting agent still always writes.
…r lifts a person's Stop
…"stopped while in progress" row After a restart, a turn a person's Stop ended reads "Interrupted after N" with the muted mark, but the relaunch still added the error row saying the provider stopped mid-response, which a live Stop never writes. The settle now skips that row when every turn it interrupts is the person's Stop's by the journal's one rule; a crash nobody stopped keeps it.
…whether a person's Stop decides a turn
…l as it stands A host stop drains the session's sink before judging whether it ends work, and a failed or slow drain read as working. So an eviction of an agent at rest wrote a Stop event that ended nothing, which lifts a person's Stop pause, and a close wrote a person's event naming no turn. The drain is now best effort: the stop goes ahead either way and only its record is at stake, so a failed or slow drain leaves the journal's read as it stands. A person's Stop keeps its own rule.
…send it stopped opened A person's Stop pressed before any turn showed names no turn. It bound the first turn opened after it, then (5ead1f6) any turn opened by no later send, so a turn the host started for a card the Stop held, or for orchestration mail, read as the person's cancellation, and a host eviction of it wrote no Stop event when its send had been abandoned by the close first. The rule is now the concept itself: a Stop naming no turn applies to a turn opened by a send it stopped, one already handed to the agent at the Stop's position. Nothing new is stored. The turn's row names the send that opened it (Codex: the submission's key; Claude: the echo, which the journal aliases to the submission), and a handed-over send's item sits at its handover, so the target set is derived from the journal. A card the Stop held is handed over after it, so it is no target; a Stop of a start whose send never opens a turn binds nothing; a rewind keeps no submissions, so a restated Stop binds no turn opened after it. With no turn running, a host stop defers to the person's Stop only while every unanswered send is one it stopped. Claude's translator, which asks before its echo row lands, passes the send its echo acknowledged.
…ent working; a failed one reads the journal A drain past its bound may still hold the turn's row, while the echo's acceptance has already landed, so the journal as it stands read nothing running: a person's close of that turn wrote no Stop event and the turn read as news. The two drain outcomes now differ: one that failed has nothing more to deliver, so the journal's read holds (as before); one still running reads working.
… every unanswered send is the Stop's The branch had no test. An eviction with only the stopped send unanswered writes nothing; one with a send made after the Stop still unanswered writes its event.
…ted send's turn row is due The previous commit read every drain past its bound as working, so a host eviction or stop of an agent at rest during a sink backlog wrote a Stop event that ended nothing and lifted a person's Stop pause. A slow drain now reads working only when the latest send the agent accepted has opened no turn the journal holds, the race it was for; otherwise the journal's read holds.
…wered beside the later one With both unanswered, the host writes only because not every unanswered send is the Stop's; a rule that deferred when any one was would pass the old control.
…dges a host stop A slow drain reads working when the latest accepted send has opened no turn yet. A Codex steer or a Claude fold is accepted into the running turn and never opens one, so a chat at rest whose last send was a steer still read working, and an eviction lifted a person's Stop pause. Sends delivered into a running turn, whose item carries that turn's scope, are skipped.
Review status: ready for review (head 32c945f, based on main, CI green)The problem. You press Stop (or close the chat) on a working native Claude or Codex chat, and Orca quits or crashes before the agent reports the turn's end. On relaunch the turn read "Failed after N" with a red "stopped while this response was in progress" row, as if the agent broke. The reason for the stop lived only in memory, so the restart lost it. What changes for you. That turn now reads "Interrupted after N" with the muted mark and no error row. Every stop that ends work saves a Stop event in the chat's history before the agent is ended, and the turn's end is written from it, live or at relaunch. A turn nobody stopped still reads Failed, and a turn the relaunch can't prove ended still reads "Couldn't confirm". Fixed during review
Deferred / known
Verified
Not verified
|
…nt-b # Conflicts: # src/main/native-chat/agent-session-journal/journal-store-collaborators.ts # src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts # src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts # src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts # src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts # src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts # src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts # src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts # src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts # src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts # src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts # src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts # src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts # src/main/ssh/orcad-remote-node-runtime.ts # tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts
…nt-b # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts # src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts # src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts
…s no second Stop event The idle sweep finishes a stop left owed with that stop's own cause. It is the same stop, so its event stands alone and the child's end keeps the cause, for a person's close and an eviction.
|
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. 📝 WalkthroughWalkthroughThe change moves Stop attribution from session-close causes to journal Stop events. Host lifecycle code records qualifying Stop events, and journal helpers match them to turns and opening submissions. Claude result handling and provider recovery use this attribution when settling interrupted turns. Claude, Codex, and shared session-close APIs no longer pass stop causes. Tests cover Stop matching, turn settlement, host restarts, and close behavior. Priority: ➖ Normal Merge Risk: 🟡 Moderate · up to When a person closes or stops a running chat, the stopped turn can still be shown as failed rather than interrupted, which is the problem this change is meant to fix. Waiting for the Stop record to finish before stopping the agent is a small fix and should be made before merging. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to Cancellation history now affects how interrupted work is recovered. The examined paths retain session-specific checks and keep stopping available when saving that history fails. No concrete new security exposure was established, but recovery and failure coverage remains incomplete. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 4 | ❓ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 52.46% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 61 functions across 50 files. (22 skipped: 22 over the file limit.) ✨ 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: c6508427-792b-4dfe-80ea-526898428a68
📒 Files selected for processing (76)
src/main/claude/claude-api-retry-idle-sweep.test.tssrc/main/claude/claude-close-turn-end.test.tssrc/main/claude/claude-journal-translator-contract.tssrc/main/claude/claude-message-journaling.tssrc/main/claude/claude-open-turn.tssrc/main/claude/claude-replay-turn-resolution.tssrc/main/claude/claude-result-journaling.tssrc/main/claude/claude-result-outcome.tssrc/main/claude/claude-structured-child-work-captures.test.tssrc/main/claude/claude-structured-control-actions.test.tssrc/main/claude/claude-structured-control-actions.tssrc/main/claude/claude-structured-dispatch-admission.test.tssrc/main/claude/claude-structured-in-turn-stop.test.tssrc/main/claude/claude-structured-journal-translation.tssrc/main/claude/claude-structured-prompt-ownership.tssrc/main/claude/claude-structured-provider-fallback.tssrc/main/claude/claude-structured-session-acquisition.tssrc/main/claude/claude-structured-session-adapter.tssrc/main/claude/claude-structured-session-close.tssrc/main/claude/claude-structured-session-state.tssrc/main/claude/claude-turn-lifecycle-item.tssrc/main/claude/claude-turn-opening.tssrc/main/claude/claude-turn-outcome.test.tssrc/main/claude/claude-turn-ownership.test.tssrc/main/codex/codex-close-turn-end.test.tssrc/main/codex/codex-provider-retry-idle-sweep.test.tssrc/main/codex/codex-structured-journal-translation.tssrc/main/codex/codex-structured-session-adapter.tssrc/main/codex/codex-structured-session-close.tssrc/main/codex/codex-structured-session-state.tssrc/main/codex/codex-structured-session-teardown.tssrc/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.tssrc/main/native-chat/agent-session-journal/journal-row-builders.tssrc/main/native-chat/agent-session-journal/journal-stop-marks.tssrc/main/native-chat/agent-session-journal/journal-stop-turn-end.tssrc/main/native-chat/agent-session-journal/journal-store-collaborators.tssrc/main/native-chat/agent-session-journal/journal-store.tssrc/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.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-claude-stop-turn-end.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.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-close.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.tssrc/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.tssrc/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.tssrc/main/native-chat/agent-session-wire/structured-agent-session-event-sink.tssrc/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-eviction.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host.tssrc/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.tssrc/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.tssrc/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.tssrc/main/native-chat/agent-session-wire/structured-conversation-command.test.tssrc/main/native-chat/agent-session-wire/structured-conversation-command.tssrc/main/runtime/structured-worker-at-rest.test.tstests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.tstests/e2e/structured-chat-owner-status-activation.unit.test.ts
💤 Files with no reviewable changes (4)
- src/main/claude/claude-structured-control-actions.test.ts
- src/main/claude/claude-turn-ownership.test.ts
- src/main/claude/claude-journal-translator-contract.ts
- src/main/claude/claude-structured-session-close.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 5 remain after this review.
| // Judged before the kill: a stop that ends nothing writes nothing. | ||
| const recorded = (await stopEndsWork(context, sessionId, session, ending)) | ||
| ? recordStopEvent(context, sessionId, session, ending) | ||
| : Promise.resolve() |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
#!/bin/bash
rg -nP -C8 'function journalStopEventRowBuilder' --type=ts
rg -nP -C3 '\bat\s*:' src/main/native-chat/agent-session-journal --type=tsRepository: stablyai/orca
Length of output: 6788
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- changed file outline ---'
ast-grep outline src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts
printf '%s\n' '--- host lifetime ---'
sed -n '1,260p' src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts
printf '%s\n' '--- stop event callers and definitions ---'
rg -n -C8 'recordStopEvent|stopEndsWork|beforeProviderChildStop|stop-provider-child|evictStructuredAgentSession|settleWork|turnEndAfterStop|personStopDecidesTurn|endedByPersonsStop' src/main/native-chat --type=tsRepository: stablyai/orca
Length of output: 42398
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- turn-end rules ---'
rg -n -C12 'export function (turnEndAfterStop|personStopDecidesTurn)|endedAt|completedAt|observedAt|exitObservedAt' src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire --type=ts
printf '%s\n' '--- stop-event implementation ---'
sed -n '1,260p' src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts
printf '%s\n' '--- adapter verdict construction ---'
rg -n -C10 'observedAt|exitObservedAt|completedAt' src/main/native-chat/agent-session-wire --type=tsRepository: stablyai/orca
Length of output: 45668
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- exact non-test timestamp bindings ---'
rg -n -C8 --glob '!*.test.ts' 'exitObservedAt|observedAt' src/main/native-chat/agent-session-wire
printf '%s\n' '--- stop event implementation ---'
rg -n -C10 --glob '!*.test.ts' 'function recordStopEvent|export .*recordStopEvent|recordStopEvent\(' src/main/native-chat/agent-session-wireRepository: stablyai/orca
Length of output: 42000
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- recordStopEvent body ---'
sed -n '73,115p' src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts
printf '%s\n' '--- stop verdict and stop proof ---'
rg -n -C12 --glob '!*.test.ts' 'StructuredAgentSessionStopVerdict|stopAgentSessionProviderRoot|completedAt' src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts src/main/native-chat/agent-session-wireRepository: stablyai/orca
Length of output: 42105
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- appendStopEvent declarations and implementations ---'
rg -n -C12 --glob '!*.test.ts' 'appendStopEvent|journalStopEventRowBuilder' src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire
printf '%s\n' '--- timestamp assignment near journal queue writes ---'
rg -n -C10 --glob '!*.test.ts' 'rowBuilder|ts:|Date\.now|now\(\)|next.*timestamp|timestamp' src/main/native-chat/agent-session-journal --type=tsRepository: stablyai/orca
Length of output: 41799
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- row writer ---'
sed -n '1,240p' src/main/native-chat/agent-session-journal/journal-row-writer.ts
printf '%s\n' '--- write queue ---'
sed -n '1,260p' src/main/native-chat/agent-session-journal/journal-write-queue.tsRepository: stablyai/orca
Length of output: 5848
Await the Stop event before stopping the child.
appendStopEvent passes the Stop builder to the serialized row writer. The writer runs the builder only after earlier queued writes complete, so event.at can be later than the time when recordStopEvent starts.
The provider exit can settle the interrupted turn with its earlier exit timestamp. The cancellation rule requires completedAt >= stop.event.at. The turn can therefore miss outcome: 'cancellation', and a later settlement can add the provider-stopped row.
recordStopEvent converts append failures into a resolved promise, so awaiting it before the child stop does not block the stop when the journal write fails.
🐛 Suggested fix: await the Stop row before eviction
const recorded = (await stopEndsWork(context, sessionId, session, ending))
? recordStopEvent(context, sessionId, session, ending)
: Promise.resolve()
+ await recorded📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Judged before the kill: a stop that ends nothing writes nothing. | |
| const recorded = (await stopEndsWork(context, sessionId, session, ending)) | |
| ? recordStopEvent(context, sessionId, session, ending) | |
| : Promise.resolve() | |
| // Judged before the kill: a stop that ends nothing writes nothing. | |
| const recorded = (await stopEndsWork(context, sessionId, session, ending)) | |
| ? recordStopEvent(context, sessionId, session, ending) | |
| : Promise.resolve() | |
| await recorded |
There was a problem hiding this comment.
ℹ️ No critical issues — one narrow relaunch edge worth a look, noted inline.
Reviewed changes
- Durable Stop event. Every stop that ends work now writes
JournalStopEvent({ reason, turnId?, at, caller? }) into the journal: the person's Stop already did (Part A), and now close (user-close), host stop (host-stop) and eviction (evict) do too viastructured-agent-session-host-stop-event.ts; quit and idle-rest write none. - One turn-end rule.
turnEndAfterStop(journal-stop-turn-end.ts) runs inside the journal's row builder and addsoutcome: 'cancellation'to aninterruptedend with no verdict when the latest person's Stop named the turn or bound to the send that opened it, and the end is no earlier than the Stop. It is wired intobuildJournalItemRow,journalLifecycleBatchRowBuilderand the chunk sizer. - In-memory cause deleted.
closeSession/disposeSessionlose the cause argument (adapters, router, both providers);StructuredAgentSessionEndedEvent.stopCause,turnVerdictForChildEnd,childEndCauseOfEndedEvent,stopIsTheUsers,recordTurnStopand Claude's per-turn sent-Stop record are removed. - Relaunch.
turnVerdictFromDeathEvidencecounts a Stop that named a live turn as a later sign of life;endedByPersonsStopdrops the "stopped while this response was in progress" row when every interrupted turn is a person's Stop's. - Old Claude CLIs.
claudeResultOutcomereturns no verdict when the journal's Stop rule decides, so an error result with noterminal_reasonafter a person's Stop keeps no error row. /clearnow ends the agent asuser-closerather thanuser-stop. NewopenedBy/clientMessageIdfields are in-memory only.
I verified no stale references to the removed symbols, that the persisted carrier (JournalTombstoneRow.stopEvent?) and stopped-turn outcome are unchanged for older readers, and that the three writers (adapter settle, host fallback, relaunch) each fold the Stop before building the end. CI is green at 32c945f44c.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
| ): number | undefined { | ||
| const stop = journal.stopMarks.latest() | ||
| const turnId = readAgentJournalTurn(item.body)?.turnId | ||
| return stop && turnId !== undefined && stop.event.turnId === turnId ? stop.event.at : undefined |
There was a problem hiding this comment.
A person's Stop pressed before the turn showed may still read Failed after a probe-based relaunch. turnEndAfterStop binds an end to a Stop that named no turn via the turn's opening send, but the relaunch only dates the end from a Stop that named the turn (stopFoundTurnLiveAt). With pid-absent evidence the end is max(lastProvenAliveAt, startedAt); when the turn row was received before the Stop yet folded after it, both sit below stop.event.at, so the endedAt >= stop.event.at guard rejects the binding. exit-observed evidence is immune (it dates at observedAt). The live binding path and the named-Stop restart path are each tested; their intersection is not, so a test would settle whether this edge is intended.
Technical details
# Turnless Stop not dated at a probe-based relaunch
## Affected sites
- `src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts:60-67` — `stopFoundTurnLiveAt` matches only `stop.event.turnId === turnId`, so a turnless Stop contributes no `liveAt`.
- `src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts:56-57` — the `pid-absent` probe path dates the end at `lastProvenAliveAt`; `runningTurnLifecycleRevisions` then keeps `Math.max(completedAt, startedAt)`.
- `src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts:125-137` — `stopEndsTurnAsCancellation` rejects the binding when `endedAt < stop.event.at`.
## Required outcome
A turn the Stop bound to by the send it stopped should read the person's `cancellation` at relaunch, exactly as when the Stop named the turn.
## Suggested approach
Feed the same send-binding result (`openingSubmission`/`isStopTarget`) into the relaunch dating so `liveAt` also covers a turn the Stop bound to by its stopped send — or, if the current behavior is intended, add a focused test at the intersection so it is pinned.
## Open question
Is a turnless Stop bound to a turn that was received before the Stop (so it opened but had not folded) intended to read Failed after a probe-based relaunch?…o main passes lint (#24496) The host file reached 302 lines after #24203 and #24311, over the 300-line limit, so the static-analysis job failed on every commit to main. The five chat-tab members now come from createStructuredAgentSessionTabSurface in the existing structured-agent-session-host-tabs module; behaviour is unchanged.

The problem
You press Stop on a native Claude or Codex chat while it works, or you close the chat while it works. If Orca then quits, crashes or restarts before the agent reports that the turn ended, the relaunched Orca shows that turn as "Failed after N" with the red failed mark, plus a red row saying the agent stopped while the response was in progress. It looks as if the agent broke, when you stopped it yourself. Had Orca stayed up, the same turn would read "Interrupted after N" with the muted mark and no error row.
Three ways to hit it, all fixed here:
Why it happened. Why an agent was stopped (your Stop, your close of the chat, the host stopping the agent, or an idle eviction) lived only in memory. About ten call sites passed it through the host's stop step into each provider's close code, and it became a lasting fact ("this turn was cancelled") only when the turn's end was written. If Orca died first, the reason died with it. On relaunch, Orca settles a turn left running from its proof that the old agent process is gone, and that proof says nothing about a Stop, so the turn read as a failure.
ELI5
When you stop or close a working chat, Orca now first writes a line into the chat's saved history: "stopped by you, during this turn". Whenever the turn's end is written, right away or after a restart, Orca checks that line. If you stopped this turn and it ended after you did, it reads "Interrupted", not "Failed". Before, that knowledge lived only in Orca's memory, so a restart forgot it.
What Changed
For you
Mechanism (on the host that runs the agent, local or SSH)
A lasting Stop event for every stop that ends work. Part A (fix(native-chat): Stop's pause is worked out from the chat's history, so a steered message is never re-sent #24072) added a Stop event to the chat's saved history,
{ reason, turnId?, at, caller? }, written by your Stop. Now every other stop writes one too, before the agent is ended: closing the chat (user-close), the host stopping an agent (host-stop), and an eviction (evict). A stop writes an event only when it ends work, meaning a running turn or a send still waiting for an answer. Quit writes none, and neither does the idle sweep putting a resting agent away.One rule wherever a turn's end is written.
turnEndAfterStop(journal-stop-turn-end.ts) runs inside the history's ordered write, where every turn row is built: by the provider's own code, by the host's fallback after a close, or by the relaunch. It adds the outcome "cancellation" to an end that has no verdict of its own when all of these hold:A provider's own verdict (success, failure, cancellation) always stands.
A Stop pressed before the turn showed names no turn. It applies only to a turn opened by a send that had already been handed to the agent when you pressed Stop and was not refused. Orca reads that from history it already keeps: each turn row records the send that opened it, and each send sits in the history where it was handed over. Anything sent after the Stop (a message, a mail delivery, a queue drain) opens a turn that is not the Stop's, and so does a card the Stop held that Resume later sent.
Relaunch. The relaunch dates a dead agent's turn end by the agent's last sign of life. A Stop event that found the turn running now counts as a later sign of life, so the end lands after the Stop and the rule applies. When every turn the relaunch ends is a person's Stop's, it no longer adds the "stopped while this response was in progress" row, as a live Stop doesn't.
Older Claude versions report a Stop as a plain error. Claude's result handling now asks the history the same rule (naming the send that opened the turn, whose rows may not have landed yet) instead of keeping its own record of the Stop it sent.
Host-side stops wait briefly for rows in flight. Closing the chat, eviction and a host stop decide "does this stop end work?" after waiting up to 1 s for the agent's streamed rows to land. If the wait fails, they read the history as it stands. If it runs past 1 s and the agent has accepted a send whose turn hasn't shown yet, they count the agent as working, because that turn's opening row may still be in the backlog; otherwise they read the history as it stands. Quit never waits.
A host stop never overrides your Stop. While your Stop is still ending a turn (or, with no turn running, while every send still waiting is one your Stop stopped), an eviction or host stop writes nothing, so the end stays yours.
Deleted: the in-memory reason everywhere it was threaded: the reason argument on each provider's close, the router forwarding it, the reason on the "agent ended" event, Claude's per-turn record of the Stop it sent, the reason kept on an unfinished wind-down, the fixed
'user-stop'the Stop's context passed to the stop step, the reason-to-verdict mapping, and the compile-time "every stop names its reason" test, replaced by "every stop entry writes its event with its reason".Why
Differences from the common pattern
/clearnow ends the agent as your close of the chat (before, as your Stop). No visible effect, because/clearrefuses while work is pending.Linked Issue
None (maintainer). Part B of the Stop-event plan; part A (#24072) has merged.
stop: { reason, turnId?, at, answer? }. It builds on the per-turn note from fix(native-chat): every chat action press is its own action (re-land #23916 on main) #24301, now on main.Compatibility
Visual Proof
After a Stop and a crash before the turn's end was saved, the relaunch reads muted "Interrupted after 15s":
The same crash with no Stop:
Testing
Electron QA (M4Air, isolated HOME, real Claude, at 8550aa1; later commits change the host stop's wait, tests, and merge main): 6 of 6 flows pass.
What the tests prove:
Each rule was removed and shown to turn its tests red, at the commit that introduced or last changed it:
Runs, all
vitest run --config config/vitest.config.tson explicit file lists:tc:node, full-repo oxlint, the anti-slop audit and the changed-code quality gate pass.claude-structured-session-integration.test.ts, which run against the real~/.claudelogin.New tests:
structured-agent-session-stop-event-entries.test.ts,-stop-event-restart.test.ts,-stop-event-binding.test.ts,-claude-stop-turn-end.test.ts; extended: the cross-version Stop-event downgrade test.Notes
Security: no new input or permission surface. Cross-platform: no platform-specific code. SSH: the change runs on the host that runs the agent, and loss of contact still reads "Couldn't confirm". Mobile and older clients: unchanged, they read the stored outcome. Performance: a host-side stop may wait up to 1 s before ending the agent; quit never waits.