Skip to content

fix(agent-status): a turn a crash cut off reads Interrupted, an unproven end Couldn't confirm - #23467

Merged
brennanb2025 merged 162 commits into
mainfrom
brennanb2025/crash-status-reads-interrupted
Sep 30, 2026
Merged

brennanb2025 merged 162 commits into
mainfrom
brennanb2025/crash-status-reads-interrupted

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 75 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2418 $\color{#cf222e}{\Huge{\mathbf{−}}}$​202 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2216
Prod 92 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1007 $\color{#cf222e}{\Huge{\mathbf{−}}}$​317 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​690

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

  • Structured chat is the only lane that produces the two new verdicts. It is behind the experimentalStructuredNativeChat setting, which is off by default.
  • Terminal agents and the default chat view produce neither new verdict. They share the readers, so the readers now handle both.
  • A user's Stop reads Interrupted for terminal and chat agents alike. Terminal Claude Code and Codex already record a user's interrupt keystroke as a cancellation, so a terminal Stop now draws the muted Interrupted mark on the tab, sidebar, worktree card, Cmd+J and phone, instead of the red one. A turn cut short by anything else reads red "Failed".

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

  • Each turn's record already said how it ended: completed, interrupted (Orca saw the agent die), or unverifiable (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.
  • The status feed passed on only the provider's verdict. A crash has none, so every surface read "no verdict" as a green done.

After

  • The status feed now reports the provider's verdict when there is one. When there is none, it works one out from the turn's record: interrupted becomes interruption, and unverifiable becomes unconfirmed ("Couldn't confirm"). A completed turn with no verdict stays unknown, as before. This happens in one shared function, agentTurnVerdict, which the chat's folded turn header also uses.
  • One exhaustive mapping decides the mark on every surface: a failure and an interruption read 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; unconfirmed reads "Couldn't confirm"; a success reads done. The phone mirrors it, pinned by a parity test.
  • On desktop a Stop's agent row says "Interrupted by user"; the phone has no status line, so there it shows the muted mark.
  • unconfirmed gets a new unconfirmed mark, "Couldn't confirm", drawn with the existing amber dashed-ring "no recent update" glyph. There is no new colour.
  • Attention (Smart Sort, completion time, sticky rows) ranks an interruption and an unconfirmed end like a completion, since the user has not seen them. Only a turn ended on purpose, a user's Stop or a newer request's replacement, is demoted (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.
  • The legacy interrupted flag, which older readers take to mean "the user stopped it", is still set only for a user's cancellation. A crash never sets it.
  • Every reader of the verdict is now an exhaustive switch, so adding a verdict fails to compile until each reader handles it. That covers the display mark, the policy checks, the mobile copy, the activity titles and the notification wording.
  • Rollups (worktree card, terminal tab, Cmd+J) gain an unconfirmed status. They rank a pending question, then a failure, then live work, then an unconfirmed end, then a user's Stop, then done.
  • Activity page: a user's Stop sits in its own "Interrupted" group with the muted mark, and a turn cut short by anything else sits with failures in "Failed", titled "Agent failed". Every row in a group draws the group's own mark, so no group mixes the two and each header is simply its group's status. The Interrupted group ranks below live work (Working, Monitoring) and above Done, as the sidebar and Cmd+J rank a Stop; Failed and Couldn't confirm stay above live work. The earlier rule that drew the done check for a Stop (Keep interrupted agent rows anchored to done state #2569) is gone.
  • Chat transcript, desktop and mobile: the folded header reads "Failed after N" for a failure or a crash-cut turn (new key components.native-chat.status.failedAfter), "Interrupted after N" for a user's Stop (new key components.native-chat.status.interruptedAfter), and "Worked for N" otherwise. Both keys are in all six catalogs and the boot catalog. An unverifiable turn still shows no duration header, as before.
  • The turn-completion feed, which drives OS notifications, still reports only provider verdicts. So a crash found at restart does not fire a notification, as today.

Half 2: the stop path records who asked

Before

  • Each place that stopped a chat's agent (closing its tab, /clear, quitting, idle release, a worktree teardown, an orchestration stop) told the agent adapter only to "close". The adapter marked the running turn interrupted with 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".
  • Stopping a running turn inside a live chat closes nothing: Orca interrupts the turn, and the agent's own result says how it ended. Claude before 2.1.91 sends that result with no reason, and later versions may still leave it out. Orca requires no minimum Claude version for chat, so on those results Orca recorded the user's own Stop as a failure and wrote an error row.
  • Closing a chat rejects its waiting messages before it stops the agent. If saving that rejection failed while the agent was still starting, the delivery loop counted the close's stop as a failed start: it wrote an error row and rejected the waiting message as a failure.

After

  • Every stop now names its cause, and the compiler requires it: user-stop, user-close, host-stop or evict. The host passes the cause to the adapter's close (closeSession(sessionId, cause)).
  • Each adapter marks its open turn through one mapping, turnVerdictForChildEnd. A user's stop or close is interrupted plus cancellation, the user's own stop. Any other cause is interrupted with no verdict, which reads as a crash. The host's fallback marks any turn no adapter handled through the same mapping.
  • The PR adds two writes, both at the moment a turn ends. The adapter records cancellation when the user asked for the stop; cancellation is an existing value that older builds already read. And a Claude turn that a newer request replaced before its result arrived is recorded superseded (below).
  • The cause itself is only in memory. It is never journaled, persisted or sent over any wire.
  • Stop inside a live chat: when Orca sends Claude the interrupt, it records the user's stop against the turn it is stopping. An error result for that turn then reads as the user's cancellation, with no error row, whatever reason Claude gives. The stop belongs to that one turn: a result for any later turn reads as it says, and if Claude refuses the interrupt the stop is dropped. Codex needs no change, because its app server reports every stopped turn as interrupted, which already reads as a cancellation.
  • A Stop that names no turn, which is what the chat's Stop button sends, is the user's stop too: Orca records it on the Claude turn open at that moment, through the same record as a named Stop. It is dropped if Claude refuses the interrupt and never carries to the next turn, so an older Claude CLI's error result after that Stop reads "Interrupted", not "Failed".
  • A stop whose cleanup fails after the agent is proven gone is finished later, by the idle release, a quit or the next close. It keeps the cause of the stop it finishes, so a user's close that the idle release finishes still reads as their cancellation. The cause is held on the in-memory record of the cleanup still owed.
  • An exit the adapter saw before the close keeps its own meaning, so a crash just before the user closed the chat still reads Failed. A verdict the provider gave stands, and so does a turn that finished during the stop.
  • Who each stop is for:
    • A user's cancellation: Stop pressed while the agent is starting; closing the chat's tab; cancelling its launch; session.tabs.close with reason user, or with no reason (older clients send user closes without one); and /clear, which closes the old session.
    • Reads Failed: quitting the app; the idle release (which never fires while a turn runs); a worktree teardown; an orchestration stop of a worker; discarding a half-started worker; and session.tabs.close with a lifecycle reason (pty-exit or cleanup).
  • The same cause decides what happens to messages still waiting behind a child that ended, through the delivery loop's existing table of end causes, which must name every cause to compile, plus one rule for the user's close:
    • After a user's Stop, they go on to a new child.
    • After the user's close, nothing fails; the messages that were waiting before the close are closed with the chat.
    • After any other end (an exit, a failed attach, or one of the host's own stops), they fail with an error row, as before.
  • Closing a chat rejects its waiting messages as "The chat closed before this message was sent.", which blames no one, before it stops the agent. If saving that rejection fails after the close stopped the agent, each delivery step after the user's close retries it for the messages that were waiting before the close, and starts no agent for them. This is decided by comparing the message's position in the journal with the close's, within the same journal epoch, so a message sent after the close still goes on. If the retry fails too, the step reports the error and starts no agent; the next send tries again, and quitting or restarting Orca rejects the message as it does any leftover.

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 interruption or unconfirmed when the provider gave no verdict. The new writes are cancellation on a turn the user's own stop cut off, and superseded on 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 journaling interruption or unconfirmed. The new values ride the existing optional mainAgent.outcome field, which is persisted in status rows and sleep records, and superseded also rides the journal's existing optional turn outcome. That makes them permanent values of those fields, so their names were chosen once: interruption is a proven death nobody asked for, unconfirmed is an unknown end that is never success, and superseded is a turn a newer request replaced, whoever sent it.

Alternatives considered

  • Write the crash verdict into the turn record when the turn is cut off. Rejected. It stores a second copy of what the record's state already says, and the copy could disagree with it. An old client's completion feed would also announce a value it would word as "finished".
  • Map a crash onto 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.
  • Show an unproven end as Failed too. Rejected. Orca did not see the agent die (for example, the owner could not be probed after a restart), so calling it a failure would be a guess.
  • Show a user's Stop as done. Tried and rejected. A green check says the work finished, which a stopped turn did not; comparable hosts show a stop in a neutral tone instead.
  • Save the stop's cause durably before stopping. Rejected. The stop is awaited in one process under one lock, so the cause can travel with the call. The accepted gap: if Orca itself dies in the middle of a close, the turn is recovered as Failed or Couldn't confirm rather than the user's cancellation.

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.

  • Intended: the verdict is worked out, not written. When those hosts find a turn orphaned by a restart, they write a new durable record at that moment (a completed-turn event with a failed or interrupted status, or a session error). Orca works the verdict out from the turn record it already holds and writes nothing new for a crash. Why it's safe: the fact is already recorded, so a second stored copy could only disagree with it.
  • Intended: an unproven end ("Couldn't confirm"). Neither host has one for a local restart. Why: Orca never calls an end it can't prove a failure, because losing contact with a host is never proof the agent died.
  • Intended: the stop carries who asked. One comparable host marks the turn inside the adapter's stop but doesn't record who asked, so a stop and a crash read the same. Orca passes the stop's cause into the adapter so the mark can tell them apart, which is what makes "Interrupted" and "Failed" differ.
  • Intended: the cause travels with the call. Another comparable host records a durable stop request first and derives "stopped by the user" from the order of events, because its stop is an asynchronous command to another process. Why it's safe here: Orca's stop is awaited in-process, so the cause arrives with the call and nothing extra needs storing.
  • Temporary: every remote close counts as the user's close. agentSession.close carries 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.
  • Temporary: a replaced turn can't say who replaced it. A Claude turn replaced by a newer message reads muted "Interrupted" whether the new message came from the user or an orchestration dispatch, because the send carries no sender. Follow-up: record the sender identity on each message (message provenance), already on the follow-up list.

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 Codex turn/completed with a status Orca does not recognise reads Failed; the translator records it as interrupted with no verdict.

Together with #23456

  • fix(native-chat): a turn a proven crash cut short reads interrupted #23456 (merged) makes the relaunch mark a turn interrupted when 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".
  • Only a turn whose end Orca cannot prove, for example because its agent ran on a host this one cannot probe, stays unverifiable and reads "Couldn't confirm".
  • A chat on screen at relaunch can be read before the proof is written. It first reads "Couldn't confirm", then fix(native-chat): a turn a proven crash cut short reads interrupted #23456 revises the turn to interrupted once the proof lands, and the status and the folded header move to "Failed after N" with it.

Remote and mixed versions

  • No new field and no capability. The two values ride existing optional fields: the status summary's turnOutcome, and mainAgent.outcome on status rows, worktree ps rows, history entries and sleep records. Status summaries and mirrored rows have no whole-frame validator, so nothing rejects a frame.
  • Old desktop on a new host. It builds the chat's status row from turnOutcome without checking the value, and its only reader of that value checks for cancellation, 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.
  • Old phone on a new host. It reads the worktree ps row by the interrupted flag, which is false for both values, so it reads done (pinned against v1.4.212).
  • New client on an old host. An old host never sends the new values. It does mark a user's close as interrupted with no verdict, so a new client folds that turn as "Failed after N". That is a known display difference.
  • A user's Stop on an old client. The mark is chosen on the client, so an old desktop or phone still draws a Stop with the red Interrupted dot, as today, while a new phone draws the muted Interrupted mark (both pinned against v1.4.212 in the cross-version suite). Nothing on the wire changes.
  • Upgrading on the same host. A chat closed mid-turn before the upgrade was saved as interrupted with no verdict. After the upgrade that turn works out as interruption, 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 as interrupted with 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.
  • superseded on 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 keeps outcome an 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.
  • Plugins. The agent.status.changed event passes mainAgent.outcome through as a string, so plugins now also see interruption, unconfirmed and superseded.

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 on sleep 120 and acted 25-28 s in.

Before (main) After (this PR)
Orca crashes mid-turn, chat on screen at relaunch Main: crash reads done
Green done, "Worked for 19s"
PR: crash reads Failed
Red "Failed after 18s" on the tab and sidebar
Same, Activity page Main: Activity Done
Listed under Done
PR: Activity Failed
Listed under Failed, red
The agent is killed while Orca runs Main: kill reads done
Green done, "Worked for 27s"
PR: kill reads Failed
Red "Failed after 25s"
The user quits Orca mid-turn, then relaunches Main: quit reads done
Green done, "Worked for 27s"
PR: quit reads Failed
Red "Failed after 25s"
The user presses Stop mid-turn Main: Stop reads red
Red "Interrupted" dot on the tab and sidebar
PR: Stop reads muted Interrupted
Muted "Interrupted after 26s"; the sidebar says "Interrupted by user"
Same, Activity page Main: Activity Stop
An "Interrupted" group with a green done check
PR: Activity Interrupted
"Interrupted", muted
A stopped chat beside a chat whose agent was killed Main: side by side
The Stop reads red and the killed chat green
PR: side by side
The Stop's tab is grey, the killed chat's tab red, "Failed after 23s"
Same, Activity page PR: Activity separate groups
Separate "Failed" and "Interrupted" groups
  • A normal reply, a clean restart and another reply show no status mark and no errors.
  • Not produced live: a turn replaced by a newer message (it should read muted "Interrupted"). The installed Claude CLI (2.1.285) folds a message sent mid-turn into the running turn, so no turn is ever replaced; main behaves the same. The unit and cross-version tests cover this path.
  • Worth knowing, not from this PR: after quitting, the relaunch prompt still asks "Resume interrupted chats?" while the turn reads "Failed". And at the default sidebar width, the sidebar row cuts off "Interrupted by user" and "Failed"; they show in full on the Activity page.
  • The phone app wasn't exercised live (the Android emulator couldn't enter the pairing link); the phone's display is covered by its unit and cross-version tests.
  • The real ~/.claude config 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

  • I manually tested these changes locally
  • Automated tests added/updated

Each new test was run red with its core change removed, then green with it restored:

  • Status feed: an interrupted or unverifiable turn with no provider verdict reports interruption or unconfirmed, and a provider verdict still wins. Red when the feed reads only the provider's verdict.
  • Relaunch after a crash (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 (failed or unconfirmed), with the legacy flag off and no completion event. Red under the same removal.
  • Old clients: the paired-web copy (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).
  • A turn cut off by a restart or by quitting Orca (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 in structured-agent-session-close-verdict.test.ts): the chat's turn bar reads "Failed after 1s" and the tab and sidebar mark reads failed. On main the same restart-cut turn shows the green done tick. Red when the host stops deriving interruption from an interrupted turn (the mark reads done), red when the mark maps interruption to done, and red when the turn bar maps interruption back to "Worked for".
  • Legacy flag: off for both values. Red when interruption is mapped into it.
  • Attention: both values rank with completions at their end time, and a cancellation is demoted. Red when interruption goes through the stop branch.
  • Who asked for the stop, at host level (structured-agent-session-close-verdict.test.ts): a user's close marks interrupted plus cancellation, publishes turnOutcome: '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.
  • Adapters (codex-close-stop-cause.test.ts, claude-close-stop-cause.test.ts): a user close marks the running turn as a cancellation. evict, host-stop or no cause leave it a crash, and so does an exit the adapter saw first. Red when either adapter drops the cause.
  • Every stop names its cause: a type-level check fails to compile when the cause is omitted.
  • session.tabs.close (structured-agent-session-tab-close-cause.test.ts): no reason and user are the user's close; cleanup and pty-exit are not. Red when the no-reason case maps to eviction.
  • A user's close of a chat whose agent is still starting, where the close's own rejection of the waiting message did not save (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.
  • Stop inside a live Claude chat (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.
  • Superseded Claude turn (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 recorded superseded, 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.
  • Folded header (NativeChatMessageList.interrupted-turn-fold.test.tsx, plus the shared status and mobile MobileNativeChatTurnStatus tests): 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.
  • Marks (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, mobile agent-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.
  • Colour (AgentStateDot.test.ts, StatusIndicator.test.ts, mobile agent-monitoring-indicators.test.ts): a Stop draws the muted token, never red; a failure draws red. Red when the Stop dot is drawn red.
  • Activity page (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 (notifications-message-formatting.test.ts): an interruption is worded "failed".
  • Local: pnpm tc:node, pnpm tc:web, oxlint on changed files, check:code-quality:changed, check:react-doctor:changed, audit:anti-slop and all five verify:localization-* scripts pass. The relevant suites pass, as do mobile's own tests.

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • SSH/remote. An owner on another host, or one Orca cannot reach, is never probed as dead, so it produces unconfirmed, never interruption.
  • Folder workspaces. Nothing here assumes a git worktree.
  • Copy. The new strings are "Couldn't confirm" (state.unconfirmed on 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.
  • Counts. The worktree card's summary count says "1 unconfirmed" rather than "1 couldn't confirm", which does not read as a count.

Author X handle: @BrennanKB5

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

brennanb2025 and others added 30 commits September 24, 2026 23:36
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.
…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.
brennanb2025 added a commit that referenced this pull request Sep 29, 2026
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.
@brennanb2025

brennanb2025 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Status: ready for review. Head 8e50d95b8b7, based on main (which includes #23456), CI green (all 5 test shards, typecheck, static analysis, cross-version), mergeable.

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 72bbbf53813, before the final display change and the recorded superseded outcome; those later changes were covered by review loop 10 and the live QA above, not by a second readiness run. It also didn't run at the start of the review; both are disclosed here.

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):

  • After quitting Orca mid-turn, the relaunch prompt still asks "Resume interrupted chats?" while the cut-off turn now reads "Failed"; the prompt's wording should match.
  • At the default sidebar width, the sidebar row cuts off the status words ("Interrupted by user", "Failed"); they only show in full on the Activity page.

Final readiness check (on 8e50d95b8b7, the final display change and superseded): passed, with no release-blocking findings. Minor findings:

  • Some stop-cause helpers still live in the crash-verdict file beside the new stop-cause file (a tidy-up for later).
  • A few comments and reference-doc lines described the earlier mapping; they're being corrected in a final pass with a main merge.
  • Claude turns replaced by a newer message before this PR were saved without a verdict, so after upgrading they read "Failed after N". That's display only, affects history only, and is rare, since current Claude CLIs fold a mid-turn message into the running turn.
  • Unproven residual: a Stop pressed during /compact on a CLI that returns an error suppresses the error row, but the command still records a failure.

@brennanb2025
brennanb2025 marked this pull request as ready for review September 30, 2026 10:28
@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 2/5

[Medium risk] Refactors agent turn outcome handling and status display.

The PR is not safe to merge while a caller can settle another worker’s Task and a failed rejection can still deliver a message from a closed chat.

Findings

  1. P1 Closed message can be sent ▶

Summary

The PR distinguishes crash-cut, unconfirmed, stopped, and superseded agent turns across status surfaces and records stop causes. Changes since the previous review also revise relay placement accounting, orchestration lifecycle authority, chat restoration, Codex subagent translation, and Android release publishing.

  • The new orchestration caller fence does not replace the removed Dispatch capability check for requests without caller evidence.
  • The previous queued-message finding remains outstanding.

Reviews (3) · Last reviewed commit: "test(native-chat): the idle sweep's held..."

Comment on lines +263 to +265
const ended = session.lastEndedChild
if (session.child || ended?.cause !== 'user-close') {
return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Closed message can be sent

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.

@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Repository guideline files applied to this review (1)
AGENTS.md — auto-discovered

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: e7b1c6d2-b5cb-4289-8fde-1f7a38c19717

📥 Commits

Reviewing files that changed from the base of the PR and between 8bb5d60 and c64f3aa.

📒 Files selected for processing (18)
  • src/main/claude/claude-structured-session-adapter.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts
  • src/main/runtime/rpc/methods/structured-agent-session.ts
  • src/main/runtime/structured-chat-coordinator-mail.test.ts
  • src/renderer/src/i18n/en-runtime-required.json
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/i18n/locales/es.json
  • src/renderer/src/i18n/locales/fr.json
  • src/renderer/src/i18n/locales/ja.json
  • src/renderer/src/i18n/locales/ko.json
  • src/renderer/src/i18n/locales/zh.json
  • src/shared/structured-agent-session-projection.test.ts
  • src/shared/structured-agent-session-projection.ts
  • src/shared/workspace-session-schema.sleeping-agent.test.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/renderer/src/i18n/locales/ja.json
  • src/renderer/src/i18n/locales/ko.json
  • src/renderer/src/i18n/locales/es.json
  • src/renderer/src/i18n/locales/fr.json
  • src/renderer/src/i18n/en-runtime-required.json
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/i18n/locales/zh.json

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.


📝 Walkthrough

Walkthrough

The change adds host-observed interruption and unconfirmed outcomes and records superseded outcomes for replaced Claude turns. Session stop causes now flow through Claude, Codex, and host lifecycle paths to turn settlement. Renderer, mobile, and notification status displays distinguish failures, interruptions, cancellations, superseded turns, and unconfirmed endings. Tests cover settlement, queued-message handling, display behavior, and compatibility with older readers.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to c64f3

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 Review

Security architecture risk: 🔵 Low · up to c64f3

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
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The directly reviewed mutation effects are session-scoped: stopping provider execution, abandoning queued work, settling journal state and changing status visibility. Generation and fence checks constrain stale lifecycle actions. Maximum production exposure across clients or environments remains unverified because transport authorization and deployment topology were not established.

Trust Boundaries and Controls

  • observed — The changed RPC handlers assign the literal user-close cause rather than accepting it from request data. Conversation commands continue to carry context-derived caller identity, and close remains behind the existing cleanup capability gate. The scoped base/head comparison found no gate removal or new RPC entrypoint in these handlers.
  • observed — The close handler accepts a session ID without forwarding caller identity to host.close; this behavior predates the PR. The capability gate does not itself establish ownership of that session. Whether an outer authorization boundary permits or prevents cross-client closure remains unresolved, not a verified vulnerability introduced by this change.

Resilience and Maintainability Implications

  • observed — Loss of an adapter route is not treated as proof of shutdown. Codex retains its indexed session when exit or terminal settlement is unproven; Claude retains retry state for unknown exit or failed cursor persistence. These controls prevent a close request alone from becoming evidence that execution resources were safely released.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning 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:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is clearly related to the agent-status change and identifies crash-cut and unconfirmed outcomes, but it is awkward and does not state the resulting Failed status directly.
Description check ✅ Passed The description is comprehensive and covers the required user impact, implementation, rationale, visual proof, testing, compatibility, and checklist areas. It does not provide an actual linked issue a…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

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.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 — agentTurnVerdict works out interruption/unconfirmed from 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 carries user-stop | user-close | host-stop | evict; turnVerdictForChildEnd maps a cause to the recorded verdict, only a user's stop/close being cancellation. 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_reason reads 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 interrupted flag is still only a user's cancellation, and the cross-version suite pins old clients dropping the new arms to "done".

ℹ️ Nitpicks

  • agentTitle returns the literal Couldn't confirm agent finished for an unconfirmed end (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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

this.publish(this.current, {
state: 'interrupted',
completedAt: observedAt,
outcome: 'superseded'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

…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.
@brennanb2025
brennanb2025 merged commit 24edf0f into main Sep 30, 2026
41 checks passed
brennanb2025 added a commit that referenced this pull request Sep 30, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant