Repository navigation
fix(agent-status): a turn a crash cut off reads Interrupted, an unproven end Couldn't confirm - #23467
Conversation
No client ever called agentSession.requestHandoff or mounted the handoff chrome. Delete the handoff coordinator, the terminal-owner runtime, the proof write path and the unmounted UI. Keep agentSession.handoffStatus, which released desktop clients read for worktree activation, and let records an older build left mid handoff reconcile through the ordinary restart and recovery paths.
Eviction now drains delivered events before quit's resume-offer snapshot. An unbounded wait there sits ahead of the provider stop, so a sink whose journal write stalls kept the child running until the step deadline aborted the eviction. The offer is advisory: bound the drain and stop the child regardless. Co-Authored-By: Claude <noreply@anthropic.com>
`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.
Co-Authored-By: Claude <noreply@anthropic.com>
Six comments still named the handoff coordinator, a handoff suspend, or a terminal-owned session as live participants in the flows they describe. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…ement The removed restart handoff test pinned this branch; nothing else did. Co-Authored-By: Claude <noreply@anthropic.com>
The handoff removal dropped the per-session queue from `handoffStatus`, so a read landing mid-start reported the reservation (no owner) instead of the settled chat owner, and shipped desktop clients blocked worktree activation on it. The read is queued again, as it was before the removal. Co-Authored-By: Claude <noreply@anthropic.com>
The gate only refused a write when a PTY had been bound to a chat session, and the only code that ever bound one was the terminal handoff this branch removes. With it gone, every admit/readmit returned "admitted" unconditionally, so the checks on the renderer write path, the runtime controller backstop, terminal.send, agent prompts, preview input and orchestration pointers, the refusal fields on terminal.send and worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane orchestration routing could no longer run. Ordinary writes take the same path in the same order as before. Co-Authored-By: Claude <noreply@anthropic.com>
…alled appendLegacyTranscriptMessages fed the terminal transcript catch-up and proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost their last caller with the handoff. Their tests now go through the live entry points instead: the roster bounds through the legacy import, the pinned-read and growth tests through the ancestry replay the history window uses, and the marker rules through the string proof in their own file rather than the session-file resolver's. Co-Authored-By: Claude <noreply@anthropic.com>
A send refused because the chat's owner is not settled showed "The session is mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that reach it are a chat that is still starting, or one whose previous agent process has not yet been confirmed stopped. The message now says which of the two it is. The refusal code is unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
With the terminal handoff gone, the module named codex-tui-rollout-proof holds only the pinned rollout lookup that structured Codex launches use to resume a thread, so the name described code that no longer exists. Rename the module and its options type. Also drop a mobile allowlist assertion that pinned the removed agentSession.requestHandoff method, which no longer exists to allow.
The handoffStatus reply type still listed the terminal handoff's fields and states (terminal placement, host label, proof retry, queued and waiting phases, the to-terminal direction). No host writes them any more and the only client reader parses the reply as unknown, so they described nothing. The reply on the wire is unchanged.
…t decode Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types still admitted them, so readers across the host kept branches for values no path produces and the compiler could not point at them. The store now validates the on-disk shape, which still accepts those values so an older record is not quarantined, and maps them once while parsing: - `preparing` and `old-owner-stopped` become `recovering` - a `tui` lease becomes `native`; when it records a process it also becomes `conflicted`, the claim every build probes but never stops. A plain native owner would be stopped by restart recovery, here and in older builds. Revisions are taken over the normalized state on both sides of every compare, and the mapped record reaches disk with the store's first transaction, the same way the tab-id backfill does. The in-memory types narrow to what this build writes, and the branches that existed only for the removed values go. Structured-worker identity keeps its verdict for a former terminal owner by refusing a conflicted claim rather than a non-native kind.
…ation A reservation only ever names a native owner now, so the request no longer carries a kind and the reserved lease records `native` directly. The attach params keep `runtimeKind`: agentSession.ensure and create accept it, and the operation fingerprint stored in the ledger covers it.
…at changes nothing else Hiding a tab also committed the visibility index, so the no-op transaction wrote the file even when its open-time revision was wrong. Committing the index first leaves the pending rewrite as the only reason to write.
…ration A write carried the fence of the last frame the pane read, and the host refused it unless that fence was still current. An idle release and the restart after it each move the fence, and the release publishes nothing, so a send after a release was refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a cold start was refused as stale. Every write already names what it acts on: a send its conversation, a cancel its turn, a prompt answer its item revision, a rewind its epoch; an option is last-writer-wins. So admission stops comparing the client's fence, and the rebase that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it. The writer-lease check stays, and so does the attach's compare-and-swap. Frames now stamp the fence read when each frame is sent instead of a copy each subscriber kept, which went stale on the same release.
A journal write and its delivery to open readers were two calls, and some writers made only the first. A failed start whose lease could not be handed back, a provider revision with no frame behind it, and eviction's settlement were all journaled without reaching an open chat. A journal handle now reports every durable change, and the host's session map binds that report to the session's readers when the handle is set. Writers no longer publish what they append; the per-writer publish calls are deleted.
…dle enters only through the map
…ackfill cannot supply its rewrite The seeded record had no surface tab id, so the next open backfilled one and that rewrite alone made the no-op transaction write. The test passed with the legacy-lease rewrite signal removed.
The handoff removal deleted it alongside the terminal-owner tests, but it covers the surfaced-PTY block that still guards resume, including an agent whose ownership is unknown.
Each commit now delivers itself, so the publish a provider frame still sends afterwards found every reader caught up but still read rows and rebuilt the timeline for each one. A caught-up reader now skips the read.
# Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts
A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".
A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.
Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.
A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.
Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.
A child that proved its start and then exited before its message was handed over left the message queued: the exit settlement returned early when nothing else was in flight. Delivery then started another child for it, and a child that died the same way started another, without end and without a row. A retried settlement for an earlier generation, run by the attach that delivery started, did the opposite: with that generation's turn unfinished it rejected the message queued for the child being attached. The settlement now takes the rejection for queued messages from its caller. The unexpected exit and the eviction pass one, and it applies even with no other work in flight; the retry for an earlier generation passes none.
The recovery test still expected a recovered interruption to mark interrupted; it now marks failed, as a failure does. Formatting only elsewhere.
…d, and show it Interrupted A Claude turn that a newer send replaced before its result arrived was recorded as interrupted with no verdict, which reads as a turn cut short by something else, now "Failed". It is now recorded with its own outcome, `superseded`, where the replacement is detected. That outcome names no sender, so a dispatch from another agent is never recorded as the user's Stop, and it sets no legacy flag. Every reader handles it in an exhaustive switch: it draws the muted Interrupted mark with the plain text "Interrupted", folds as "Interrupted after N", and attention demotes it with a Stop, through the renamed agentTurnEndedOnRequest. Older builds read an arm they do not know as no verdict, which is what this turn carried before, so their rows keep reading done; the cross-version suites pin an older desktop's journal and status readers and an older phone.
…rpose agentTurnEndedOnRequest becomes agentTurnEndedOnPurpose: a user's Stop or a newer request's replacement, never a fault. The Claude turn-end comment no longer says a replaced turn carries no verdict.
The phone's workspace list dot re-derived the desktop worktree card's precedence ladder and freshness fold by hand on the phone, and each review loop found a new way that copy drifted from the desktop. It also aged host rows on the phone's clock. The list dot returns to main's behaviour; the phone's agent rows already show Failed / Interrupted (#22944). A follow-up will have the host derive the worktree's display status once, from the same ladder the desktop card uses, and publish it to the phone, after #23467 changes the same verdict vocabulary. No other mobile change in this branch is needed, so the branch no longer touches mobile/.
…atus-reads-interrupted # Conflicts: # mobile/src/session/MobileNativeChatMessage.tsx # src/main/claude/claude-journal-translator-contract.ts # src/main/claude/claude-open-turn.ts # src/main/claude/claude-structured-control-actions.test.ts # src/main/claude/claude-structured-journal-translation.ts # src/main/claude/claude-structured-prompt-ownership.ts # src/main/claude/claude-structured-provider-fallback.ts # src/main/claude/claude-turn-ownership.test.ts # src/main/codex/codex-structured-journal-translation.ts # src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts # src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts # src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts # src/main/native-chat/agent-session-wire/structured-agent-session-host.ts # src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts # src/renderer/src/components/dashboard/DashboardAgentRow.tsx # src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx # src/shared/structured-agent-session-projection.ts # src/shared/structured-agent-session-turn-timing.ts
…atus-reads-interrupted Conflicts: - claude-turn-ownership.test.ts: main replaced the delivery-fence Stop tests; kept main's tests and this branch's recordTurnStop/withdrawTurnStop fakes. - codex-structured-session-state.ts: main removed the turn-process sweep import; kept that removal and this branch's stop-cause import. Main now routes a Stop naming the live Claude turn through the conversation Stop, so that path now carries the named turn's user-stop, as the named path already did. The stop-cause types move to their own module so the adapter contract stays under the line limit.
…atus-reads-interrupted The host file ran four lines over its limit once main's queued-message wiring met this branch's close cause: the close takes the lifetime's own signature, and the attach context drops a publishStatus the lifetime context it spreads already carries.
…ads Failed after N On main a restart-cut turn shows the done tick. Pin the chat's turn bar and the tab's mark for both cuts, through the recovery settlement and the quit's child-end mapping, and pin the quit's turn bar through the host's own quit.
… turn The chat's Stop button names no turn. Claude's conversation Stop recorded the user's stop only for a named turn, so an older CLI's error result after that Stop read Failed. It now records it on the open turn through the same intent, dropped when Claude refuses the interrupt and never carried to the next turn.
The lifetime context type makes publishStatus optional, so the attach context that spreads it no longer satisfied its own type once its duplicate publishStatus went. The host's lifetime context is now inferred, and checked with satisfies, so the spread carries the member it always sets.
The Activity page's status grouping put the Interrupted group (a user's Stop, or a turn a newer request replaced) above Working and Monitoring, so a Stop still sorted like news there while the sidebar, worktree card and Cmd+J rank it below live work. It now follows live work and stays above Done; Failed and Couldn't confirm keep their places above live work.
|
Status: ready for review. Head What a user now sees (Brennan's ruling): a Stop the user presses, and a turn replaced by a newer message, read muted "Interrupted" / "Interrupted after N" on every surface, for terminal and chat agents. A turn cut short by anything else (the agent killed, Orca crashing, or Orca quit mid-turn) reads red "Failed" / "Failed after N". An end Orca can't prove reads amber "Couldn't confirm". On main, a crash, kill or quit reads as a green finish and a Stop reads red. Review: ten review loops; the last one was clean apart from a wording fix and moving the Activity page's Interrupted group below live work (matching the sidebar and Cmd+J). An architecture challenge replaced the original stop-recording design with typed stop causes. Every change has tests that fail when it's removed, including a restart- and quit-cut turn that reads a green done tick on main and red "Failed after N" here. Readiness check: passed with no release-blocking findings at Live QA (a remote Mac with the real Claude CLI; screenshots in Visual Proof): crash, kill and quit read red "Failed after N"; a Stop reads muted "Interrupted after 26s"; a stopped chat and a killed chat sit side by side as grey and red in separate groups; a normal reply and restart are clean. Not covered live: a turn replaced by a newer message (the installed Claude CLI folds a mid-turn message into the running turn, so it never happens; unit and cross-version tests cover it), the phone app (the Android emulator couldn't enter the pairing link), and an older Claude CLI that doesn't report why a turn stopped (covered by tests). Follow-ups (not this PR):
Final readiness check (on
|
|
| const ended = session.lastEndedChild | ||
| if (session.child || ended?.cause !== 'user-close') { | ||
| return true |
There was a problem hiding this comment.
If a user closes a chat while a message is queued but no agent is active, and saving the message's rejection fails, the message remains pending. Because no agent ended, there is no user-close marker for this check to find. On the next delivery wake, the loop can start an agent and send the message the user closed.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Note Repository guideline files applied to this review (1)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 (18)
🚧 Files skipped from review as they are similar to previous changes (7)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review. 📝 WalkthroughWalkthroughThe change adds host-observed Priority: ⬇️ Low Merge Risk: ⚪ Minimal · up to The changes distinguish unexpected cutoffs from intentional stops and unconfirmed endings while preserving provider verdicts and older-client compatibility. No actionable merge-blocking issue is established; merge after normal checks pass. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to Stop and crash handling now preserve why work ended, with safeguards against treating an uncertain shutdown as successful. No newly introduced security vulnerability was established in the reviewed paths. Access-control and mixed-version coverage remain incomplete, so the assessment is not a complete security assurance. 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)
Full details: Docstring CoverageExplanation Docstring coverage is 48.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 50 functions across 61 files. (7 skipped: 7 unsupported.)
✨ 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.
ℹ️ No critical issues — one behavioral observation inline, one nitpick below.
Reviewed changes
Read the complete 166-file diff and ran the new/affected suites (agent-main-agent-verdict, native-chat-turn-status, agent-status-types, structured-agent-session-close-verdict, structured-agent-session-recovered-turn-clock, claude-turn-outcome, claude-structured-in-turn-stop) — all green.
- Derived host-observed verdicts —
agentTurnVerdictworks outinterruption/unconfirmedfrom the turn record when the provider gave none, in one shared place consumed by the status summary and the folded turn header. - Stop causes —
closeSession(sessionId, cause)now carriesuser-stop | user-close | host-stop | evict;turnVerdictForChildEndmaps a cause to the recorded verdict, only a user's stop/close beingcancellation. Every stop site must name a cause, and an aborted-then-retried close keeps the owed cause. - Stop inside a live Claude turn — the user's stop is held against the open turn so an error result with no
terminal_reasonreads as their cancellation, and is withdrawn when the CLI refuses the interrupt. - Superseded turn — a Claude turn replaced by a newer request is journaled
outcome: 'superseded'and reads muted "Interrupted". - Reader mapping — sidebar, tab, worktree card, Cmd+J, dashboard, Activity, mobile and notifications move to exhaustive switches over the new outcome set; the legacy
interruptedflag is still only a user'scancellation, and the cross-version suite pins old clients dropping the new arms to "done".
ℹ️ Nitpicks
agentTitlereturns the literalCouldn't confirm agent finishedfor anunconfirmedend (src/renderer/src/components/activity/activity-thread-presentation.ts:85), where every other surface says the shorter "Couldn't confirm". The phrasing reads a little oddly for a title — "Agent result unconfirmed" or reusing the existing label would scan more cleanly.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| this.publish(this.current, { | ||
| state: 'interrupted', | ||
| completedAt: observedAt, | ||
| outcome: 'superseded' |
There was a problem hiding this comment.
This new journaled outcome reaches the turn-completion feed: open() publishes the superseded end before the replacing turn's running row, so the feed observes a settled request with a truthy outcome for one commit and broadcasts a completion. On the client that becomes an unread mark plus an OS notification worded "stopped" for a turn a newer request just replaced — a user-visible change the PR description rules out ("the turn-completion feed ... still reports only provider verdicts"). If that is intended, the description should say so; if not, the feed should skip superseded.
Technical details
# A superseded turn now fires a completion event
## Affected sites
- `src/main/claude/claude-open-turn.ts:138` — `open()` publishes the superseded end, then the replacing turn's running row; the sink queue commits them separately, so the feed observes turn 1 settled with no later turn.
- `src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts:138` — `if (!request.outcome) return`; `superseded` is truthy, so it broadcasts. `state.owesWork` is false because the observed status is `idle`.
- `src/renderer/src/components/native-chat/structured-attention-dispatch.ts:47` — the gate is membership in `AGENT_JOURNAL_TURN_OUTCOMES`, which now includes `superseded`, so it delivers the notification.
- `src/main/ipc/notification-options.ts:91` — words it `stopped`.
## Required outcome
- Decide whether a turn replaced by a newer request (a user's own send, or an orchestration dispatch into a busy worker) should raise an unread marker / OS notification.
- If it should, correct the PR description's "still reports only provider verdicts".
- If it should not, skip `superseded` at the completion feed — it is a recorded outcome, not a provider verdict.
## Open questions for the human
- Is a "stopped" notification for a turn the user or an orchestration dispatch just replaced desirable, given attention already demotes it?…atus-reads-interrupted
…hich are derived The journal now records superseded as well as the provider's verdict and a stop; interruption and unconfirmed are derived on read. The resume row no longer claims interrupted renders red.
…atus-reads-interrupted
…ict cause, like its siblings
…ges crossed (#24231) * test: two tests catch up with the journal-database store and the close cause #24006 moved the session store into the chat journal database, and #23467 made close take a cause; #23684's and an older tab-table test still used the old calls, so main's typecheck fails. * test: the startup-reconcile close test passes the close cause too

Based on main; it was stacked on #22944, which has merged. #23456, which it pairs with, has also merged; see "Together with #23456" below.
ELI5
If a chat agent died partway through a turn (its process crashed, or Orca went down with it), every status surface showed a green "done" check, as if the work had finished: the sidebar, tab, dashboard, Activity page, Cmd+J and phone. Now a turn cut short by anything other than the user's Stop or a newer message reads Failed in red, like any other failure, and a turn whose end Orca cannot prove reads Couldn't confirm. Neither looks like a clean finish.
A turn the user stopped, or one a newer message replaced, reads Interrupted, in a muted grey that is neither the finished green nor the failure red, and it is ranked below news in attention, because the user already knows about it. Before, a Stop and a crash drew the same red Interrupted dot. To keep the two apart, every place that stops an agent now says who asked for the stop.
Inside the chat, a turn a crash cut off used to fold to "Worked for 12s", exactly like a finished turn. It now folds to "Failed after 12s", and a turn the user stopped folds to "Interrupted after 12s".
Scope: which chat this affects
experimentalStructuredNativeChatsetting, which is off by default.What Changed
This PR has two halves: status now reports how a cut-off turn ended, and the stop path now records who asked for a stop.
Half 1: status reports how a turn ended
Before
completed,interrupted(Orca saw the agent die), orunverifiable(Orca could not prove how it ended). The provider's own verdict (success, failure or cancellation) was stored beside it only when the provider gave one.After
interruptedbecomesinterruption, andunverifiablebecomesunconfirmed("Couldn't confirm"). Acompletedturn with no verdict stays unknown, as before. This happens in one shared function,agentTurnVerdict, which the chat's folded turn header also uses.failureand aninterruptionread red "Failed", and outrank live subagent work as a failure always has; a user's Stop (cancellation, or an old host's legacy flag) reads "Interrupted", drawn with the existing muted foreground token;unconfirmedreads "Couldn't confirm"; a success reads done. The phone mirrors it, pinned by a parity test.unconfirmedgets a newunconfirmedmark, "Couldn't confirm", drawn with the existing amber dashed-ring "no recent update" glyph. There is no new colour.agentTurnEndedOnPurpose). Clean-finish policy (hibernation, pane ownership, the star prompt) treats all of them as not clean. Notifications word an interruption "failed", as a failure is.interruptedflag, which older readers take to mean "the user stopped it", is still set only for a user's cancellation. A crash never sets it.unconfirmedstatus. They rank a pending question, then a failure, then live work, then an unconfirmed end, then a user's Stop, then done.components.native-chat.status.failedAfter), "Interrupted after N" for a user's Stop (new keycomponents.native-chat.status.interruptedAfter), and "Worked for N" otherwise. Both keys are in all six catalogs and the boot catalog. Anunverifiableturn still shows no duration header, as before.Half 2: the stop path records who asked
Before
/clear, quitting, idle release, a worktree teardown, an orchestration stop) told the agent adapter only to "close". The adapter marked the running turninterruptedwith no verdict. A user closing their own chat and an Orca-driven shutdown therefore looked the same as a crash, and all three would have read "Interrupted".After
user-stop,user-close,host-stoporevict. The host passes the cause to the adapter's close (closeSession(sessionId, cause)).turnVerdictForChildEnd. A user's stop or close isinterruptedpluscancellation, the user's own stop. Any other cause isinterruptedwith no verdict, which reads as a crash. The host's fallback marks any turn no adapter handled through the same mapping.cancellationwhen the user asked for the stop;cancellationis an existing value that older builds already read. And a Claude turn that a newer request replaced before its result arrived is recordedsuperseded(below).session.tabs.closewith reasonuser, or with no reason (older clients send user closes without one); and/clear, which closes the old session.session.tabs.closewith a lifecycle reason (pty-exitorcleanup).Why
The record of each turn already says whether it was cut short by a death Orca saw or ended in a way Orca cannot prove. The status feed simply did not pass that on, so every surface read green. This PR works out the status verdict from that record in one place. The stop path now carries who asked for it, so a user's Stop reads Interrupted and a crash reads Failed, and only the Stop is demoted in attention.
What is derived and what is written. The status verdict is worked out from the turn's record each time it is read, so it is never stored twice. It is
interruptionorunconfirmedwhen the provider gave no verdict. The new writes arecancellationon a turn the user's own stop cut off, andsupersededon a turn a newer request replaced. Each has to happen when the turn ends, because that is the only moment that knows why. The journal's verdict type holds only recorded values, so the compiler stops anyone from journalinginterruptionorunconfirmed. The new values ride the existing optionalmainAgent.outcomefield, which is persisted in status rows and sleep records, andsupersededalso rides the journal's existing optional turnoutcome. That makes them permanent values of those fields, so their names were chosen once:interruptionis a proven death nobody asked for,unconfirmedis an unknown end that is never success, andsupersededis a turn a newer request replaced, whoever sent it.Alternatives considered
cancellation. Rejected. Attention demotes a user's stop, so a crash the user has not seen would sink in Smart Sort. The legacy flag would also tell older clients that the user stopped it.Differences from the common pattern
What matches. Comparable hosts show a user's stop in a neutral tone (for example "You stopped after N" or "Stopped manually"), and a turn cut off by a crash, a host death, or the app being shut down or restarted as a red failure ("Failed", or a red error row). Orca now does the same: "Interrupted" in the muted tone for a Stop, and red "Failed" for anything else, including quitting Orca mid-turn.
agentSession.closecarries no reason on the wire today, so the host records any close a client sends as the user's, and a turn it cuts short reads muted "Interrupted". Follow-up: add an optional close reason to the wire (wire-safe for mixed versions); no ticket yet.A superseded Claude turn. A Claude turn that a newer send replaced before its result arrived is recorded with its own outcome,
superseded, where the replacement is detected. It reads "Interrupted" in the muted tone, folds as "Interrupted after N", and attention demotes it with a Stop. The replacement fires for any send Orca dispatched, the user's own message or an orchestration dispatch into a busy worker chat, and nothing there says who sent it, so it is never recorded as the user's cancellation, its row says plain "Interrupted" rather than "Interrupted by user", and it sets no legacy flag. A Codexturn/completedwith a status Orca does not recognise reads Failed; the translator records it asinterruptedwith no verdict.Together with #23456
interruptedwhen it proves the agent that wrote the turn is gone: an exit Orca watched, or a probe that found the process gone. This PR shows that turn as red "Failed" and folds it as "Failed after N".unverifiableand reads "Couldn't confirm".interruptedonce the proof lands, and the status and the folded header move to "Failed after N" with it.Remote and mixed versions
turnOutcome, andmainAgent.outcomeon status rows,worktree psrows, history entries and sleep records. Status summaries and mirrored rows have no whole-frame validator, so nothing rejects a frame.turnOutcomewithout checking the value, and its only reader of that value checks forcancellation, so the row reads done, as today. Its status-row parser drops an unknown value as a field and keeps the row, and its sleep-record parser keeps the record. All three are pinned against v1.4.214 in the cross-version suite.worktree psrow by theinterruptedflag, which is false for both values, so it reads done (pinned against v1.4.212).interruptedwith no verdict, so a new client folds that turn as "Failed after N". That is a known display difference.interruptedwith no verdict. After the upgrade that turn works out asinterruption, so it folds as "Failed after N" and, when it is the chat's latest request, shows the red Failed mark. The same holds for a Claude turn a newer message replaced before this PR: it was saved asinterruptedwith no verdict, so after the upgrade it reads "Failed after N" instead of the muted Interrupted mark a replaced turn gets now. That affects saved history only, changes display only, and is rare, because current Claude CLIs fold a message sent mid-turn into the running turn instead of replacing it. No saved data changes.supersededon older builds. Older readers take an outcome they do not know as absent, which is what a replaced turn carried before. The journal row schema keepsoutcomean open string, the turn-outcome reader returns unknown for it, a downgrade's rewind drops it and keeps the row, and the old completion feed sends nothing. So an older desktop keeps the row and reads it done, and an older phone, which reads only the legacy flag, reads it done too: exactly what a replaced turn looks like on those versions today. The cross-version suite pins the journal reader, the status row builder and parsers against v1.4.214, and the phone against v1.4.212.agent.status.changedevent passesmainAgent.outcomethrough as a string, so plugins now also seeinterruption,unconfirmedandsuperseded.Linked Issue
Part of the native-chat turn-facts series. There is no public issue.
Visual Proof
Full-app QA of this PR's final head (
8e50d95b8b7) against its merge base with main (8b01590847d, which already includes #23456), on a remote macOS machine with the real Claude CLI, from identical starting states per arm. Each scenario ran Claude onsleep 120and acted 25-28 s in.Green done, "Worked for 19s"
Red "Failed after 18s" on the tab and sidebar
Listed under Done
Listed under Failed, red
Green done, "Worked for 27s"
Red "Failed after 25s"
Green done, "Worked for 27s"
Red "Failed after 25s"
Red "Interrupted" dot on the tab and sidebar
Muted "Interrupted after 26s"; the sidebar says "Interrupted by user"
An "Interrupted" group with a green done check
"Interrupted", muted
The Stop reads red and the killed chat green
The Stop's tab is grey, the killed chat's tab red, "Failed after 23s"
Separate "Failed" and "Interrupted" groups
~/.claudeconfig was unchanged. Two Codex config files on the test machine changed while this run was stopped, from that machine's own Orca, not this run.Testing
Each new test was run red with its core change removed, then green with it restored:
interruptedorunverifiableturn with no provider verdict reportsinterruptionorunconfirmed, and a provider verdict still wins. Red when the feed reads only the provider's verdict.structured-agent-session-recovered-turn-clock.test.ts): a running turn is marked by the next host's recovery, first with an observed exit and then with no evidence. This is checked on the published status, the hook server's status row and the display mark (failedorunconfirmed), with the legacy flag off and no completion event. Red under the same removal.paired-agent-row-turn-outcome-arms.test.ts), the status-row parser, the sleep-record parser and the mobile copy all keep a row that carries a value they cannot name, and read it as done. Red when the reader or parser stops narrowing the value. Cross-version cells cover v1.4.212 (phone) and v1.4.214 (desktop parser, structured row builder, sleep records).structured-agent-session-recovered-turn-clock.test.ts, "reads Failed after N, marked failed, for a turn cut off by a restart" and "... by quitting Orca"; the quit through the host's own quit instructured-agent-session-close-verdict.test.ts): the chat's turn bar reads "Failed after 1s" and the tab and sidebar mark readsfailed. On main the same restart-cut turn shows the green done tick. Red when the host stops derivinginterruptionfrom an interrupted turn (the mark reads done), red when the mark mapsinterruptionto done, and red when the turn bar mapsinterruptionback to "Worked for".interruptionis mapped into it.interruptiongoes through the stop branch.structured-agent-session-close-verdict.test.ts): a user's close marksinterruptedpluscancellation, publishesturnOutcome: 'cancellation'and folds as "Interrupted after". An eviction and a quit have no verdict and fold as "Failed after". Also covered: the host fallback, a turn whose start landed only as the agent stopped, a turn cut off before the close, a turn that finished during the stop, a provider verdict, a close that aborts and is retried, a user's close or an eviction whose cleanup the idle release finishes (each keeps its own cause; red when the retry uses the idle release's cause), and a crash the adapter saw before the close.codex-close-stop-cause.test.ts,claude-close-stop-cause.test.ts): a user close marks the running turn as a cancellation.evict,host-stopor no cause leave it a crash, and so does an exit the adapter saw first. Red when either adapter drops the cause.session.tabs.close(structured-agent-session-tab-close-cause.test.ts): no reason anduserare the user's close;cleanupandpty-exitare not. Red when the no-reason case maps to eviction.structured-agent-session-provider-child-record.test.ts): the waiting message is rejected as closed with the chat, no agent starts, and no error row is written. A message sent after the close is delivered, and so is one sent after a journal epoch change, where positions start again from the beginning. When saving the rejection fails, no agent starts, and the next send closes the earlier message and is delivered. Red when the user's close is made to go on like a Stop (an agent starts), to fail like an exit (an error row), when the rejection is dropped or widened to later messages, when the epoch check is dropped, or when a failed rejection is ignored.claude-turn-outcome.test.ts,claude-structured-in-turn-stop.test.ts): an error result with no reason after the user's Stop reads as a cancellation with no error row, also through the real cancel path. The same result with no Stop, or one with a failure reason and no Stop, stays a failure. A turn that finished during the Stop stays a success. The stop does not reach the next turn, ends when its own turn ends, is dropped when Claude refuses the interrupt, and a host's stop does not count as the user's. A Stop that names no turn (the chat's Stop button) reads the same as a named one: a cancellation, dropped on a refusal, and kept off the next turn. Each case is red when its part of the change is removed.claude-turn-outcome.test.ts,claude-structured-journal-translation-turn-timing.test.ts, plus the mark, status line, dashboard, header, notification and mobile tests): the replaced turn is recordedsuperseded, reads the muted "Interrupted" with no "by user", folds as "Interrupted after 12s", and is demoted in attention with no legacy flag. Red when the outcome is not written, and red for each reader mapped elsewhere.NativeChatMessageList.interrupted-turn-fold.test.tsx, plus the shared status and mobileMobileNativeChatTurnStatustests): three settled turns (finished, crash-cut and user-stopped) render "Worked for 12s", "Failed after 12s" and "Interrupted after 12s". Red when a Stop folds as worked, and red when a crash folds as interrupted.agent-main-agent-verdict.test.ts,terminal-tab-activity-status.test.ts,worktree-agent-activity-summary.test.ts,worktree-card-agent-summary.test.ts,DashboardAgentRow.test.tsx,recent-workspace-tab-rows.test.ts, mobileagent-row-display.test.ts,paired-agent-row-turn-outcome-arms.test.ts): on the tab, the worktree card, the sidebar rows, the dashboard, Cmd+J and the phone, a success reads done, a failure and an interruption failed (also under live subagent work), a cancellation (recorded or an old host's flag) interrupted with "Interrupted by user" in its row, and an unconfirmed end unconfirmed. Red when any arm maps elsewhere, and when the mobile copy drifts.AgentStateDot.test.ts,StatusIndicator.test.ts, mobileagent-monitoring-indicators.test.ts): a Stop draws the muted token, never red; a failure draws red. Red when the Stop dot is drawn red.ActivityPrototypePage.thread-grouping.test.ts,activity-status-group-header.test.ts,activity-thread-grouping.status-order.test.ts): a cancellation draws Interrupted, an interruption Failed, and an unconfirmed end its glyph; a Stop and a crash land in separate groups, each headed by its own status, and a Stop or a replaced turn ranks below Working and above Done. Red when either maps back, and red when the Stop group is ranked above Working again.notifications-message-formatting.test.ts): an interruption is worded "failed".pnpm tc:node,pnpm tc:web, oxlint on changed files,check:code-quality:changed,check:react-doctor:changed,audit:anti-slopand all fiveverify:localization-*scripts pass. The relevant suites pass, as do mobile's own tests.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
unconfirmed, neverinterruption.state.unconfirmedon the Activity page), "Interrupted after {{value0}}" and "Failed after {{value0}}". All are hand-added to all six catalogs. The dashboard's "Interrupted" label reuses the existing Activity key. Other agent-row strings in these components are English literals, as their neighbours are.Author X handle: @BrennanKB5
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)