Skip to content

fix(native-chat): a reply Orca cut off reads Interrupted; an agent's own crash still reads Failed - #28676

Draft
brennanb2025 wants to merge 4 commits into
mainfrom
brennanb2025/cut-turn-reads-interrupted
Draft

brennanb2025 wants to merge 4 commits into
mainfrom
brennanb2025/cut-turn-reads-interrupted

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 23 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​111 $\color{#cf222e}{\Huge{\mathbf{−}}}$​208 $\color{#cf222e}{\Huge{\mathbf{−}}}$​97
Prod 13 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​111 $\color{#cf222e}{\Huge{\mathbf{−}}}$​47 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​64

ELI5

When Orca quits, crashes, restarts, or puts an idle chat to sleep in the middle of a native chat reply, the reply gets cut off. Nothing went wrong with the agent: Orca went away. Today every status surface still shows that reply as a red Failed. This PR makes it read Interrupted, the same muted mark a Stop uses. When the agent's own process really does crash while Orca is running, it still reads Failed, and now that failure is recorded as a fact instead of being guessed later.

What Changed

The problem. The worktree card, the agent row, the tab bar, the Activity page, the phone's agent list and the OS notification all turn a reply that Orca cut off into red "Failed". That happens because the shared mapping (agentVerdictDisplayMark in src/shared/agent-main-agent-verdict.ts) treats the host's interruption verdict (a turn cut short that nobody asked to stop) as a fault, exactly like failure. The mapping did this on purpose, because the same interruption verdict was the only thing recorded when the agent's own process crashed mid-reply. So making interruption read neutral on its own would have quietly turned real agent crashes grey too.

Before, as a user:

  • Quit or restart Orca mid-reply, reopen the chat: red "Failed" on the card, tab, agent row, Activity and phone; the chat's turn bar reads "Worked for 27s" as if the reply had finished.
  • The agent's process dies on its own mid-reply: the same red "Failed".

After, as a user:

  • Orca quit, crashed, restarted or put the chat to sleep mid-reply: muted Interrupted on the card, tab, agent row, Activity and phone (no "by user", since nobody pressed Stop). The turn bar reads "Interrupted after 27s". The OS notification says "stopped" instead of "failed". Nothing extra pops up.
  • The agent's process dies on its own while Orca is running: red Failed, turn bar "Failed after N", and the unread dot and notification fire the same way they do when the agent reports a failure itself.
  • Pressing Stop and then having the process exit still reads as your Stop ("Interrupted by user").

The mechanism.

  1. Record the crash where it is observed. When a provider process exits on its own (structured-agent-session-child-exit.ts: not a close Orca asked for, and the exit's failure fact is providerExited rather than hostFault) and a reply was still running, the dead-process settlement (structured-agent-session-dead-generation-settlement.ts) already writes, in one transaction, the "stopped while this response was in progress" row and the turn's interrupted end. It now also writes the existing failure outcome on the root turn that row reports on (providerExitFailureRevision in structured-agent-session-stale-turn-verdict.ts). The lifecycle stays interrupted, since that is what the host observed. A turn that already carries a verdict keeps it, and so does a turn a person's Stop decides. Stop is checked explicitly here, because the shared Stop rule (turnEndAfterStop) leaves alone any turn that is written with a verdict already; this is the same check the host's own Stop path already makes before it writes.
  2. Only that live path records a failure. When Orca itself force-closes a healthy agent (for example after its own journal write fails), the exit carries hostFault, so the turn reads Interrupted, not a provider failure. A reopen that finds the old process gone (settleStaleStructuredAgentSessionState) still writes no outcome. A process found missing later can't be told apart from Orca going away, so it is never called an agent crash. A reopen that can't prove the process is gone still reads "Couldn't confirm".
  3. Display. interruption now maps to the muted 'interrupted' mark in agentVerdictDisplayMark and in the phone's copy of it (agentRowVerdictMark). It maps to interruptedAfter in the turn bar (settledTurnStatusKey), to "interrupted" in the Activity titles (so it is grouped with Stops, not failures), and to "stopped" in the OS notification wording. The outcome contract comment in src/shared/agent-turn-outcome.ts now says a failure can also be a provider exit the host watched.

Completion feed (unread dot + notification): announced on purpose. The feed (structured-agent-session-turn-completion-feed.ts) announces a turn that settles with a recorded outcome, and stays silent on the first look at a reopened session. On a normal crash the agent's adapter publishes its own "interrupted" end first, with no outcome, and the host's settlement adds failure to that same turn a moment later. The feed used to treat the turn as already settled and ignore the later revision, so the failure never fired. It now remembers whether the settled turn already had a verdict: a verdict that arrives later on the same turn fires once, and nothing fires twice. Settlement on reopen records no outcome, and reopened history is already silent, so a reopen stays quiet.

Why

  • Classify at the source rather than re-derive on read. The exit handler is the one place that knows first-hand whether Orca asked the process to stop. Storing the verdict there means every reader (desktop, phone, sidebar, notifications) gets the right answer from the same field without new logic, and the transcript's explanation row stays an explanation, not something that decides the result. The earlier attempt (fix(native-chat): a chat cut off by a crash, quit or restart reads Interrupted until its state changes #25670) inferred the failure later by scanning nearby notice rows on every read. That coupled the status to how the transcript happened to look, and two architecture reviews recommended moving it to the producer. Recording the failure where the exit is observed matches the common pattern.
  • Keep the change in one PR with the display mapping. Muting interruption without recording real crashes would hide genuine agent failures, so the two must land together.
  • Reuse what exists. No new outcome value, no new stored flag, no new notice. The existing failure outcome, the existing one-transaction settlement and the existing Stop rule carry it.

Deviations from the common pattern:

  • intended: an Orca-side cut shows a muted, persistent "Interrupted" mark rather than going back to a plain resting state. That keeps Orca's own status surfaces consistent with the Stop mark they already show, and it tells the user the reply did not finish. It adds no stored state; the mark comes from the turn's lifecycle.
  • intended: a failure is recorded only from an exit Orca watched while it was running. A process found missing at reopen is never called a crash, because Orca can't tell the two apart after the fact. This can under-report a crash that happened while Orca was down. It never over-reports one.

Mixed versions. No wire shape changes: failure and interruption are existing outcome values. An older client reading a newer host sees a watched crash as Failed, as it does today. A newer client reading an older host shows a crash that older host recorded as Interrupted, because older hosts never stored the failure. The status is muted rather than wrong, and the transcript row still says the agent stopped.

Out of scope (separate PRs): the wording and tone of the "stopped while this response was in progress" row, and the desktop card and tab marks that disappear 30 minutes after the chat's last activity.

Linked Issue

Replaces part of #25670

Fixes #

Visual Proof

Pending: QA to follow

Testing

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

  • New exit-flow test in the existing recovered-turn harness (structured-agent-session-recovered-turn-clock.test.ts), running the real child-exit handler, journal, status feed, host status row and completion feed, with the adapter's own outcome-less end published before the host settles (the real order): a provider exit while running reads Failed and fires exactly one failure completion. A person's Stop before the exit stays their cancellation (muted Interrupted, announced once as a cancellation). An Orca-caused cut (hostFault) reads Interrupted and fires nothing. In the same harness, the existing Orca-side recovery cases now assert muted Interrupted, "Interrupted after 1s", and no completion event.

  • I checked that the new test fails without the completion-feed change (no failure announced) and without the hostFault check (an Orca cut reads Failed).

  • Updated existing tests that asserted the old mapping (shared verdict, turn bar, Activity, tab bar, worktree card, phone agent row, notification wording, restart and close flows). Removed three tests that only pinned "crash-cut reads failed/worked" and are now covered by the mapping tables.

  • pnpm test (Bun 1.4.3, as pinned) on every touched file, the completion-feed test and the paired-client mapping test: 19 files, 309 tests pass. All of src/main/native-chat plus src/renderer/src/runtime: 646 files, 5672 tests pass. Mobile vitest on the touched phone files: pass (unchanged since the first round).

  • orca-ci-checks: 21/22 pass, including both typechecks. The one failure, audit:code-quality:native, is six import-cycle warnings in mobile/src/transport/* and mobile/src/terminal/document/*, files this PR doesn't touch.

AI Disclosure

Review

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Security: no change. Cross-platform: no platform-specific code. SSH/remote: the verdict is recorded on the execution host that watched the exit, and readers on any client read the stored outcome. Mobile: the phone's mapping copy is updated the same way. Backwards compatibility: see "Mixed versions" above. Performance: one extra lookup over the journal items already in memory during exit settlement.

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)

…own crash still reads Failed

Every status surface showed red Failed for a turn Orca quit, crashed,
restarted or evicted, because the host's `interruption` verdict mapped to
the failed mark. That mapping stood in for real agent crashes too, which
were recorded only as `interruption`.

The dead-generation settlement now records the existing `failure` outcome
on the root turn a provider exit of its own cut, in the same transaction as
its exit row, unless the turn already has a verdict or a person's Stop
decides its end. Reopen settlement records no outcome. With crashes
recorded at the source, `interruption` now displays as the muted
Interrupted mark (desktop, phone, turn bar, Activity, notification).
… caused stays Interrupted

The adapter publishes its outcome-less end before the host's exit settlement adds `failure` to the
same turn, so the completion feed saw the turn as already settled and never announced the failure.
The feed now remembers whether a settled request had a verdict: a verdict that lands later fires
once, and nothing fires twice.

An exit whose failure fact is `hostFault` (Orca force-closed a healthy agent) no longer records a
provider failure; only a `providerExited` exit, or one with no fact, does.

The exit-flow test now drives the real child-exit handler with the adapter's end published first,
and the paired-client mapping test expects the new Interrupted mark.
… silent

The completion feed announced a verdict recorded on an already-settled turn
whenever that turn had settled without one, including a turn that was already
in the conversation when the feed baselined it (a reopen, or a stale turn the
next provider child settles) and whose verdict a thread resume later restores.
Only a request that ran after the baseline can now await its verdict, so the
provider's own crash still announces its failure once.

Also moves the tests still asserting the old crash-cut mapping to Interrupted,
and folds duplicate cases.

This branch has not been deployed

No deployments
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