Skip to content

Show a tool call you stopped as interrupted, not failed - #25181

Merged
brennanb2025 merged 67 commits into
mainfrom
brennanb2025/acp-c5-tool-interrupted
Oct 6, 2026
Merged

brennanb2025 merged 67 commits into
mainfrom
brennanb2025/acp-c5-tool-interrupted

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 12 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​801 $\color{#cf222e}{\Huge{\mathbf{−}}}$​4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​797
Prod 24 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​316 $\color{#cf222e}{\Huge{\mathbf{−}}}$​91 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​225

Dependencies #25141 and #25064 have landed on main. This diff contains only the interrupted-call change; their transition and shared assembler code come from main.

ELI5

When you press Stop while the agent is running a command, that command never finishes. Today the chat tells you it failed, as if the command itself errored. It didn't fail; you stopped it. After this change the chat says it was interrupted.

What Changed

The problem. A structured chat's tool call has three states: running, completed, failed. When a turn or session ends while a call is still running, whoever settles it (the shared timeline assembler from #25064, the settlement that runs after a restart, the Codex lane) can only pick failed. So every call cut short by Stop, by the agent cancelling it, or by the agent process exiting shows "1 failed" in the run header and its partial output in red. Grok over ACP (the Agent Client Protocol) makes this constant: on a cancel it leaves the running call in_progress forever, so Orca has to close it itself, and today that always reads as a failure.

A second case, found in review: when Orca restarts and cannot yet prove the old agent process died, it closes the turn as "unverifiable" and the running call as plain failed. If proof of the death arrives later (the next time the chat is opened or a new agent process is started), the turn was already corrected to interrupted, but the call could not be: nothing on it said it had been closed for lack of proof, so it read failed forever.

What you see now.

  • A collapsed run with a call you stopped says "1 interrupted" in the same quiet grey text "1 failed" uses, and gets no completion check mark (it didn't succeed either).
  • A run with both says "1 failed, 1 interrupted".
  • The partial output a stopped call left is no longer tinted as an error.
  • After a restart, a call that read failed because nothing had proved the agent died now changes to interrupted once that proof arrives, together with its turn.
  • Nothing else changes: no new notice, banner or row. Mobile shows the partial output untinted too (it only ever showed the output tint, never a failed label).

Mechanism.

  • A journal tool-call item (and the chat block projected from it) gains one optional field, endedAs, written only beside state: 'failed'. It has two values: interrupted (a proven interruption) and unverifiable (closed when its session ended and nothing proved why). One shared reader, agentJournalToolCallLifecycle, turns the pair into running | completed | failed | interrupted; unverifiable reads as failed, exactly as today. The run header, the output tint, and the projection read through it.
  • One shared rule, endedRunningAgentJournalToolCall, decides what a call still running at an end becomes. Only an interruption Orca knows happened cuts it short: the call's turn ended interrupted (a person's Stop, a cancel, or a newer turn superseding it), a session that ended on an observed child exit, or the agent process found dead after a restart. A turn the provider completed around a call it never closed stays plain failed. An end the host could not verify (lost contact, a restart with no proof yet) also reads failed, but the call keeps endedAs: 'unverifiable' so a later proof can find it. That last case applies only to a call whose turn was still running when contact was lost: a call in a turn a person had already stopped takes the Stop's interrupted (next bullets).
  • Late proof. The restart settlement already re-runs on every chat open and agent start, and already revised an unverifiable turn to interrupted once death evidence named the agent process that wrote it (by its owner "fence", the number each agent process generation writes under). It now does the same for calls: each call with endedAs: 'unverifiable' whose creating fence is the one the evidence names becomes interrupted. That is derived from the journal row itself on each settle, so there is no separate list of owed corrections. A call that failed on its own (no endedAs), a completed call, a call another agent process wrote, and evidence from an older build that names no owner are all left alone; once corrected, a call no longer matches, so re-running writes nothing.
  • One place applies it to journal rows: terminalAgentJournalBody(body, end) (in journal-terminal-settlement.ts), the "row that settles an item no one will finish" function that Add a shared timeline assembler for structured agent chats (not wired yet) #25064 made shared. Both the shared assembler's turn and session settlement and the restart (dead-generation) settlement every lane shares call it, so the ending is a parameter of that one function, not a second copy of the rule. The Codex lane's turn and session settlement uses the same rule on its in-memory open items (it already had a helper named interruptedBody that wrote failed).
  • Which end a call takes: a call still running when its turn ends ends as that turn's journal row ends. Usually that row is the one the same settlement writes: the provider's turn end, a newer turn superseding it, or the session's end. But another writer can settle the turn row first. A person's Stop writes it interrupted (outcome "cancellation") straight to the journal, and since Add a shared timeline assembler for structured agent chats (not wired yet) #25064's latest round a Stop only stops the turn's text and cancels its pending prompts; its running calls stay the agent's to finish, so a call the agent completes after the Stop lands completed. When the agent then ends that turn (or a newer turn or the session's end does), the calls still running take the Stop row's state, read when the settlement is written, not whatever the provider's later end reports. So a stopped turn's unfinished calls read interrupted even if the provider answers the cancel with completed, and whichever settlement closes them: the agent's turn end, a newer turn, the session's end (including one the host could not verify), or the restart settlement with no proof the agent died. A turn row another writer left unverifiable leaves them unverified, open to a later proof; work outside any turn (background tasks, subagents) takes the settlement's own end. The rule lives in one place, runningCallEnd in journal-terminal-settlement.ts beside terminalAgentJournalBody, and every settlement that ends running calls through that function asks it: the assembler's turn and session settlement, the restart settlement, and the live settlement when an agent process exits unexpectedly. Only a call whose turn row is still running when the settlement runs takes that settlement's own end; after a restart that end comes from the death evidence (proof: interrupted; none: failed with endedAs: 'unverifiable'). A settlement with no ending of its own (the Stop's) ends no calls at all.
  • A provider that reports a cancellation itself (ACP tool_call_update with status: cancelled, for the Grok adapter later in this stack) closes the item with interruptedAgentJournalToolCall(body); the assembler keeps it, and a later turn end does not restate it.
  • The Claude lane is unchanged: it never marks a running call settled at a turn end (Claude reports an interrupted call as an error result), so nothing new reaches it except through the shared restart settlement.
  • Small supporting refactor: the journal schema's turn item and legacy status.turnLifecycle repeated the same eight fields; they now share one definition. That freed the line the new field needed under the file's 300-line limit.

Why

Why a field beside failed and not a new interrupted state value. Persisted journal rows outlive the build that wrote them: after a downgrade an older Orca folds the same rows, and a phone or desktop client on an older version reads this host's items. Every build so far reads tool state with === 'failed' / === 'running' checks, so an unknown value counts as neither: the older run header would mark a stopped call done ✓, which is worse than today's "failed". Its rewind normalizer would also turn the row into a raw-text fallback. A capability negotiated at the RPC boundary can protect clients, but not persisted rows read by an older host. The additive field keeps every older reader on the existing failed fallback with no negotiation, and the shared reader keeps the concept in one place. The cross-version test proves this against a real older release for both endings, including that the bare new state value reads as a success there.

Why record unverifiable on the call instead of re-deriving it later. The alternatives were (a) to correct every failed call inside a turn once the turn is proven interrupted, which would wrongly relabel a call that genuinely failed before the stop, or (b) to work backwards from which settlement batch last wrote each row, which the journal does not expose per item and would tie correctness to settlement ids. Recording the unverified end on the row is the same model the turn already uses (turn.state: 'unverifiable'), keeps the fact with the thing it describes, and lets the existing re-run settlement find exactly the calls it closed.

Why interrupted (and the quiet header count). The common pattern models a call cut short as its own terminal state, neither error nor success, shown in muted text and counted apart from errors in a group summary, with no extra notice. "Interrupted" is also the word Orca already uses for the turn itself (turn.state: 'interrupted'), so the call and its turn agree.

Why an unverified end still reads failed. Orca's execution rule (docs/reference/ssh-execution-boundary.md) is that loss of contact is never evidence a process died. Marking work the host lost track of as "interrupted" would claim a stop nobody proved, so a call in a turn that was still running when contact was lost keeps today's reading until proof arrives. A turn a person already stopped is different: the Stop is the interruption, recorded on the turn before contact was lost, so its calls read interrupted whatever later happens to the process, just as the turn does.

Differences from the common pattern

  • intended: The fact is stored as state: 'failed' + endedAs rather than a fourth state value. Safe because every reader in this build goes through one reader, and older builds read the existing failed fallback (proven by the cross-version test).
  • intended: An end the host could not verify (lost contact, restart without proof) leaves a running call in a still-running turn reading failed, where the common pattern marks every call open at a session's end interrupted. Orca distinguishes unverifiable from proven ends everywhere else; claiming an interruption there would contradict that. The call carries endedAs: 'unverifiable' so that, unlike a permanent failure, a later proof corrects it. The common pattern has no unverified end, so it has nothing to correct later.
  • intended: A call the provider leaves open across a turn it reports completed reads failed; the common pattern cancels every call still open at any turn end, including a completed one. Orca only claims an interruption it can prove, and on a completed turn nobody stopped the call. Kept deliberately; worth revisiting with the Grok adapter PR in this stack if real traffic shows such calls.
  • temporary: A Claude call interrupted by the user still reads failed: Claude reports it as an error result, and its result frame's own interrupted flag is not read yet. Follow-up: read that flag in the Claude lane and write endedAs from it.
  • intended: A call cut short by a death nobody asked for (a crash, an unexpected exit, or a death proven after a restart) reads "1 interrupted", while Orca's own verdict for that same turn reads failed (the agent-status mark in the sidebar), and the turn bar reads "worked for", not "interrupted after". The common pattern marks every item still open at such an end interrupted, so the call follows it. Orca's turn verdict keeps its own split between "stopped on purpose" and "died unasked", and the stored data keeps that split on the turn row (its outcome), so nothing is lost and reading these calls differently later would change only the reader. Unchanged by this PR, for contrast: a call whose end the host could not verify (lost contact) still reads "1 failed" with its output in red, exactly as today, while that turn reads unconfirmed. The call now also stores endedAs: 'unverifiable', so a neutral reading there would likewise be a reader-only change; that is left as an open question for review.
  • intended: The ending is written onto the call when it is settled, rather than derived at read time from how its turn ended, which is how the common pattern does it. Calls outside any turn (background work) have no turn to derive from, and each chat item is projected and cached on its own, so deriving would thread the turn row into every reader (desktop, mobile, worker transcript). The written value only copies the turn's own vocabulary (interrupted / unverifiable), and the finer reason (Stop, newer turn, death) stays on the turn row.
  • intended: No per-line "(interrupted)" label on the expanded call. Orca shows no per-line "failed" label either; the header count is the existing surface and the line drops the error tint.

Linked Issue

Part of the ACP (Agent Client Protocol) native-chat stack. No separate issue.

Visual Proof

Desktop QA done on a separate Mac (the M4Air), 6 of 6 captures pass. The before/after screenshots are in the review comment on this PR. How it was run, plainly:

  • The after build was this branch at 70fd4bac5bd; the before build was main at 40f9e7a3fcd. Both ran as isolated dev builds with their own profiles.
  • No agent was run. The same tool-call rows were written straight into each build's chat journal (one turn with one stopped call; one turn with one stopped call and one call that failed on its own), then the restored chat was opened.
  • After: the collapsed run reads 1 interrupted (screen reader "Interrupted tool calls: 1", no check mark), the mixed run reads 1 failed, 1 interrupted, and the stopped call's partial output is in the normal muted text. Before: 1 failed / 2 failed, and the partial output is red.
  • Not verified: pressing Stop on a live agent (the rows were seeded, nobody pressed Stop), the mobile app, and Windows or Linux.

Exactly what changes on screen:

Before After
Desktop, collapsed tool run with one call you stopped header ends in small grey monospace text 1 failed (screen reader: "Failed tool calls: 1"); no check mark same spot, same style: 1 interrupted (screen reader: "Interrupted tool calls: 1"); still no check mark
Desktop, run with one failed and one stopped call 2 failed 1 failed, 1 interrupted
Desktop, expanded stopped call with partial output output text in the destructive red (text-destructive) output in the normal muted foreground (text-foreground/80)
Mobile, expanded stopped call with partial output output box on the red-tinted error background output box on the normal panel background
A call closed during a restart with no proof yet, in a turn still running 1 failed, red output unchanged (1 failed, red output) until proof arrives, then as the first row
A call in a turn you stopped, closed during a restart with no proof the agent died 1 failed, red output 1 interrupted, as the first row
A call that failed on its own, or completed unchanged unchanged

The renderer test (NativeChatToolRunCallCounts.test.tsx) renders the real desktop tool run from a projected journal row and asserts the header text, accessible name, no check mark, and no error tint. The mobile test renders the real mobile message from a projected row and asserts the error background is absent for an interrupted call and present for an unverified one.

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

New tests:

  • provider-timeline-assembler-tool-ends.test.ts: turn interrupted, turn superseded, session ended on an observed exit, and provider cancel all read interrupted; a person's Stop written to the journal leaves the call running, and the agent's later turn end (reporting interrupted, or completed) makes it read interrupted, while a call the agent completed after the Stop stays completed; a completed turn and a lost child still read failed; a restart with no proof leaves the call failed with endedAs: 'unverifiable'; after a person's Stop, a session end the host could not verify and a restart with no proof both make the call read interrupted, like its turn, while a call that failed before the Stop stays failed; a call that finished before the stop stays completed.
  • structured-agent-session-late-proof-tool-ends.test.ts (real journal and database): a restart settles with no proof, then a proof naming the owner arrives: the turn and its cut-short call both read interrupted, a call that failed on its own stays failed, a completed call stays completed, and a second settle writes nothing. A proof naming another agent process, or an older build's proof naming no owner, changes nothing; a call another owner wrote stays as it was. Also covers a call the restart-eviction settlement closed.
  • Dead-generation settlement: a proven death cuts the running call short; the unverifiable case reads failed and keeps endedAs: 'unverifiable'.
  • Codex: a turn Codex reports interrupted cuts its active tool short; a session end does too; a completed turn still fails it.
  • Projection: the field survives the journal schema and the partial output is not an error.
  • Run outcome: interrupted counted apart from failures, never a success; unverifiable and any unknown ending read as the failed state beside it.
  • Renderer: header text, accessible name, no check mark, no error tint.
  • Mobile (mobile/src/session/MobileNativeChatMessage.test.ts): output of an interrupted call has no error background; output of an unverified one keeps it.
  • Cross-version (tests/e2e/cross-version-wire/agent-session-interrupted-tool-downgrade.unit.test.ts): a real older release's reducer, schema, projection and run outcome read this build's interrupted and unverifiable calls as 2 failed calls, not a success, both as a host folding the rows after a downgrade and as a client reading this host's items.

Ablations for the turn-row rule (at a46e8b8a4eb): letting a stopped turn's calls take the provider's later end fails the completed Stop test; letting a settlement with no ending (the Stop's) end calls fails 6 tests across the tool-ends and Stop suites. Earlier ablations (measured before the 2026-10-05 merges): making the shared rule always write failed fails 9 settlement tests across the assembler, restart and Codex paths; making the reader ignore endedAs fails 6 reader and renderer tests. Removing the late-proof correction fails all 3 late-proof tests (the review's reproduction failed the same way before this fix and passes now); dropping its owner check fails the other-owner test.

Local runs (vitest, explicit files): the review's reproduction passes; every src/main/native-chat test file plus the shared native-chat / agent-session and Codex structured-journal suites (293 files, 2,681 tests) pass, as do the renderer and cross-version tests. The mobile test could not run locally: this worktree shares the root install, and the mobile config needs mobile's own install (expo/tsconfig.base.json), which was not installed here; react-test-renderer is declared in mobile/package.json. CI's Mobile Checks verify job runs it: on an earlier push its log showed MobileNativeChatMessage.test.ts (23 tests | 2 failed) (my first assertion walked to the wrong element), and at e690534b986 MobileNativeChatMessage.test.ts (23 tests) passes with the whole mobile suite (916 files, 9,936 tests). On one earlier push, mobile web app bundle failed on a Chromium "Unable to capture screenshot" error in an HTML preview test this PR does not touch; it passed on the next run. Known unrelated: the cross-version qoder-history-search-downgrade test fails on unrelated PRs too, and main's typecheck error at src/main/claude/claude-child-work-evidence-retention.test.ts:88 is fixed separately in #25063.

What I verified / didn't

  • Verified: the reference behaviour (session-end settlement of open items as interrupted, turn-end cancellation of open calls including on completed turns, interrupted count in the group summary) read first-hand in the source; the older-build reading against the real pinned release; ablations above; the late-proof reproduction against a real journal.
  • Verified on a separate Mac (see Visual Proof): the rendered desktop tool-run header, screen-reader text and output color, before and after, from seeded journal rows.
  • Not verified: a live Stop on a running agent; the mobile app on a device; real Grok or Codex traffic (fakes and fixtures only); Windows, Linux and SSH hosts (no platform-dependent code changed).

Notes

  • Remote / SSH and mixed versions: the change is an additive optional field (Rule 1 of docs/reference/remote-wire-compatibility.md); older hosts and clients read the existing failed fallback for both endings, proven by the cross-version test. No capability is needed because nothing old has to interpret the field. The late-proof correction runs on the host that owns the journal, from evidence that host recorded.
  • Cross-platform: no platform-dependent code.
  • Localization: two new strings (interruptedCount, interruptedCallsLabel) added to English, the hand-maintained es/fr/ja/ko/zh catalogs (every shipped UI language), and the runtime-required English catalog. The repo's catalog verifiers pass, and all five translated catalogs now miss the same 49 keys as before this PR.

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)

Merged the rebuilt assembler (2026-10-05)

#25064 was rebuilt: the assembler now settles from the journal at write time, and one shared function, terminalAgentJournalBody, gives the terminal row for both its settlement and the restart settlement. Merging it conflicted in exactly those two places, where this PR had changed two separate copies of that rule. Resolution: the ending is now a parameter of the one shared function (terminalAgentJournalBody(body, end)), and both settlements pass how the turn or session ended. The restart settlement's own copy is gone, and its "is anything unfinished" check now uses the existing requiresTerminalSettlement, which is exactly that question. The rebuilt assembler also added a path for a turn another writer already ended (a person's Stop); the next section replaces how this PR handled it. Behaviour everywhere else is unchanged, including the late-proof correction and the Codex lane.

The rebuilt assembler removed "a new provider session replaces this one" (session reset), and its test rig's restart now runs the restart settlement, as production does. So the tool-ends tests drop the session-reset case, and the restart case now checks that a restart with no proof leaves the call unverified (the late-proof test covers the later correction).

Local runs at the merge (vitest, explicit files): every assembler test file, the restart (dead-generation), crash turn-end, crash mid-start, stale-turn verdict and open, stop-event (four files), unexpected-exit, late-proof and journal crash-boundary suites, the Codex settlement and command-retention suites, the shared run-outcome and projection suites, and the renderer test: 31 files, 332 tests pass; four more settlement-adjacent files (Claude context-usage restart and subagent permission request, recovered turn clock, compaction) pass, 48 tests; the cross-version downgrade test passes; the earlier review's late-proof reproduction passes. Typecheck (node, then web) was run locally through the shared-machine gate: the only errors are stream-json / stream-chain modules missing from this worktree's stale shared install (package.json matches main), none in this PR's files. The mobile test still can't run locally (no mobile install); CI's Mobile Checks runs it.

Second merge of the rebuilt assembler and the turn-row rule (2026-10-05)

#25064's next round changed what a person's Stop does in the assembler: it now stops the turn's text and cancels its pending prompts at once, but leaves the turn's running tool calls to the agent, which settles them at its own turn end (or a newer turn, or the session's end settles them). The merge (of #25064 at 9fcb95406ea) was textually clean, but it left this PR's old way of ending calls under a Stop dead: that path read the Stop row when no ending was passed, and no call is ever settled without one now. That path is removed, and replaced by the single rule under Mechanism: a call still running when its turn ends takes that turn's journal row state, so a stopped turn's calls read interrupted even if the agent later reports the turn completed. The promptsOnly flag #25064 added to the settlement scope is folded into that rule: a settlement with no ending (the Stop's) ends no calls. Spanish (es.json) gained the two strings it was missing.

Local runs at a46e8b8a4eb (vitest, explicit files): every assembler test file, the restart (dead-generation), crash, stale-turn, Stop (nine files), unexpected-exit, late-proof, journal crash-boundary, subagent-liveness, store, write-queue and Stop/resume-row suites, the Codex settlement and command-retention suites, the shared run-outcome and projection suites, and the renderer test: 41 files, 469 tests pass; the cross-version downgrade test passes. Typecheck (node, then web) through the shared-machine gate: only the known stream-json / stream-chain errors from this worktree's stale shared install (5 node, 4 web), none in this PR's files. The localization coverage audit currently fails on worktree-creation-hook-preparation.ts, a file identical to main that this PR does not touch.

The same turn-row rule in the restart settlement (2026-10-05)

What review found. After the second merge, the turn-row rule lived only in the assembler. The restart settlement still ended every running call from the death evidence alone, without reading the call's turn row. So after a person's Stop, if the agent never ended the turn and Orca then restarted with no proof the agent died, the call read "1 failed" in red under a turn that read "interrupted after". The same situation closed by the assembler's own session end read "1 interrupted". This needs Stop, an agent that never answers it, and a restart that cannot prove the old process died (an unverifiable process identity, or a lost remote host). It affects assembler-lane agents such as Grok. Codex writes its turn row and its calls in one batch, so it never reaches this state.

Fix. runningCallEnd moved out of the assembler into journal-terminal-settlement.ts, beside terminalAgentJournalBody. The assembler, the restart settlement and the live exit settlement all call it. There is no second copy. A call whose turn row is already settled ends as that row ended. Only a call in a turn still running when the settlement runs keeps that settlement's own end; for a restart, that is the evidence-based end.

Side effect, stated for completeness. A call left running under a turn the provider had already completed now reads plain failed in the restart and exit settlements too, as it already did in the assembler (the "completed turn" difference above). Before this commit, those two settlements wrote interrupted or unverified for it. main writes plain failed there, so against main nothing changes. The case needs a lane that completes a turn without settling its calls, as the Claude lane can (see the Claude note under Mechanism).

Tests and ablations. New tool-ends tests cover two paths after a Stop: a session end the host could not verify, and a restart with no proof. Both make the call read interrupted while the turn keeps the Stop's row. A call that failed before the Stop stays failed across the restart, with no endedAs. Two ablations:

  • Reverting only the restart settlement to the evidence alone fails the restart test and the failed-before-Stop test.
  • Making the shared rule ignore a settled row fails 4 tool-ends tests.

Local runs. At 70fd4bac5bd (vitest, explicit files), the same 42 files as the previous section pass, plus the five settlement-adjacent files: Claude context-usage restart, Claude subagent permission request, failure text, recovered turn clock and compaction. That is 47 files and 531 tests, including the cross-version downgrade test. oxfmt and oxlint are clean on the changed files. The node typecheck through the shared-machine gate shows only the 5 known stream-json / stream-chain errors from this worktree's stale shared install, none in this PR's files.

Up to date with main (2026-10-05)

Merged main at d17351401d7 and #25064 at ad8ebbd1f31 (which already contained that main). Both merges were clean, and the lockfile equals main's. The six translation catalogs still parse, keep this PR's two strings, and pass the repo's three localization checks. The same 47 test files (531 tests, including the cross-version downgrade test) pass at the merged head. The node and renderer typechecks through the shared-machine gate show only the known stream-json / stream-chain errors from this worktree's stale shared install.

Current main refresh (2026-10-06)

Merged main at 4e64fa9940f5a4a9bfb7b021a3c405fd76643ca5; head 98e165184fbab3d2ac976c2b4965ae32e50f03bb. C1a’s landed transition code now comes from main. The interrupted-call change and inherited assembler patch are preserved; the one header conflict uses main’s typography and separator while keeping the failed/interrupted counts. All 24 explicit test files / 277 tests and scoped format/oxlint on 56 code files pass. The lockfile and submission-position test exactly match merged main, with exactly one used provider-handle import. Fresh CI is pending; CI is the typecheck authority.

Prior-head CI result (98e1651)

Head 98e165184fbab3d2ac976c2b4965ae32e50f03bb. Current-head CI has finished: static analysis/typecheck, mobile, packaging and the other unit shards passed. The only test failures are main’s unchanged release-checkout parser-dependency assertion (#25731) and file-explorer cache-scan assertion (expected 2001 visits, got 4002); final verification failed from those two results. The cache failure reproduces locally and its sidebar/store code matches merged main byte for byte. No branch-owned failure remains; no local typecheck was run.

Refresh after the shared assembler landed

A Stop or session ending could leave a running tool call looking failed; this PR shows it as interrupted while preserving calls that already failed. Merged main at eaaae0196f556032c64c665f853d46af97461cec, including #25064 and #25747; current head 5ca9bc56ff8c1f681d2395b20158103a3146c42e. All 36 feature sections and 1212 added/deleted lines are preserved exactly; no transition/assembler dependency copies remain in the diff. All 13 explicit suites / 199 tests and scoped format/oxlint on 29 code files passed. Main’s lockfile and complete submission-position test are unchanged, with one used provider-handle import. Current-head CI is fully terminal and green, including typecheck; details below.

Current-head CI finished

At 5ca9bc56ff8c1f681d2395b20158103a3146c42e, all current-head runs are complete: 16 checks passed and 16 were skipped. Static analysis/typecheck, all five unit shards, compatibility, mobile and both packaging jobs passed. Main’s two corrected test assertions are green in this run.

…al timeline folder

Pure moves so a shared timeline assembler can use them: Codex's message
ordinal counter becomes ProviderTurnMessageOrdinals and Claude's turn-row
revision becomes the provider-neutral agent-journal turn-row revision. Only
names and import paths change.
…found again after a restart

- A sink transition is admitted whole or not at all; its steps run back to
  back at their turn in the journal's write queue, and each resolver reads
  the fold with every earlier write landed. A resolver may also say where the
  row belongs (turn scope, provider reference), and the writer always hears
  how the transition landed. A resolved lifecycle batch chooses its
  settlement mutations from the fold at execution.
- New optional row field providerItemRef: the provider's own reference for
  the item a row is, written only where the row's identity cannot spell it
  (Codex keys messages by their place in the turn and renumbers its item ids
  on resume). Set by the creating write, kept by revisions, indexed by the
  journal fold, never read by clients. A downgrade test shows an older host
  and client render such rows unchanged.
- Provider timeline identity schemes (shared legacy arm, Codex) and the join
  index that resolves a provider item to its row from memory or the fold:
  ordinals and request incarnations are read back from the rows, so a
  restart or an evicted entry finds the original row instead of placing a
  new one.
… joins read from a replaced epoch

A fresh join index continued a turn's messages at the first free place, so a journal holding only a
later ordinal (an imported or removed earlier row) had its sequence back-filled. The place is now
one past the highest ordinal any row or echoed send holds there, read through a pure scheme reader.
The join caches also drop what they read when the journal's epoch is replaced.
…t its turn in the journal

Adapters translate their provider's dialect into a small grammar (turns,
items, streamed text, requests, context facts, session end/reset); one shared
assembler turns it into the journal rows every structured lane writes.

Each event is planned as one sink transition. Which row a write lands on,
whether a replay writes anything, and every change to what the assembler
knows (its ledger) are decided by the transition's resolvers at the event's
turn in the journal's write queue, against the fold as it stands then. A
forecast (the ledger plus admitted events still queued) only answers apply()
at once. So a refused event allocates nothing, a write the journal rejects
leaves no trace in memory, and a restart or evicted cache finds the same rows
again. Text and full snapshots of one provider item share one row and one
lifecycle; reset always flushes text and settles the old session from the
journal; the open-work budget is derived from what is actually open.

Codex migration contracts compare against the existing Codex translator,
including a restart mid-stream and a repeat that outlives the join cache.
…nd background work

A third review found two blockers with the earlier rounds' cause, a remembered interpretation
trusted after the journal moved on:

- A reused request id was judged by its earlier prompt's settled turn before asking which turn the
  new one lands in, so a real approval in a later turn was dropped. The target turn now decides:
  the old turn again is a replay; a different live turn opens the next prompt beside it.
- A text stream checked its row's turn only on its first write, and a turn's end released streams
  by the turn planning expected. Every write now checks the row, a turn's end stops the streams
  whose rows are in it, and turn status reads the journal first, so another writer's Stop wins.

Also: a message boundary drawn by an event the journal held as a replay no longer splits an
anonymous message; a send naming a turn not yet open waits for that turn; the budget charges a
stream's thread and turn strings and the turn caches are byte-bounded; the open turn ends when the
journal shows it settled; a turn's opener is read from the journal's row.

Background work is now Orca's existing background-task row instead of a tool call flagged
`outlivesTurn` (a flag remembered only in memory, so a restart failed the task). A turn's end
never settles that row, so it survives restarts; session end leaves one in flight unverifiable.
Three tests that opened a background tool call with `outlivesTurn` now open a background-task row
and keep their original expectations about which turn the row stays in.
… streams

A row kept for the anonymous stream that may continue it, and the marker that a stopped stream's
queued writes write nothing, lived in the live-stream map and were never removed when no stream
followed. They now live in their own bounded maps, so the live map holds open streams only.
…, not failed

A call still running when its turn was interrupted, when the provider child
was seen to exit, or that the provider cancelled now carries `endedAs:
'interrupted'` beside `state: 'failed'`. Every build that predates the field
keeps reading the call as the failure it always showed; this build reads both
through one shared reader and counts the call apart from failures in the run
header, without the error tint on its partial output.

The shared assembler, the restart settlement and the Codex lane settle a
running call through one rule: only a proven interruption cuts it short, so a
turn the provider completed around an unclosed call, or work the host lost
track of, still reads failed.
A settle with no proof of the child's death closed its running calls as plain failed, so a
proof written afterwards corrected the turn to interrupted but could not find the calls. The
call now keeps endedAs: 'unverifiable' beside its failed state, and the stale settle revises
exactly those calls whose owner the proof names. Older builds still read them as failed.
Keeps both multi-row writes: the transition's per-row write (each built after the
one before it folds) and main's all-or-nothing queued-rejection write. Main's
ledger receipt rides the shared single-row write. A resolved lifecycle batch
cannot carry rejectsQueued, so it is never silently dropped.
…embler' into brennanb2025/acp-c5-tool-interrupted
…embler' into brennanb2025/acp-c5-tool-interrupted
…m the transition PR

Nothing in production reaches the state they defended (an assembler that lost its memory while
its child keeps streaming the same turn), and the stored Codex id was positional. The legacy
identity scheme moves to the assembler PR with its first caller; the Codex scheme and any
persisted reference wait for Codex to move onto the assembler. The Codex ordinal counter goes
back to codex/, since no neutral code imports it.
A settlement too large for one row now commits all its rows or none, through the journal's
existing all-or-nothing write, instead of a row-by-row writer. Every row is built before any
commits, so a settlement naming one item twice is refused before anything is written.
…d-forget

Nothing reads which steps of a transition wrote. A failed step fails the sink, leaving the
steps before it written; the header says so, and tests cover it plus a settlement whose second
row fails inside the transaction. writeAgentJournalTurnRow returns nothing again, as on main.
…ansitions' into brennanb2025/acp-c1-timeline-assembler
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review summary

The problem. When a tool call is cut short, because you pressed Stop, the agent cancelled, a newer turn superseded it, or the agent process exited, whoever closes it out could only mark it failed. So the chat said "1 failed" and showed the partial output in red for a call nobody said had failed.

User-facing change. A tool call cut short by a proven interruption now reads "1 interrupted" in the collapsed run header. It uses the same small grey style as "1 failed", has no check mark, and has the screen-reader label "Interrupted tool calls: 1". A mixed run reads "1 failed, 1 interrupted", and the partial output is no longer red, on desktop and mobile. Nothing else is added: no notice, no banner. A call Orca lost contact with still reads "failed", as it does today.

Mechanism (unchanged by review). Tool-call rows keep state: 'failed' and gain an optional endedAs: 'interrupted' | 'unverifiable'. This keeps older Orca builds and clients on today's "failed" reading; an unknown state value would read as success there. One shared reader turns the pair into running / completed / failed / interrupted. If proof that the agent process died arrives later, it upgrades an earlier "unverifiable" end to "interrupted". That upgrade is re-derived from the row each time Orca settles, and is keyed to the agent process that created the call.

Fixed during review.

  • A late proof of death now corrects the tool call, not just its turn (fixed before this review; confirmed).
  • One rule for every settler. A call still running when its turn ends now ends the same way that turn's row ended. So after a person's Stop, its calls read "interrupted" whether the turn is closed by the agent's own end, at the next turn, at session end, or by the restart cleanup. A call the agent completed after the Stop stays completed. This rule now lives in one shared function used by the assembler (Add a shared timeline assembler for structured agent chats (not wired yet) #25064), the restart cleanup and the exit settlement.
  • Spanish strings for the new labels were missing; all shipped locale catalogs now have them.

Decisions to confirm (Brennan).

  • After a crash or an unexpected exit, the tool header reads "1 interrupted" while Orca's existing turn verdict for that same turn reads failed on the sidebar ("Codex Chat - Failed") and "Worked for …" on the turn bar. The stored data keeps the difference, so this is a reader choice. It's kept because the common pattern marks calls still open at a session's end as interrupted, and it's labelled as intended in the body.
  • A call whose end Orca couldn't verify (lost contact) still reads "1 failed" in red. That's existing behaviour, but this PR now stores the "couldn't verify" fact. A neutral reading (not counted as failed, no red) would be a one-function change if you want it.

Deferred (temporary).

  • A Claude call you stop live still reads failed: Claude reports it as an error result. Follow-up: read Claude's interrupted flag in the Claude lane.
  • The Codex lane's "evicted active item" path still writes plain failed. Follow-up: route it through the shared function.

Verified.

  • Two review rounds, an architecture review (verdict: keep this design) and a readiness checklist (no P0/P1/P2).

  • 531 tests, including the cross-version test against v1.4.218 (an older host reads the new rows as plain failed).

  • CI on the final head 16eef1244af (current main and the final Add a shared timeline assembler for structured agent chats (not wired yet) #25064 merged in): typecheck and static analysis, all five unit-test shards, Mobile Checks (which run the mobile render test), the mobile web bundle, packaging (including Windows), cross-version wire compatibility and verify all pass.

  • Visual QA on the M4Air, on this head and on main, with identical chat-history rows written straight into isolated profiles. 6/6 pass, and I checked the two collapsed captures myself.

    Before (main) After (this PR)
    Collapsed run before "1 failed" after "1 interrupted"
    Expanded output before red after not red
    Mixed run before "2 failed" after "1 failed, 1 interrupted"

Not verified.

  • The live Stop path. Nobody pressed Stop: this build has no agent lane that produces a stopped call except Codex, and Codex can't run on the QA machine.
  • Mobile on a device. The mobile render test runs in CI's Mobile Checks.
  • Windows and Linux.

…assembler

The common pattern discards the history a provider replays while loading a
session, so the assembler has no use for an input.history event.
Review follow-up to the adoption removal: drop the comment naming the
provider's saved message, and make requestedAt required since every pending
input comes from input.accepted.
- journal-store.ts imports: main #24576 dropped journalStoreLoadedFields; this branch types
  appendResolvedItem off JournalItemAppender, so neither it nor JournalResolvedItem is imported.
- event-sink-queue: keeps this branch's journalItems; journalStopDecidesTurn takes main #24864's
  two arguments (openedBy plumbing removed on main).
…ansitions' into brennanb2025/acp-c1-timeline-assembler
agent-session-journal-schemas.ts: main #24576 (through C1a/C1b) made the known body union internal
(KnownItemBody) behind an open union; this branch's TurnLifecycleFields, tool-call endedAs and the
shared turn fields sit on it unchanged.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Merged the current C1b, then current main, into this branch (now 105ba896929).

  • Conflict (one, with C1b): agent-session-journal-schemas.ts. Main fix(native-chat): never delete chat history on read; a chat Orca can't load says why once #24576 (arriving through C1a/C1b) made the known body union internal (KnownItemBody) behind an open union. This branch's TurnLifecycleFields, the tool-call endedAs field and the shared turn fields now sit on it; the diff against C1b is exactly this branch's change. Main merged in cleanly after that.
  • Verified: 117 of 121 related test files pass locally (1553 tests); 4 can't load on stale local dependencies. The mobile test can't run on this machine; CI's Mobile Checks covers it. The scoped typecheck has only the stale-dependency error. The line-limit lint passes on the merge result.
  • CI: the main-caused "focused code-quality plugins" failure (Add confirmed deletion of nested worktrees #25668's duplicate import), which also skips CI's typecheck and test shards here.

brennanb2025 added a commit that referenced this pull request Oct 5, 2026
No conflicts. C5 now carries C1b without the assembler's input.history event; D3's
remaining reference goes with D2's adoption removal.
…CP foundation squashes)

One conflict, in agent-session-journal-schemas.ts: main moved the row's failure fact into
agent-session-failure-fact-schema.ts (#25073) right where this branch added TurnLifecycleFields.
Took main's move (the old local FailureFact is gone) and kept this branch's TurnLifecycleFields.
…p-c5-tool-interrupted

Main's Electron-import ratchet still allowed src/main/provider-process to be absent after #24989
created it, which fails its own retirement test on every branch; #25710 makes the lane required.
…opaque provider handle

Main's #24991 replaced the {kind, ...} handle with {transport, agent, nativeId}; three test
files from this stack still wrote the old shape. Same lines the downstream ACP branch uses.
brennanb2025 added a commit that referenced this pull request Oct 6, 2026
Conflicts were only in files another base owns: A6's Claude/Codex/provider-process files take A6 #25204's head, the journal schemas take C5 #25181's head, and main's Stop-note test takes A3's (A3 now fixes it for its own API, so D3's copy of that fix is dropped).
brennanb2025 added a commit that referenced this pull request Oct 6, 2026
@brennanb2025

brennanb2025 commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Merged main after #25064/#25747 landed; head 5ca9bc56ff8c1f681d2395b20158103a3146c42e, with only the interrupted-call feature in the diff. All 199 targeted tests and scoped format/oxlint passed, main lock/import remain exact. CI is fully terminal and green: 16 passed / 16 skipped, including typecheck, all five unit shards, compatibility and both packaging jobs.

brennanb2025 added a commit that referenced this pull request Oct 6, 2026
@brennanb2025
brennanb2025 marked this pull request as ready for review October 6, 2026 07:36
@brennanb2025
brennanb2025 merged commit c4ea14c into main Oct 6, 2026
35 checks passed
brennanb2025 added a commit that referenced this pull request Oct 6, 2026
Main now carries A3 #25159 (squash 9905765) and C5 #25181 (squash c4ea14c); both squashes equal
their final branch heads, so the merge was computed against main's tree with those heads as extra parents
(scratch commit, never pushed) and committed as an ordinary two-parent merge of origin/main.

Resolutions: dead-generation settlement keeps D3's move of the unfinished-work helpers; provider support,
create support, the record's stored-agent check and its tests take main's A3 (records no longer admitted
per registration). D3 callers of the removed registry follow: agent-launch-mode reads the registered
agents from STRUCTURED_AGENT_RUNTIME_REGISTRATIONS; the Grok host rig opens the store without agents; the
"admits Grok's records" registration test is gone with the registry.
brennanb2025 added a commit that referenced this pull request Oct 6, 2026
…-history

- #24660: the row writer's planned write is public writeRows (the new step writer
  calls it); the transactional write that also stores the chat's status is
  commitRows, so every path still writes the status row once per transaction.
- #25181: the open settlement plan ends a running call as its turn's row ended
  (runningCallEnd / terminalAgentJournalBody) and revises calls an unproven settle
  closed once a proof names their owner; the status facts read
  requiresTerminalSettlement, the same facts as before. Liveness keeps main's
  lostLiveWorkJournalBody under the per-item roster revision.
- #24660: the open plan settles what a gone process left queued through
  holdUnsentSends (a person's message becomes a held card); its leftovers are
  exactly the queued sends written before the open.
- #25159: reading and settling a chat need no adapter. The persisted tab listing
  lists every tab with a record, startup settles any chat whose record exists,
  and adapterSupportsRecord / hostCanSettleRecord are gone, as on main.
- Line caps: the runtime's startup step moves into its own layer
  (orca-runtime-structured-agent-session-startup-step.ts), and the host exposes
  its restore as one `startup` surface instead of four pass-through methods.
- The golden digest is unchanged, so the status rules stay at 3.
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