Skip to content

fix(native-chat): a turn a proven crash cut short reads interrupted - #23456

Merged
brennanb2025 merged 30 commits into
mainfrom
brennanb2025/crash-turn-reads-interrupted
Sep 29, 2026
Merged

brennanb2025 merged 30 commits into
mainfrom
brennanb2025/crash-turn-reads-interrupted

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 12 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1278 $\color{#cf222e}{\Huge{\mathbf{−}}}$​36 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1242
Prod 15 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​291 $\color{#cf222e}{\Huge{\mathbf{−}}}$​104 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​187

Scope: which chat this affects

  • Structured chat only. This PR changes the experimental structured native chat, which runs the agent through its SDK. It is behind the experimentalStructuredNativeChat setting, which is off by default (src/shared/default-global-settings.ts:137).
  • Default chat view: no change. The default chat view, backed by a terminal and reading the agent's transcript, does not use this code, and nothing about it changes.
  • Terminals: no change. Plain terminals and terminal-backed chats behave exactly as before.

ELI5

When Orca crashes while an agent is answering, the next launch finds that agent's process gone and closes off the unfinished answer. It used to label that answer "couldn't confirm", as if Orca did not know what happened, even though it had just checked and knew the agent was dead. Now the answer is marked as interrupted, shows how long the agent actually worked, and says the agent stopped.

What Changed

Before: after a crash mid-reply and a relaunch, the cut-off turn was settled as unverifiable:

  • no "Worked for …" line and no fold: the turn stayed fully expanded with every tool row;
  • no explanation row;
  • the same as a chat Orca truly could not reach.

After: the turn is settled as interrupted:

  • it shows "Worked for …" and folds like any ended turn (desktop and mobile);
  • it gains the row "Claude stopped while this response was in progress. You can continue in this conversation." (naming whichever agent it was);
  • the chat that was on screen when Orca crashed shows "Couldn't confirm" for a few seconds after relaunch, until Orca has proved the old agent gone (by checking its process, or by stopping it if it outlived Orca); then it changes to interrupted, "Worked for N", with no send needed;
  • a chat whose agent Orca could not prove gone (lost contact, an owner on another host, an identity it could not verify) still reads unverifiable, with no end time and no row.

Words used below:

  • Ownership record (the code calls it a lease): Orca's saved record of which agent process owns a chat. While the agent runs, Orca renews it about every 10 s, and refuses to renew unless the process still matches the one it recorded.
  • Proof of death (death evidence): what Orca writes on that record when it has proved the agent died, with the time it found out.
  • Process check (probe): Orca asking the operating system whether the recorded process is still running.
  • Timeline (journal): the chat's saved rows. A row is "live" when the agent's activity produced it, and "recovered" when Orca wrote it afterwards while cleaning up a crash.

Mechanism:

  • Which deaths count as proven. The turn's verdict now follows one fact: whether the ownership record carries a proof of death. With one, the turn is interrupted; without one, it stays unverifiable. Orca writes a proof only when it saw the process exit, or when a process check found the process gone or replaced by a different one. Every release that proved nothing writes none. The "has this agent exited" answer already uses the same rule, so the two cannot disagree, and there is no separate list of kinds to keep in step.
  • When the turn ended. A seen exit ends at the exit. A death found by a process check ends at the last time Orca proved the agent alive. It never ends after the check or before the turn began, and falls back to the check's time when no proof of life was recorded. The end becomes the shown duration, so ending at the check would count the time Orca itself was down: a crash overnight would read "Worked for 9h".
  • Why the last proof of life is needed. The timeline only gets a row when the agent reports something. Claude reports a shell command once when it starts and again when it finishes, so in QA a crash 26 s into a command read "Worked for 2s". The ownership-record renewal every 10 s is a proof of life Orca already writes to disk. The proof of death now keeps the last renewal from before the death, as a new optional field lastProvenAliveAt, so the shown duration is at most about one renewal short however quiet the agent was.
  • Why timeline rows don't count. An earlier revision ended the turn at the later of the proof of life and the last live timeline row. But a timeline row does not say who wrote it: the user's send, Orca's delivery rows and Orca's status notes are live rows too, and a send can be accepted into a crashed chat before the proof of death lands. Readiness review reproduced it: a send at 3,605,000 into a turn whose agent was last proven alive at 210 ended the turn at the send, reading "Worked for 1h". Only the renewal is a fact about the agent alone. It costs at most one renewal interval: in QA, 0.6 s on a streaming Claude reply and nothing on a silent command, which was already bound by the renewal.
  • Never before the turn began. The last renewal can predate the turn: a renewal at t−3 s, a turn started at t, a crash at t+5 s. The end is then the turn's start (the later of when it was requested and when the provider opened it), so a crash within one renewal of the turn's start can read "Worked for 0s", a lower bound, rather than ending before it began.
  • Where it is captured. One function writes the proof of death from a process check, for both a relaunch and a recovery that stops a leftover agent. It copies the record's last renewal time, never later than the check, so a clock that stepped back still writes a valid interval. The other writers (a seen exit when a chat is closed, a failed start) leave it out: a seen exit already ends at the exit, and a start that failed before a process existed had nothing to prove alive.
  • Whose death it is. A proof of death now names the owner it is about, as ownerFence: the generation number (fence) the record held for that owner. The turn's timeline rows already carry the fence of the owner that wrote them. Each running turn is judged by the fence of the row that created it, which the timeline derives from its rows on every open, with nothing stored. A running turn is interrupted only by a proof naming its own owner; a proof about any other owner leaves it unverifiable, with no end. Why it matters: after a crash, a new start reserves a new fence, which clears the relaunch's proof about the old owner. If that start then fails before the old turn is settled, the record's proof is about the start's own agent (a watched exit at the failure, or recovery later finding the agent it left behind gone). Main ended the old turn there, an hour after the crash, with the start's reason in the row; now it says Orca can't confirm. Carrying the relaunch's proof forward so that turn reads interrupted is a follow-up. Every writer sets the field: a process check, a failed start, and closing the last view. A record without ownerFence (written by an older build) keeps main's rule: a watched exit reads interrupted at the exit, and everything else reads unverifiable. Always reading unverifiable would regress the upgrade case, where an older build saw an exit mid-turn but quit before settling it.
  • A chat read before the check is revised when the proof arrives. On desktop the chat on screen at relaunch reads its timeline before startup has checked any agent process. Opening a crashed chat's timeline settles whatever the dead Orca left running, and at that moment nothing has proved the agent gone, so the turn is settled unverifiable. The saved session store now tells the host, once a write is committed, which chat just gained a proof of death, whichever step wrote it: the startup check, a recovery that stops an agent that outlived Orca, a failed start, or a seen exit. If that chat is open, the host queues that same settle for it behind the chat's other work, and the settle now also moves an unverifiable turn to interrupted when the proof names the owner that wrote it. Any later open of the chat re-runs it too, so a revision that could not be written converges the next time the chat opens. It only ever moves unverifiable to interrupted, never back, and never from an older build's proof, which names no owner. A second run finds nothing to change and writes nothing. The explanation row is named after the proof it explains (the chat, the owner's fence, and when the death was found), so a retry after a settle that was only partly written revises that row instead of adding a second one. Each write batch keeps its own per-attempt id, because a retry splits only what is left into batches, and reusing an earlier batch's id would make the journal skip that batch as already written. It cannot touch a newer agent's turn: a proof exists only while the record is released, a new start clears it, and the newer agent's turns carry its own fence. Because the trigger sits where every proof is written, a new kind of proof needs no extra call. One side effect: when an agent's exit is seen but the turn's own settlement write fails, recording the exit now retries the settle at once, where main left the turn running until the chat was next opened. Quit stops listening first and waits for any settle already queued. Clients see the change as an ordinary timeline update.
  • Parking a chat for recovery no longer claims the agent was alive. On relaunch, a chat whose agent Orca cannot drive is parked for recovery, and parking used to stamp the renewal time with the relaunch. Nothing at that moment vouched for the agent through its connection: either the check could not tell, or it found an agent that outlived Orca but whose output no longer reached anyone. Recovery then stops that agent and records its death from the same record, so the turn would have counted the downtime. The three places that park a chat (relaunch, and two failed-start paths) now leave the renewal time alone; the record's own last-updated time still moves.
  • The explanation row. Main now words this row from the agent's name alone and never puts a proof's detail into it, since that detail is Orca's log text. This PR does not change the row's words; it only decides when the row is added.

Unchanged for the user (every reader checked): sidebar, mobile and dashboard status dots, Smart Sort, notifications, the resume-after-restart offer (a crash writes no resume marker) and send gating. These read "running or not", or the turn's outcome, which this settle does not set.

Related display change: #23467 changes the folded line for these turns from "Worked for N" to "Interrupted after N", and shows the unproven case as "Couldn't confirm". This PR supplies the end time that line reads.

Why

The unverifiable state exists for deaths Orca cannot prove, such as lost contact with an SSH host. Using it for a local process Orca has just proven gone told the user less than Orca knew.

Alternatives considered:

  • The later of the last proof of life and the last live timeline row (this PR's previous revision). It is up to 10 s closer for a chatty agent, but a row does not say who wrote it, so a send accepted before the proof of death ended the turn at the send and counted the downtime. Counting only rows of certain kinds still counts Orca's own status notes, and telling the agent's rows apart would need a new saved field on every row.
  • End at the last live row alone (an earlier revision). It is right for a chatty agent, but its error has no limit: a silent command or long hidden reasoning writes no rows, so the undercount grows with the command.
  • Keep excluding parked chats when capturing, instead of fixing the stamp. That adds a second rule beside the one that writes the wrong time. Fixing the stamp makes the renewal time mean one thing everywhere.
  • List which proof kinds count. That is a second copy of a fact the record already carries: a proof is written only when death was proved. Reading its presence keeps the turn verdict and the exit answer from disagreeing.
  • End the turn at the check's time. That is simpler, but the duration would include the downtime, which is wrong in the one place the end time is shown.
  • Keep unverifiable when the process was replaced by another. A verified mismatch is positive evidence that the recorded process no longer exists, so it proves the death as much as a missing process does.

Differences from the common pattern

Comparable desktop agent hosts, on a restart, close a conversation's active turn as interrupted (or mark the session errored) from state they already hold, best effort. This PR matches that for a proven local death. Deviations:

  • End time. Those hosts stamp the interruption at detection time. This PR ends it at the last proof of life, because the end drives the shown duration. None of them carries a heartbeat into the turn's end; Orca's renewal is its own fact.
  • A provisional verdict first. Those hosts settle once, at detection. Here the open can run before detection, so a chat on screen at relaunch first reads "Couldn't confirm" and is revised to interrupted when the check (or a recovery that stops an agent that outlived Orca) proves the death, usually within seconds. The alternatives were to make every read wait for the startup check (up to about 14 s before a chat shows anything) or to have a read run the process check itself; both put the check in the path of reading a chat.
  • Unproven deaths. Those hosts have no separate state for a death they could not prove. Orca keeps unverifiable for that case: its execution host can lose contact with an agent without the agent being dead.

Linked Issue

Part of the native-chat "a chat never gets stuck" series. Follows #22820, which made a recorded proof of death mean the death was actually proven.

Visual Proof

Full-app QA of this PR's final head (728d3ec9bc2) against its merge base with main (ccdb324b63e), from identical starting states per arm, on a remote macOS machine with the real Claude CLI. The Electron main process was killed with kill -9 mid-turn, left down about 90 s, then relaunched with the same profile and the chat on screen. The Codex rows come from an earlier full run on a second Mac (real Codex CLI, isolated background build, head c32dd598bc); their silent command ended at the last proof of life, which is what the final rule does.

Before (base) After (this PR)
Claude running sleep 120, crash about 28 s in, chat ON SCREEN at relaunch
"sleep 120 · 1 failed" only: no duration, no explanation, and it never changes

The first frame with the chat already reads "Worked for 18s", folded
Claude, fold opened
Exactly one "Claude stopped while this response was in progress. You can continue in this conversation." row; no process-id text
Claude, the next message
Answered, with no duplicate row
An owner this host cannot check (seeded as a remote host)
Stays "Couldn't confirm" with no row, unchanged for 35 s
Codex running a silent sleep 120 (c32dd598bc)
No duration, no explanation

"Worked for 26s" for 29.4 s of real work
Codex, fold opened (c32dd598bc)
"Codex stopped while this response was in progress…"
  • The duration is a lower bound. It ends at the last time Orca proved the agent alive (renewed about every 10 s), so it can read up to one renewal interval short, and it never counts the time Orca was down. In the final run the last renewal came 9.1 s before the kill: "Worked for 18s" for about 28 s of work.
  • "Couldn't confirm", then "Worked for N": in every live run the proof of death was written (about 1 s after the renderer was ready) before the chat first painted, so the chat showed its final state immediately. The path where a chat reads first and then changes is pinned by the host-level tests below.
  • A normal reply, a clean quit and a relaunch re-settle nothing, with no renderer or main-process errors.
  • The status words ("Interrupted", "Couldn't confirm") on the tab, sidebar and folded header come from fix(agent-status): a turn a crash cut off reads Interrupted, an unproven end Couldn't confirm #23467; this PR changes the turn's recorded outcome and its duration.
  • The launcher for these runs refused a home that resolved to the machine's real home, ran with rig-local HOME and config folders, and had agent hooks off. The real ~/.codex and ~/.claude config files were hashed before and after every run and did not change.

Testing

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

New and changed tests (each shown red with its core line removed or restored):

Case What it pins Change that turns it red
Verdict from a seen exit, a missing process, a replaced process, and no proof Interrupted at the exit; interrupted at the last proof of life, never after the check; the check's time when no proof of life was recorded; unverifiable with no proof Only a seen exit counts (2 red); end at the check's time (2 red); no proof reads interrupted (1 red)
Relaunch after a crash mid-turn, process check finds it gone (whole host, read restore and attach) Interrupted at the last proof of life, or at the turn's start when that renewal predates it; the default "provider stopped" row appears; no status claims a turn outcome The same three changes (4 red)
Relaunch with an agent on a host this one cannot check Stays unverifiable: no end time, no row No proof reads interrupted (red)
Seen exit before a quit (regression) Keeps its exit time; the row names the agent, not the exit's detail —
A command whose output kept streaming past the last renewal, then a proven death (real timeline) Ends at the last proof of life, not at a row written after it The previous revision's end rule (red: ends at the later row)
A crash with the agent's last row at 200 and last proof of life at 210, the reopen finding no proof yet, a send accepted at 3,605,000, then the proof at 3,606,000 (real timeline) Ends at 210, not at the send The previous revision's end rule, the later of the proof of life and the last live row (red: ends at 3,605,000)
A proven death whose last proof of life predates the turn's start Ends at the turn's start, never before it Drop the clamp (5 red: the unit test and 4 relaunch tests whose seeded renewal predates the turn)
Verdict with a last proof of life before and after the check, and with none; a seen exit with one Ends at the proof of life, never after the check, and at the check when none was recorded; a seen exit still ends at the exit Drop the proof of life from the end rule (red)
Claude-shaped crash mid-tool: one Bash row at T, renewal at T+25 s, relaunch an hour later, process gone (whole host, read the way a client reads) Ends at T+25 s and shows "Worked for 27s" through the display's own timing code Drop the proof of life from the end rule (red: ends at T, 2 s)
The same crash, but the agent outlived Orca: the check finds it running, recovery stops it, then finds it gone The proof of death keeps the pre-crash renewal, and the turn ends at T+25 s Stamp the relaunch time when parking for recovery (red: ends at the relaunch, counting the hour)
A failed start parked for recovery, before and after the agent proved itself, then its death recorded The renewal time keeps the last proof of life, and the proof of death carries it Stamp the failure time when parking (2 red)
Saved store: relaunch after a renewal, then reload; a proof an older build wrote; an impossible interval The new field round-trips; an old proof loads unchanged; a proof of life after the check is set aside like any other invalid record Drop the field's validation (red)
The proof of death records the last renewal, never after the check The field is the renewal time; a clock that stepped back writes the check's time Drop the capture (8 red across files); drop the cap (red)
A send or a reader opens a crashed chat nobody had open (the open-time settle from #22821) A proven death reads interrupted; one nothing proved stays unverifiable Only a seen exit counts (2 red)
A start after the crash spawns an agent and fails without proving it gone; recovery later finds that agent gone; a new start settles the old turn (whole host) The old turn is unverifiable: the proof is about the newer agent Judge any turn by the latest proof (red: ends at the relaunch, an hour after the crash)
A start after the crash fails and cleanup watches its agent exit; a new start settles the old turn (whole host) The old turn is unverifiable, not ended at the failed start Same (red); leave the owner out of a failed start's proof (red)
Verdict with a proof about another owner, an older build's proof, and a turn whose writer is unknown Another owner's proof and an unknown writer: unverifiable. An older build's watched exit: interrupted at the exit; its probe proof: unverifiable Judge by any owner (red); older builds' proofs always unverifiable (4 red, including 2 upgrade tests for chats left mid-turn)
The crash left a send in doubt, which the reopen settles at the new fence (whole host, both paths) The turn still ends at the last proof of life, reading about 27 s Take the turn's owner from the newest row instead of the row that created it (2 red)
Every proof-of-death writer (process check, ownerless reservation, failed start ×3, closing the last view past a recovery floor) Each names the owner it released Drop the field from each writer (red each); a malformed ownerFence is set aside like any invalid record
The chat is read (and subscribed to) before the startup check; the check then proves the owner gone (whole host) Reads unverifiable, then interrupted at the last renewal, "Worked for 27s", one explanation row; the subscriber is sent both Drop the revision (4 red); don't re-run the settle when a proof is written (5 red)
The chat is read before the startup check, and its agent outlived Orca: the check finds it running, recovery stops it, then finds it gone (whole host, no send) The turn goes from unverifiable to interrupted at the last renewal, and no agent is started Re-run the settle only after the startup check, as this PR's previous revision did (red: stays unverifiable); don't re-run it when a proof is written (red)
An agent exits mid-turn and the timeline refuses the exit's own settlement write once (main's test, adapted) Settled in place with the row naming the agent, the chat still open, no new agent; refusing the retry too still leaves the reopen, the read after a restart and the next send to settle it (main's tests for those now refuse the retry as well) Don't re-run the settle when a proof is written (red: stays running)
The saved store's notice: a failed write, the startup check's proof, a later unrelated write, a listener that unsubscribed Told exactly once, after the proof is on the record; never for a write that proved nothing Tell for any record that holds a proof (red); the previous revision's callback on the startup check only (red)
A start after the crash runs the check itself while the chat is open, and its new agent is already writing a turn (whole host) The old turn is interrupted; the new agent's running turn is left alone Re-run the settle without requiring a proof (red: the new turn reads unverifiable)
The revision's write fails, then the chat closes and is opened again Stays unverifiable (no running turn), then the reopen revises it to interrupted Drop the revision (red)
A turn settled unverifiable, then proofs about another owner, from an older build, and about its own owner, settled twice Only its own owner's proof revises it, once; the second run writes nothing Revise from any owner's proof (red); revise from an older build's proof (red); add the explanation row on every run (2 red)
A settle too big for one write batch: the second batch fails, then the settle runs again (real timeline) Every turn ends interrupted, with exactly one explanation row Name the row by the attempt, as before (red: two rows); reuse the batch ids across attempts too (red: 199 turns left running)
v1.4.211 opens a store this build wrote after a proven death Reads the record and keeps both new fields A compatibility pin: this build cannot turn it red, because the older validator ignores unknown keys by design

Runs (latest, with the end at the last proof of life): vitest over the ten files this rule touches (the verdict, the crash-turn end, the stale-turn open and send-open, unexpected exit, the wedged-profile relaunch, surface lifetime, the journal reducer and store, and death-evidence persistence): 155 tests pass. The earlier full run over src/main/native-chat, src/main/runtime/agent-session*, src/main/runtime/structured*, src/shared/agent-session* and src/shared/structured-agent* (298 files, 2,863 tests) predates this change. The two cross-version downgrade tests against v1.4.211 pass in CI. pnpm tc:node and pnpm tc:web are clean, oxlint on the changed files is clean, and check:code-quality:changed and audit:anti-slop pass.

Review

Mixed versions and saved data:

  • No wire change. interrupted with an end time is an existing timeline state that every client already renders.
  • Two new saved fields, both optional, on the proof of death in the host's own session store: lastProvenAliveAt and ownerFence. They never reach the renderer, mobile or a remote client.
  • Why a field, and why this one: a death is an interval, from the last time Orca proved the agent alive to the check that found it gone. The proof already stored the upper end; the new field is the lower end. It has to be saved because recording the death overwrites the renewal time, so the fact is gone once the record moves. Saving the lower end rather than a finished turn end leaves the turn's end derived when it is settled, and would also answer "how long was Orca down" later.
  • Why ownerFence, and why the fence is enough: a proof of death is a fact about one owner, and a released record no longer holds that owner. The fence is the record's generation key. It moves only when a new owner reserves (always to a new number, never reused, even after the store falls back to its backup) and when an owner is released, and only a reservation can take on a process. So a fence names exactly one owner, and the process identity (pid, start time) adds nothing the turn could be matched on: timeline rows carry the fence, not the process. It has to be saved because the fence the dead owner held is gone from the record once it is released, and it cannot be reliably worked out afterwards: a release usually moves the fence one step, but a floor raised when the store falls back to its backup silently breaks that inference, so it is recorded when the death is.
  • Downgrade: v1.4.211 checks only the proof's kind, detail and time, so it keeps and ignores both fields. Pinned by a cross-version test that opens, with v1.4.211's record store, a store this build wrote after a proven death.
  • A proof an older build wrote names no owner, so it keeps that build's rule: a watched exit ends the turn at the exit, and anything else leaves it unverifiable, as on main. This applies only to a proof the older build recorded itself; a crash under an older build is proved at the relaunch, which this build writes. A chat an older build parked for recovery still carries that build's relaunch stamp; if this build then records its death, the end can include the downtime up to that older relaunch, for that one turn.

Who reads the renewal time (lastRenewedAt, found by renaming the field and typechecking the node and web projects):

  • withLease in agent-session-lease-transitions.ts:39 copies it into the record's last-updated time. Nothing but the validator reads that, and the three parking transitions now set it themselves.
  • The record validator in agent-session-record.ts: requires a safe integer.
  • deathEvidenceFor in agent-session-lease-adjudication.ts: the new reader.
  • Tests only otherwise. Every other hit is a write: reservation, spawn seen, identity proved, renewal, provider handle recorded, timeline checkpoint (no production caller), release, recorded death and clearing a free record.
  • The renewer does not read it; it renews on a timer and skips parked chats.

Carrying the old owner's proof forward (follow-up): when a start after the crash fails before the old turn is settled, that turn now reads unverifiable, because the start's reservation cleared the relaunch's proof about its owner. Settling the old turn when the reservation is granted, or keeping the proof per owner, would let it read interrupted. That changes the order of acquisition, which is outside this PR.

Sidebar and tab display follow in a stacked PR: this PR fixes the turn's lifecycle and verdict on the host (the chat transcript). The sidebar, tab and mobile status still show a crash-cut chat as done, because on main no status display reads a turn's outcome yet; that display vocabulary arrives with #22944. A small PR stacked on #22944 adds two host-observed outcomes derived from this lifecycle: interruption (a proven mid-turn death, shown as Interrupted and ranked as news, never a user cancellation) and unconfirmed (an ending Orca cannot prove, shown as "Couldn't confirm").

Merge order with #22944: #22944 (on the accept-then-deliver stack) demotes turns stopped by the user in attention and sorting. It reads a turn's outcome, which this settle never sets, so a crash-cut turn is not classified as a user cancellation. The restart test here asserts that no status carries a turn outcome, which covers the composition from this side. Once both are on main, a test through #22944's agentTurnStoppedByUser should be added.

Overlap: #23131 has a test that asserts unverifiable for this case; whichever lands second flips it. #22821, now on main, also settles when a send or a reader opens the chat; its test asserted unverifiable for a proven death, and this PR flips it. #22835, now on main, pins that a read before the startup check settles the turn; its test asserted unverifiable, and now asserts unverifiable, then interrupted. #22835 also pins that a turn whose exit settlement could not be written is settled by the next open; that test now refuses the in-place retry as well, so it still pins the open.

Merging main: main has since moved the fold that settles a sent message's delivery into its own file, which this PR had also done to stay under the reducer's line limit; this PR now uses main's file. Main also rewrote this settle's explanation row (see "The explanation row"), which superseded this PR's own copy change.

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.

Known weaknesses

  • The duration can read up to about 10 s short. A process-checked death ends at the last renewal, which Orca writes about every 10 s, so the work between that renewal and the death is not counted. The duration never counts the time Orca was down. In QA: 22 s for 26.6 s of work (Claude, silent command), 26 s for 29.4 s (Codex, silent command), and about 15 s for 18.6 s (Claude, streaming reply).
  • A crash within one renewal of the turn's start can read "Worked for 0s". When the last renewal came before the turn started, the turn ends at its start, never before it: a lower bound, not the real work time.

Notes

  • SSH and remote hosts: a death is proven only on the execution host that owns the process. For an agent on another host the check cannot answer and writes no proof, so its turn stays unverifiable, per docs/reference/ssh-execution-boundary.md.
  • Windows: the same process checks; no platform-specific change.
  • Folder workspaces: not affected.

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)

Author X handle: @BrennanKB5

… wrote live

A revised item keeps its first sighting's timestamp, so a long command or a streamed reply read as ending when it started. The reducer now tracks the latest live row the same way it tracks all activity.
… crash reconciliation

A crashed host's working roster is retired when the journal reopens. That row was
written live, so a probe-proven turn ended at the relaunch and counted the downtime.
…proved the owner alive

A crash mid-tool left the turn ending at the tool call's start, because Claude writes nothing
while a Bash call runs. The death evidence now records the lease's last renewal before the
death as lastProvenAliveAt, and the turn ends at the later of that and the last live row,
capped at the probe.

Parking a lease in recovery no longer stamps lastRenewedAt, since nothing proved the owner
alive then; a child that outlived Orca would otherwise have its turn count the downtime.
…last proof of life, and the timing read goes through the display selector
…journal reducer

Main grew the reducer to its line limit, so the live-activity bound tipped it over. The dispatch
row and echo-acceptance folds move unchanged into their own module.
… a proven death interrupted

Main's open-time settle test still asserted the old rule, where only a watched exit proved a death.
…e turn

A start after a crash that spawned a child and then failed without proving it gone parks that
child for recovery; when recovery finds it gone, the proof of death carries its proof of life,
which is after the crash. The older turn then ended there and counted the downtime.

The journal now derives the fence of its newest live writer, and the lease that holds the proof
names the owner it released by the fence it moved to. The last renewal counts only when that
move was one step past the writer of the turn.
…t proof of life

The reopen settles that send at the new fence, so the owner check must read the fence of live rows only.
… wrote

The settle read the record's latest proof of death for whatever turn a gone generation left
running. After a crash, a start that reserved a new fence cleared the relaunch's proof, and if
it then failed, its own child's death (a watched exit at the failure, or a probe finding the
child it left for recovery gone) ended the older turn an hour after the crash.

Every proof of death now records ownerFence, the fence of the owner or reservation it is about;
a fence names exactly one owner. The journal derives the fence each item was created at, and a
running turn is interrupted only by a proof naming its own owner; otherwise it is unverifiable.
Evidence older builds wrote keeps their rule. This replaces the derived one-step fence check.
…released

A watched exit that names no owner reads the older rule, so the settle alone cannot tell a
dropped field; the writers are pinned directly, including past a recovery floor.
…rn-reads-interrupted

# Conflicts:
#	src/main/native-chat/agent-session-journal/journal-reducer.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts
…s the turn it left unverifiable

On desktop the chat on screen at relaunch opens before the startup reconcile has probed its owner,
so the open settles the cut-off turn unverifiable. When the reconcile then records the proof, it
re-runs the same settle for every open conversation, which revises that owner's unverifiable turns
to interrupted with the proof's end. Any later open re-runs it too, so a failed write converges.
Only upward, only for a proof that names the turn's own owner.
…e, then interrupted

Covers the reconcile revising an open chat to the last renewal (27 s) and a subscriber being sent
both states, a start after the crash whose running turn the queued revision leaves alone, a failed
revision write converging at the next open, a proof about another owner or from an older build
never revising, and a second settle writing nothing.
…h is written

The store tells its listeners, once committed, of each record a transaction gave a new proof of
death, so every writer (the startup reconcile, a recovery that stopped a child which outlived
Orca, a failed start, a watched exit) triggers the same serialized resettle for a chat already
open. The reconcile's own callback is gone. Quit stops listening first, and a queued resettle is
drained with the starts.
…the exit is recorded

Recording a watched exit now queues the same settle an open runs, so the turn converges without
waiting for the chat to be reopened. The reopen and read-after-restart cases now refuse that retry
too, so they still pin the open's own settle.
…e the in-place retry too

The exit's release now queues the same settle, so two tests named for the send's settle refuse that
retry as well; the comments that said nothing retries it now say what does.
…rn-reads-interrupted

# Conflicts:
#	src/main/native-chat/agent-session-journal/journal-reducer.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts
@brennanb2025
brennanb2025 marked this pull request as ready for review September 28, 2026 10:36
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Status: ready for review. Head 286b1b3f1e, based on main, CI green (23 pass, 0 fail).

Review: eight review loops, the last one clean. Design changes made along the way, each with tests that fail when the change is removed:

  • A crash-cut turn ends at the later of Orca's last proof that the agent was alive and its last output, never counting downtime.
  • Each proof of death names the agent run it describes, and a turn only takes a verdict from its own agent's proof.
  • A chat on screen before the startup check first reads "Couldn't confirm", then changes to "Worked for N" once the proof lands. That change fires from the one place proofs are written, so recovery and future writers trigger it too.
  • The explanation row is keyed by the death it explains, so retries never duplicate it.

Live QA (full app): Claude on a remote Mac and Codex on a second Mac, crash mid-turn then relaunch, compared with base. All scenarios pass; details and screenshots are in Visual Proof.

  • Claude mid-sleep 120: "Worked for 22s" for 26.6 s of work. Codex mid-silent sleep 120: "Worked for 26s" for 29.4 s. Base shows no duration or explanation.
  • An owner Orca can't check stays "Couldn't confirm". A normal reply, quit and relaunch re-settle nothing.
  • In these runs the proof landed before the chat first painted, so the brief "Couldn't confirm" step wasn't visible; host-level tests pin it.

Known limits:

@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

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

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: c6699a35-10b9-4b73-9cbc-05a6c41b781e

📥 Commits

Reviewing files that changed from the base of the PR and between 0b0beb5 and 728d3ec.

📒 Files selected for processing (2)
  • src/main/runtime/agent-session-record-store.ts
  • src/shared/agent-session-lease-adjudication.test.ts

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


📝 Walkthrough

Walkthrough

The change adds owner-fence and last-proven-alive data to session death evidence. Journal items retain their creation fence, and settlement uses matching evidence to determine turn state and completion time. The record store notifies listeners when death evidence changes, allowing conversation delivery to schedule resettlement for indexed open sessions. Tests cover evidence persistence and validation, delayed settlement, retries, and cross-version reads.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 728d3

Crash recovery now uses owner-matched death evidence to revise eligible turns. No concrete user-facing regression is established, so the change appears mergeable after normal checks.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 728d3

The change is limited to opt-in structured chat and does not show a new attacker entry point. A timing gap may leave an interrupted answer with an outdated status if ownership changes before queued recovery work runs.

Retained concerns

  • Low · reliability · inferred: If a new owner reservation clears committed death evidence before a queued resettlement reads the record, the prior owner's open turn may retain an unverifiable rather than interrupted verdict. Reachability of that ordering on production attach paths remains unconfirmed.
Security review details

Security Blast Radius

  • inferred — The inspected listener operates within the structured chat host and exposes session IDs to its internal consumer, not a direct network or agent-controlled callback registration path. The supplied intent places structured chat behind an off-by-default setting.

Trust Boundaries and Controls

  • observed — The production listener acts only for indexed sessions and schedules serialized resettlement; the settlement path uses recorded death evidence and per-item owner fences rather than treating every turn as belonging to one owner.

Resilience and Maintainability Implications

  • observed — Resettlement errors are reported and left for a later open or acquisition to rederive; the inspected production listener tracks its queued task rather than synchronously throwing from the notification.

Hardening Proposals

  • proposed — Preserve the committed owner-fenced proof until its corresponding journal settlement has consumed it, or pass that proof with the notification, so a later reservation cannot erase the evidence needed by queued recovery work.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.58% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 48 functions across 27 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the main change: proven native-chat crashes now settle a cut-short turn as interrupted. The wording is slightly awkward but remains specific and understandable.
Description check ✅ Passed The description is comprehensive and follows the repository template. It explains the user impact, mechanism, rationale, linked issues, visual proof, testing, compatibility considerations, known weakn…
  • 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 new issues found.

Reviewed changes

  • A proven crash now reads interrupted, not unverifiable. The verdict keys on whether the ownership record carries a proof of death that names the turn's owner (ownerFence) rather than on the proof's kind alone. A probe-proven death ends the turn at max(lastProvenAliveAt, journal.lastLiveActivityAt()), capped at the probe's observedAt, so the duration never counts Orca's downtime. Older-build evidence (no ownerFence) keeps main's rule.
  • Two new optional record fields. ownerFence and lastProvenAliveAt on AgentSessionDeathEvidence, validated (lastProvenAliveAt <= observedAt, ownerFence >= 0) and set by every proof writer. Never sent over the wire; a v1.4.211 downgrade test confirms an older store ignores them.
  • Journal tracks what the provider actually wrote. lastLiveActivityAt excludes recovered rows (crash reconciliation is not the provider working), and itemFences records the generation that created each item so a turn is judged only by its own owner's proof.
  • An open chat is revised when the proof arrives. AgentSessionRecordStore.onDeathEvidence fires post-commit for each record given a new proof; the delivery layer queues a serialized resettleOpenStructuredAgentSessionConversation for open chats and stops listening on dispose. provenUnverifiableTurnRevisions only moves unverifiable → interrupted, and the explanation row is keyed by the death it explains so retries add one row.
  • A lease parked in recovery no longer claims the agent was alive. Restart and failed-acquisition parking keep lastRenewedAt and move updatedAt instead.

Tests read as genuine coverage: I ran the new structured-agent-session-crash-turn-end.test.ts (9 pass) and the five edited test files (64 pass). I also independently probed the store-notification path (every deathEvidence write is one-shot and reference-stable on unrelated writes; all transact callbacks are synchronous) and the turn-end arithmetic (completedAt >= startedAt holds because a judged turn's creating row is always live; the lastSeen > 0 guard prevents a zero end).

ℹ️ The turn end leans on a journal-global live-activity bound

turnVerdictFromDeathEvidence uses journal.lastLiveActivityAt(), a maximum over every live row in the journal, not just the judged owner's rows. This is safe today only because the host's ordering guarantees no other owner's live rows exist when a turn is judged, but the bound's name suggests a scoping the code does not enforce, so a future change that judges a turn later in the lifecycle could silently inherit a newer owner's activity. No change requested — flagging the invariant so it survives refactors.

Technical details
# Title
`lastLiveActivityAt()` is journal-global; the verdict assumes single-owner journals at judge time

## Affected sites
- `src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts:53` — `Math.max(evidence.lastProvenAliveAt ?? 0, journal.lastLiveActivityAt())`
- `src/main/native-chat/agent-session-journal/journal-reducer.ts:85` — updates the journal-wide max, with no fence parameter
- `src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts:353` — "never touches the turn a start after the crash is writing" pins the current ordering: `turn-1` ends at `LAST_RENEWED_AT` although `turn-2`'s live row is present later in the test

## Required outcome
None currently — this is informational. The invariant to preserve: when `settleStaleStructuredAgentSessionState` judges a turn, the only live rows in the journal belong to the turn's own owner (guaranteed because a reservation clears `deathEvidence`, and the settle runs before a new child's buffered events land).

## Suggested approach (optional)
If the global bound is ever loosened, scope the live-activity read to the judged owner's fence (the journal already tracks `itemFences`) rather than changing call order.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…never at a later send

A send accepted into a crashed chat before the proof of death wrote a submission row live, and the journal-wide last-live-row bound counted it, so the revised turn ended at the send and counted Orca's downtime. The bound is now the last row the owner's provider child wrote, per writer fence: submissions, dispatch rows and crash reconciliation are Orca's or the user's, and a newer owner's work says nothing about the dead one.
… at a timeline row

The end of a probe-proven death was the later of the last renewal and the last live timeline row. A send accepted into a crashed chat before the proof writes a row live, so the revised turn ended at the send and counted Orca's downtime. Rows cannot tell the agent's output from Orca's or the user's, so the end is now the last renewal alone, never after the probe, and never before the turn began. The journal's live-activity bound and the reopen's recovered marker, which existed only for it, are gone.

@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 new issues found.

Reviewed changes

Reviewed the single commit since the prior Pullfrog review (286b1b3 → e5cf5a0), which re-scopes where a crash-cut turn gets its end time.

  • Owner-scoped provider activity. The journal-global lastLiveActivityAt is gone; the reducer now keeps providerActivityAt: Map<fence, ts> and the verdict reads journal.lastProviderActivityAt(evidence.ownerFence). This closes the prior review's informational note directly — a newer owner's live rows can no longer inflate the judged turn's end.
  • Only the provider child's own output counts. New isLiveProviderOutput classifies rows — item/tombstone/lifecycle-batch count when live, submission/dispatch/epoch never do. A send accepted into a crashed chat before the proof no longer ends the turn at the send.
  • Two regression tests pin both paths (structured-agent-session-stale-turn-verdict.test.ts): a send accepted before the proof, and a newer owner's work. Each goes red on the old global bound.

I confirmed the classifier is exhaustive over all six JournalRow kinds, that every Orca settlement batch writes recovered: true (so Orca's own post-crash writes cannot count as provider activity), and that the two changed test files pass (62 tests).

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@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 new issues found.

Reviewed changes

Reviewed the single new commit ed7cce41 (since e5cf5a0), which replaces the per-fence provider-activity bound with a renewal-only end rule.

  • Timeline rows no longer bound the turn end. providerActivityAt, isLiveProviderOutput, lastProviderActivityAt and lastLiveActivityAt are all removed, and turnVerdictFromDeathEvidence no longer takes the journal. A probe-proven death now ends at min(lastProvenAliveAt ?? observedAt, observedAt). This sidesteps the earlier revision's weakness — a timeline row does not say who wrote it, so a send accepted into the crashed chat could end the turn at the send.
  • A floor at the turn's start. settledLifecycle clamps completedAt up to max(requestedAt ?? 0, startedAt ?? 0), so a renewal that predates the turn cannot end it before it began.
  • journal-store-restore.ts revert. The open/restore recovered: true stamp is gone, consistent with no reader of that flag remaining for this feature.

I verified the stated invariant holds: the clamp cannot push the end past the probe, because requestedAt is a host-clock value (the submission row's ts) and so began <= observedAt. No references to the removed symbols remain, git diff origin/main...HEAD -- journal-store-restore.ts is empty (net-zero vs main), and the changed tests pass — stale-turn-verdict (20) + journal-reducer (40), plus crash-turn-end (9), send-open-stale-turn (5), stale-turn-open (3), and wedged-profile-migration (19).

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[High risk] Rewrites how agent session crashes are settled and tracked.

The PR is not yet safe to merge because a proof committed during conversation opening can leave the visible turn unverifiable.

Findings

  1. P1 Death proof missed during opening ▶

Summary

The PR uses owner-specific proof of death to settle crash-cut structured-chat turns as interrupted, records their last proven-alive time for the displayed duration, and revises an open chat when proof arrives later.

  • Adds persisted death-evidence fields, journal fence tracking, and regression tests for restart and recovery paths.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Open journal] --> B[Settle running turn provisionally]
  C[Death proof commits] --> D{Session indexed?}
  D -- Yes --> E[Queue resettle]
  D -- No --> F[No resettle queued]
  B --> G[Index session]
  F --> G
Loading

Reviews (2) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..."

Comment on lines +84 to +85
const stopResettling = deps.store.onDeathEvidence((sessionId) => {
if (sessions.has(sessionId)) {

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 Death proof missed during opening When a chat starts opening before restart reconciliation, it reads the record before opening its journal and registering in sessions. If proof of death commits during that opening, this callback sees no registered session and queues no resettle. The open can then use its earlier record to mark the turn unverifiable, leaving the on-screen chat that way until it is reopened instead of changing it to interrupted when the proof arrives.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Status: ready for review. Head 728d3ec9bc2, based on main, CI green (34 pass, 0 fail), mergeable. This replaces the earlier status comment.

Since the last status comment:

  • The end of a crash-cut turn is now only the last time Orca proved the agent alive, capped at the proof of death and never before the turn started. The earlier rule also took the agent's last timeline row, but a row doesn't say who wrote it, so a message sent before the proof of death stretched the turn over the downtime. A readiness check reproduced that ("Worked for 1h"), and it can't happen now.
  • Main merged in twice, with only this PR's changes remaining each time.

Review: eight review loops plus a confirming pass on the last change, all clean at the end. Every design change has tests that fail when the change is removed.

Readiness check: passed with no findings at this head. It ran twice, at 286b1b3f1e and at the final head, but not at the very start of the review; that's disclosed here. A remaining, unproven risk: if another session's startup check records this chat's proof of death in the moment between a send reading the chat and settling it, the chat keeps "Couldn't confirm" (main's current behaviour) until the next send or reopen.

Live QA at this head (a remote Mac with the real Claude CLI; details and screenshots in Visual Proof):

  • A crash 28 s into sleep 120, with the chat on screen: the first frame already reads "Worked for 18s", with one "Claude stopped…" row and no process-id text. Base shows no duration or explanation.
  • An owner this host can't check stays "Couldn't confirm". A normal reply, quit and relaunch re-settle nothing.
  • The earlier Codex run (real Codex CLI): "Worked for 26s" for 29.4 s of work.

Known limits:

@brennanb2025
brennanb2025 merged commit 2dc2693 into main Sep 29, 2026
46 of 47 checks passed
brennanb2025 added a commit that referenced this pull request Sep 29, 2026
Resolves the crash-boundary settle against main's proven-death revision (#23456).

Main now writes the "stopped while this response was in progress" row when a proof of death
arrives for a chat already open, revising a turn the open could only call unverifiable. This
branch's open already wrote the restart row for that turn, so a plain merge showed both rows
whenever the chat opened before the startup reconcile probed its owner.

The settle now picks one row per boundary: the exit's when a proof names the owner that was
mid-response, else at a crash boundary the restart's. A later proof revises the turn and adds no
row when the boundary's row already follows it; a turn some other release left unverifiable
still gets the exit's row. The restart row is named by its boundary, like the exit's by its
death, so a retried settle cannot add a second. The row choice moves to its own module to keep
the settlement file within its line limit.
brennanb2025 added a commit that referenced this pull request Sep 29, 2026
…3614) into #23059

Conflicts, resolved to keep main's mechanism and #23059's turn facts:
- Codex turn boundaries and translator: main's #23682 wins; the error-driven
  `fail` end and the oversized-notification settler are gone, and only
  `turn/completed` ends a turn, a command's turn included.
- Codex adapter: the compaction tracker stays deleted; #23618's settlement of
  sends in an ended turn is kept, and `codexDispatchRejection` is imported from
  its new home.
- Journal reducer: main's per-item creating fence (#23456) moves into the item
  and submission folds this branch split out of the reducer.
- Stale-turn verdict: main's proof-of-death revision; every turn revision states
  the thread scope.
- Failure words: main's retry-control rules (#23608) with this branch's any-command
  retry step; `commandRefused` drops its retry step beside a Retry, as hostFault does.
- Delivery: both the commit wake and main's death-evidence resettle and dispose.
- Fold row and transcript slots: both the turn-outcome flag and main's `agentId`.
- pr.yml and pnpm-lock.yaml taken from main.

Beyond the conflicts, the merged files needed:
- use-structured-agent-session: both sides added the same `submissions` key; one kept.
- structured-agent-session-projection: the root-item import main still used is unused here.
- The desktop message list and the mobile chat view each grew past the line limit
  from both sides' additions: the turn-membership memo moves into its own hook, and
  the mobile list footer (live status, then waiting rows) into its own helper.
brennanb2025 added a commit that referenced this pull request Sep 29, 2026
…a dead /compact settles on its recorded exit

Two tests the main merge brought together:
- The crash-turn test from #23456 writes a turn record through the event sink
  without options; every row here states its turn scope, and a turn record's is
  the thread.
- The /compact whose exit settlement could not be written no longer stays running
  until the next start: main now settles an open chat from the exit it recorded, so
  the command reads interrupted before the next message, which is then delivered.
brennanb2025 added a commit that referenced this pull request Oct 1, 2026
…23916 on main) (#24301)

* refactor(native-chat): a stopped child ends on the one reading of its stop

The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that
verdict to the child's ending, so the host never forms a second view of whether the root is gone.
Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition,
and a failed re-attach passes what its release saw. The end-of-child record can therefore also
carry a stop whose root was not seen to go, which nothing ends on yet.

* feat(native-chat): the host says it accepts a send before any agent has it

The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same
string capable clients already send. A client can then tell a host that answers a send at
acceptance, and admits a Stop with no writer before a turn starts, from an older one that still
restarts the agent inside the send. Additive: an older client ignores a capability it does not
know.

* refactor(native-chat): an attach never opens a journal of its own

The attach adopts the conversation's open journal, which outlives it, so it no longer opens one
for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag
that told the two cases apart is gone. Tests that attach without a host open the conversation the
way a host does.

* fix(native-chat): a moved fence resends nothing on a host that accepts first

The outbox treated any fence change as a new owner: it dropped the answer of a send in flight,
queued that send to go out again under the same id, and unblocked a refused head. On an older
host that is how a send the restart refused, unrecorded, gets another try. On a host that records
every send before it starts an agent, a fence moves because that start ran, so the same rule
resent into every failed start. With a fence stamped on every frame, that became a loop.

The outbox now reacts to a fence change only when the host has not advertised that it accepts a
send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed
start reaches the client as a rejected message it keeps with its Retry. Against an older host, or
before one has answered, the outbox behaves as it did. Desktop and paired web share this hook.

* refactor(native-chat): a child's end says whether the user or the host stopped it

The end-of-child record's cause now tells a user's Stop from the host stopping the child for a
cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a
user's Stop, as before, and fails the start it was waiting on after a host stop, with the one
error row and every queued message rejected, in the stop's reason when it gave one. The reason
stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet.

* fix(native-chat): a chat whose only work is a queued message is not offered for resume

A message accepted while the agent was starting counts as working in the chat, and quit rejects it
as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a
chat whose agent never had the message. The snapshot now reads only what was handed over.

* fix(native-chat): the conversation outlives its agent

Opening a chat no longer starts its agent. A conversation is reached through one host
accessor that opens its journal at rest, and a send is what starts the agent, through
the delivery loop. One idle sweep, every five minutes, stops an agent that has been
quiet for thirty minutes and owes no work, then drops an open journal handle that is
only a cache. Its record, tab, status row and readers stay.

- hold and release are no-ops; hold still builds the host for shipped mobile builds.
- The holders, the holds, the release clock and the exit respawn are deleted.
- Options, the model list, the goal and the context meter answer at rest; a model pick
  at rest is recorded as intent for the next start.
- Compact, rewind, clear and goal changes start the agent first. A send does too when
  a rewind is still in doubt after the conversation opens.
- Orchestration routes mail and group addresses on ownership (the record plus the chat
  tab), not on whether the process runs. An open dispatch keeps its worker running.
- The restart continuation is a send; Resume all holds each slot until the message is
  handed over or rejected.
- A read error never replaces a loaded transcript, and shows the host's own words.

* test(native-chat): type the queued-message fixtures in the resume-offer tests

* fix(native-chat): a start that dies while a message waits on it is that message's failed start

Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When
that start died, its exit wrote the start's error row and left the message queued, so the delivery
loop started a second agent into the same failure and wrote a second row. A child's end now records
where the conversation's journal stood, and the loop settles a message accepted before a failed
start ended with that start: one row, under its key, and no second start. A message sent after the
failure still gets a fresh start.

* fix(native-chat): a request that failed reads as failed

A structured chat whose only message the agent's start refused read as a
green finish, and a cancelled structured turn did too: the host published a
verdict only for turn records, and structured rows carried no `interrupted`.

The host projection now reads the session's latest request: its turn's
outcome, or `failure` for a send the agent or its start refused. A send
that was withdrawn, or left undelivered by a restart or a close, fails
nobody and makes nothing listable. The ingest publishes `interrupted` as the
hook lanes do, and every reader decodes the verdict through one accessor, so
a failure reads Failed on the dot, the rollups, history and `worktree ps`,
behaves like a cancellation in every clean-finish policy, and notifies as
"failed".

* docs(native-chat): say what an attach's open conversation and unconfirmed ids are now

* test(native-chat): a verdict change republishes the mobile status projection

* refactor(native-chat): the store's retention trigger keeps its flag compare

A verdict change always moves the completion clock the same check already
reads, so a second verdict compare there caught nothing new.

* test(native-chat): a user message the provider journaled keeps its session listed

* test(native-chat): pin what a failed start settles, and what a resume offer names

A view's child that dies while a sent message waits settles that message only when it died starting
and no child has taken its place: a proven child's crash, or a second start since, gets the message
delivered. The resume offer names the handed-over message, never a newer one still queued.

* test(native-chat): the failed-start pins fail on what the message became, not on a timeout

* fix(native-chat): a restart offer ends when the chat's agent starts again

The offer used to end only when the chat's newest user message changed,
because opening a chat started its agent and that start could not be told
apart from real activity. Opening a chat starts nothing now, so the host
reads the fact it already publishes: a chat's status row goes from not
host-owned to host-owned exactly when its agent is started. At that edge the
offer and any failure record for the chat are withdrawn, unless the start is
a resume action's own (its continuation is the oldest undelivered message).

A continuation and a message racing to be first are decided at acceptance:
the continuation is refused, quietly and with nothing filed, when any other
message was accepted since the restart. A failed continuation start leaves
the offer retryable, and each resume action sends its own message id.

Deleted: the newest-user-message comparison, its journal reader, the
continuation filter, and the failure ledger's own "answered by the chat"
check. The marker still carries its message id for one release, so the
previous build can read it.

* fix(runtime): end a transcript stream when its client unsubscribes

Desktop: the IPC subscription controller was dropped as soon as the streaming
handler returned, which for most streams is right after it binds. A later
runtime:unsubscribe then found nothing to abort, so the host kept the subscriber
and derived and sent every publish to a channel no one listened to. The controller
now lives until the renderer unsubscribes, resubscribes the same id, or goes away.

Mobile: disposing an agentSession.subscribe stream now sends agentSession.unsubscribe
with the stream's frame id, so the host ends that subscriber and leaves a sibling
stream on the same socket running. The direct path now passes the frame id the relay
path already passed.

* fix(native-chat): a late provider-session update keeps a failed recovery record failed

A provider-session heartbeat that rewrites a completed recovery record kept
its interrupted flag but dropped the outcome it was copied with, so a live
failed checkpoint read as a clean finish until the next status write.

* test(orchestration): the preamble's host stub is typed, not cast

The preamble send now takes only what it reads of the host, the send, the settlement wait and the
record's fence, so its test builds that host with real types instead of `as never`.

* test(native-chat): the terminal-bell check asserts the renamed verdict field

The bell notification test still checked for agentInterrupted, which no
longer exists, so it could not catch a verdict leaking into a bell dispatch.

* fix(native-chat): a failed turn ranks like a completion for attention

Attention readers (completion time, Smart Sort, sticky retention, Cmd+J
Recent) now demote only a turn the user stopped. A failure is news the
user has not seen, so it keeps its completion time, ranks in the Done
class, stays retained after its pane goes away, and a retained failure
reads failed in the worktree rollup instead of done. Clean-finish
policy (hibernation, pane ownership, the value moment) still treats a
failure like a stop.

The retention trigger compares verdicts again: success -> failure no
longer moves the completion clock.

* fix(native-chat): one fact ends a restart offer: the chat moved on since the restart

The offer is live while no other message has been accepted in the chat since the
restart and its agent has not proved a start since. The offer list, the resume's
reservation check and the continuation's acceptance check all read that one fact,
so a message whose start then failed withdraws the offer too, and a stale click
finds nothing to act on.

The fact is read off the conversation's open handle, which the restart closed, so
it is retired durably whenever it may have changed: a message accepted, a start
proven. A close and reopen within the same run therefore cannot bring the offer
back. A continuation rejected before it reached the agent does not count, so a
retry after a failed start still runs.

Deleted: the quit-time gate on withdrawal, which changed nothing because the
withdrawal and the quit's own offer write share one queue; the per-action
"withdrawn" flag and the separate acceptance check it paired with.

* test(native-chat): an older build reads the restart offer this build records

The offer lives in a file the previous release reads after a downgrade. Pin that
against the pinned release's own capsule, and run the lane when the marker or the
capsule changes.

* fix(native-chat): read a restart offer against where the journal stood when it was taken

"Since the restart" was read off the conversation's open handle, which the idle
sweep closes: after a reopen, a message the user had already sent looked older
than the handle and the withdrawn offer came back.

The offer now records the journal position (epoch and sequence) at the moment
it is taken, and a message accepted after that position, or a journal on another
epoch, means the chat moved on. That is derived from the journal, so it holds
across any number of closes and reopens. An older build's offer has no position;
only a start withdraws it. Because the message half is now durable, the offer is
no longer rewritten in the recovery file on every accepted message; a proven
start still writes it, since only the host that saw the start knows of it.

* test(native-chat): wait for the listing's retire write before reading the recovery file

* refactor(native-chat): every journal row states which turn it belongs to

Rows gain a turn scope stated by the write that creates them: the open root
turn, or the conversation. A queued message takes its scope from its handover.
Rows stored before scopes existed are placed on replay by the root turn open
when they were created, so no persisted state is needed for them. Rewind keeps
each retained row's scope and producer, so a subagent's row stays its own.

* fix(native-chat): keep the terminal-backed chat's read error over its local echoes

Messages winning over a read error is right for the structured chat, whose read retries and whose
messages came from the transcript. The terminal-backed view assembles its list from local echoes
too (a launch prompt, a pending send), so a failed read there showed only those bubbles and no
error. Only the structured pane now keeps messages over an error.

* fix(native-chat): a start retries the exit settlement a failed journal write left owed

An agent exit whose journal settlement write failed releases the lease latched until a retry lands.
Reopening the chat used to be that retry; with reveal now only opening the journal, nothing retried
it before the next app launch, and every send was refused. The start the send needs now runs the
retry first, where the attach would.

* fix(native-chat): a failed main agent reads failed while its subagents still work

The verdict is now read from the main agent's own state, not the folded
row: a main agent that is done and failed has a verdict even while its
subagents keep the row working. Without mainAgent (history, worktree ps,
older hosts) the old combined-done rule stands.

Display marks the verdict through agentVerdictDisplayMark: a failure
outranks every combined state on the agent's dot, label, tab badge,
dashboard and activity rows; a stop marks only a done row, so a
successful or stopped main agent with live subagents still reads
working. Subagent rows keep their own state. The worktree card, terminal
tab and Cmd+J rollups share one pane fold and rank a pending question,
then failed, then working, monitoring, interrupted and done.

worktree ps publishes the main agent's outcome on a working row, and the
mobile mirror reads it. The store's change check, the paired-client
mirror's equality and its epoch now see a verdict change on a working
row, which otherwise moves no state or clock and left the worktree card
reading working. Clean-finish policy is unchanged: a working row is never
hibernated and has no completion time.

* perf(native-chat): answer the owner check without opening the chat

Worktree activation calls agentSession.handoffStatus for every chat tab in the worktree, and the
answer comes from the session record alone. Reaching it through the accessor opened each resting
chat's journal (a full read, the crash-boundary write and a restored status publish), then kept it
open for the idle window. It now checks the record and the adapter's support, as before this series,
and opens nothing.

* fix(native-chat): a read waiting on the session lock opens nothing once quit began

The accessor checked for quit before queueing the open, so a read queued behind a session task ran
its open after teardown had begun and indexed a journal no teardown step would close. The check now
runs at the open itself.

* test(native-chat): pin stated turn scopes, the upcast of unscoped rows, and rewind attribution

* fix(native-chat): /compact is a message the chat sends, run as a turn of its own

The conversation command RPC now accepts /compact into the queue like any
send and answers once it is handed over. The delivery loop opens the command's
own turn, starts the provider on it, and waits for the provider's end off the
session's queue, so messages typed meanwhile are held and delivered after it,
even when it fails. It settles by re-reading the journal: a child that died
meanwhile already wrote the verdict. Stop ends the command at once. The 180 s
completion window, the unconfirmed row and the recovery of an older build's
compaction record are gone; that record no longer gates anything. On Codex the
provider turn the command opens is claimed into the command's turn.

* fix(native-chat): read a failed resume's chat before calling it retryable

Whether a failed resume is retryable is the offer's own rule: the chat has not moved on since the
restart, read from its journal. The failure list read it only for a chat already open, so once the
idle sweep closed a chat the user had moved on in, its failure showed Retry again, and the click did
nothing. The list now opens the failed chats first, as the offer list does.

* test(native-chat): type the provider event sink the settlement test reaches for

* docs(native-chat): the worktree ps outcome comment no longer claims old hosts send it

The field is new: an old host sends no outcome at all, so a reader falls
back to interrupted. The removed clause said old hosts send it on done
rows, which never shipped.

* fix(native-chat): say the structured read keeps trying only where it does

The structured pane's "Orca keeps trying to load it" line never showed: the view state filled in an
untranslated fallback whenever the read error had no text, and the empty state prefers any message.
The view state now leaves the message out, so the structured pane shows that line and the
terminal-backed pane its own translated one. Mobile's structured lane does not resubscribe after an
error frame, so it no longer makes the claim.

* fix(native-chat): rows group under the turn their record names, not the one above them

Each row's turn is the turn its stated scope names, anchored on the entry
that opened it, or on the turn itself when the provider opened it unasked.
So /compact groups its own rows and the previous turn is untouched, a message
typed into a running turn joins it, and a provider-resumed turn folds under
its own Worked-for. A row reporting how a turn ended, an error or the
compaction separator, never folds. Desktop and mobile read the same keys; a
host that states no scope keeps today's positional grouping.

* test(native-chat): await the send's settlement instead of polling for the start

The at-rest send tests polled for the provider start with vi.waitFor's one-second default, which a
loaded machine outran. They now await the host's own settlement of the message.

* docs(native-chat): the status-store listing rule names provider-journaled user messages

* fix(native-chat): a restart offer resumes any time after the quit, and knows its own continuations

The continuation's message id was dated by the quit, and the ledger refuses a new id dated more than
a day back, so Resume or Retry a day after quitting was always refused (on main too). It is now
dated by the resume action.

Telling a rejected continuation from the user's own message read the operation ledger, whose rows
expire after about a day; after that a failed resume stopped being retryable. The offer now
records the continuation each action sends on its own capsule entry, bounded to the newest 16, so
the ids end with the offer. The ledger read is deleted.

* fix(native-chat): a /compact is not a request the sidebar, notifications or restart resume report

The sidebar's prompt, preview, verdict and instant, the turn-completion feed,
and the restart-resume marker read past a conversation command and its turn to
the last real request, so a /compact neither notifies nor re-dates the row,
and a command in flight is never offered as work to resume. An older client
shown a command's turn in the legacy form names the session's own agent.

* fix(orchestration): route no mail to a structured worker its orchestration released

A structured worker is routed on ownership, and a resting worker's lease is released, so ownership
held while its chat tab stayed listed. A worker the coordinator abandoned and then released, found
at rest by the release, therefore still took peer mail and @worktree: broadcasts, and each one
restarted its agent. Routing now also reads the orchestration's own resource row: once it is
released, direct mail, group addressing and worker-show's addressable answer drop the worker, as
they would a terminal worker whose terminal closed. The chat tab stays, and nothing new is stored.

* fix(native-chat): a failed retry names the user's prompt, not Orca's continuation

A resume's continuation is written to the chat before its start, so after a failed attempt the chat's
newest user message is that rejected continuation. A second failure then showed Orca's own restart
text as the chat's prompt. A retry now keeps the prompt its first failure named.

* test(native-chat): pin what a conversation command's admission refuses at rest and at handover

* test(native-chat): tests merged from the base state which turn their rows belong to

* fix(native-chat): a refused send notifies failed through the completion feed

The host's completion feed followed only the newest turn, so a send the
agent or its start refused, which creates no turn, read Failed on its row
but sent no notification. The feed now follows the session's latest
request, read from the projection the status feed already makes for the
commit: a turn keeps its id, a refused send is named by its journal item
key. It announces only while the session is idle, as the row reports a
verdict, so queued sends refused one commit at a time notify once, and a
withdrawn send falls back to a request already announced.

* fix(orchestration): read the released row optionally, as the authority does

worker-show's observation called the row lookup directly, which a runtime double without it threw on
and failed the structured tab-retirement release.

* chore(native-chat): one import per module and no unexplained casts in the turn-scope changes

* test(claude): pin which turn a Claude row joins, including a subagent's after the turn ends

* fix(native-chat): the status bar drops a restart offer the chat moved on from

The renderer re-read the host's restart offer only when a failed chat showed activity, so after a
message withdrew a pending offer the host answered no chats while the status bar kept counting one,
and clicking it opened nothing. The same watch now covers pending offers: a status change in an
offered chat asks the host again, once.

* fix(native-chat): a refused steer is read from the turn its handover named

The latest-request reader decided whether a refused send had joined a running turn by comparing
host clocks: its handover time against the previous turn's end. The handover row now states the
turn it delivered into, so the reader reads that instead and the clock comparison goes. A journal
written before handover rows stated a turn is scoped on replay from the turn open when each row
was written, which can differ from the clock reading only when a send and a turn's end share a
millisecond.

* fix(mobile): the native-chat controller contract carries the turn journal

The controller and overlay already pass nativeChatTurnJournal, but the
contract type never declared it, so mobile failed to typecheck.

* fix(native-chat): the live turn is the running turn, not the newest user row

A turn the provider opened on its own (a background wake, a resumed turn)
anchors on its own record, but the list still treated the newest user row
as the live turn. While such a turn ran, the settled user turn before it
lost its duration and the running turn's own rows were drawn as settled,
so its tool calls lost their live state.

nativeChatTurnMembership now answers both questions from the turn record:
each row's turn, and the live turn (the running root turn's anchor, else
the newest user row, which is also all an unscoped host has). Desktop and
mobile key liveness, the timing clock and the live status's row on it.

* test(native-chat): a turn the provider opened keeps its own clock

Pins that the local turn clock follows the live turn, so a wake after a
settled turn does not restart that turn's clock when no host durations
are recorded.

* fix(native-chat): a running turn no message opened draws its status on no row

Its live status belongs to the transcript-tail indicator alone. Once it
settles, its duration draws at its first row as before; a running turn a
message opened still draws on that message.

* fix(native-chat): every copy of a row carries the main agent's own status

History entries, sleep records and `worktree ps` rows carried a flattened
top-level `outcome`, copied under different gates and without the main agent's
clock. They now carry `mainAgent` (state, outcome, stateStartedAt), the type
the live row already persists and sends, and every copy site takes it with
`interrupted` through one function, `agentVerdictFields`.

- The accessor reads `mainAgent` then the legacy flag; the mobile mirror
  matches it line for line.
- Sleep records admit `mainAgent` with `normalizeMainAgentStatusField`, so a
  malformed value drops the field, never the record.
- Mobile dates a main agent that failed under live subagents by its own clock,
  as desktop does, and its row equality compares `mainAgent`.
- The activity feed reads a history entry's own `mainAgent` instead of
  rebuilding one; the sync key and history equality compare it.

* test(native-chat): pin the worktree ps verdict across host and phone versions

Pairs the real v1.4.212 host and phone row reader with this build: an old phone
reads a new host's rows by `interrupted`, a new phone reads an old host's rows
(no `mainAgent`) the same way, and a new phone reads a failure under live
subagents as Failed, dated by `mainAgent.stateStartedAt`. The release checkout
now carries the phone's self-contained row reader, and the lane runs when the
`worktree ps` row producers change.

* test(mobile): name the parity table's row for its role

* test(native-chat): a roster of idle or finished children does not keep an agent awake

The sweep reads owed background work through the shared child-work liveness that upstream's
release clock adopted; a child that went idle or finished is not work the agent still owes.

* fix(native-chat): a request that settles while the user is asked something notifies once

The completion edge waited for an idle session, and a pending prompt (including a
subagent's approval) is not idle. Structured chat has no other attention producer,
so a main turn that finished while a subagent waited on the user sent nothing
until the prompt was answered.

The edge now waits only on owed work (a running turn or an unanswered send), which
the projection reports even beneath a pending prompt. A request that settles with
a prompt pending announces once; the renderer words it "needs input" from the
host status mirror's `attention`, and answering the prompt keeps the same request
identity, so it does not announce again. The wire shape is unchanged.

* fix(orchestration): a task dispatched into a resting structured worker keeps it running

The sweep's open-dispatch check read only the worker-start dispatch that owns the worker's terminal
resource, so a task later dispatched to the same worker (orchestration dispatch --to, which writes a
dispatch with no worker row) did not count: after thirty quiet minutes the worker was stopped while
that task was open, and its coordinator read exited. Any unsettled dispatch addressed to the worker's
process incarnation now counts, derived from the existing rows.

* fix(native-chat): a command's wait ends when its child does

The delivery loop waited for a /compact only on the adapter's compaction
tracker, which learns of the child's end only on some exit paths: a Codex
exit or close, and a Claude close, never reach it. The wait then never
ended, so nothing queued behind the command was delivered again, Stop had
no child to answer through, and the tracker's leftover entry refused the
next /compact.

Every way a child ends passes endProviderChild, so the host now offers a
per-child end signal there. The loop races the tracker against it (the
dead-generation settlement has already written the command's verdict),
and on that end asks every adapter to release the command, so a later
command runs and no later provider turn is claimed into the dead one.
The adapters' own exit-time releases were unreachable (Codex) or covered
one path of several (Claude), and are removed.

The Codex RPC test harness moves to its own module so the exit can be
driven through the real adapter's connection callback.

* fix(native-chat): keep refusing sends during a command on an older host

An older host's controller still refuses a send while a conversation
command runs, so dropping the client's block turned every message typed
during /compact into a 'not sent' row with Retry there. The block stays
for hosts that do not run the command as a send-path turn, and goes only
for those that do.

The signal is one the client already holds: a host that runs /compact on
the send path states a turn scope on every journal row it writes, the
same fact turn membership uses to tell it from an older host. Both now
read it from one predicate. On an empty conversation, or one whose rows
all predate the upgrade, the signal is absent until the command's own
entry streams in, so that brief window keeps the old local refusal; no
capability or wire field is added.

* docs(native-chat): comments stop describing the hold this PR removed

Eight comments still justified orderings and teardown choices by a viewer or dispatch hold that
pinned the provider child. Nothing holds any more; the orderings stand for the binding's redrive
subscription and parked mail, and a chat's agent runs from a send until the idle sweep rests it.
Comment-only.

* fix(native-chat): the completion says when the user is being asked

A request that settles while a prompt waits on the user was worded "needs input"
from the renderer's status-feed mirror. Remote clients receive the status and
completion streams over separate sockets, so they can arrive in either order and
the wording could be wrong both ways.

The host already knows at emit time, so the completion now carries an optional
`awaitingUser: true` in that case and omits it otherwise. The renderer words the
notification from that field alone and no longer reads the status mirror. Old
clients ignore the field and word by outcome; old hosts never send it.

* fix(native-chat): a restart offer keeps the start its own continuation made

Whose start ended an offer was decided at read time, from whether the offer's continuation was
still the queued message. Once the provider refused that continuation, the child it had started
read as someone else's start, so the offer ended and its failure showed no Retry. The delivery
loop now records which queued message a start is for on the in-memory child, and the child's end
carries it; the offer counts a start as its own when that message is one of its continuations.

* fix(native-chat): a rewound turn still names the message that opened it

A Codex rewind rebuilds the epoch without submissions, so each sent message survives only under
its provider key. The kept turn records still named the submission key, so each turn anchored on
itself and its rows grouped apart from the message that opened it. The rewind now renames the
turn's opener along with the message.

* fix(native-chat): Stop ends only the command it names

Stop on a command turn abandoned whatever compaction the session had pending, so a late Stop for
an earlier /compact cancelled the one running now. The tracker now ends a command only when the
Stop names its turn, and the cancel reply reports whether it did.

* fix(native-chat): an agent gets a full idle window after its owed work ends

The sweep measured quiet only from the last journal row, so once a subagent, command, monitor or
dispatch that had outlived the window ended, the agent was stopped at the next tick. A child can
read done before the lead's wake-up turn writes anything, and stopping in that gap loses the
wake-up. The sweep now counts owed work it observes as activity, which gives the agent the full
window afterwards, as the release clock it replaced did.

* test(claude): the options-read fixture runs a live child

The fixture marked its conversation running with a hasProviderChild field the
session type does not have, so the read took the at-rest path and refused a
session with no record. It now carries a child, which is what the read checks.

* test(native-chat): host tests reach its collaborators through a typed seam

The rest-test rig and three test files read the host's private members with
Reflect.get and cast the result. The host now exposes one test-only accessor,
collaboratorsForTests(), and the subscribers class a subscriberCountForTests()
beside its existing retainedActivityCountForTests(), so the tests are checked
against the real types and the casts are gone.

* fix(worktree-status): a departed agent's failure yields to live work on the worktree card

A retained failed agent has no expiry, so ranking it with a live failure pinned the card to Failed over other panes' live work. It now ranks below working, monitoring and permission, and above every finished outcome.

* refactor(orchestration): one owner answers a structured worker's custody

Routing, group addressing, worker-show and the idle sweep each composed their own reading of
whether orchestration still holds a structured worker, so each new obligation or retirement state
had to be added to every reader. structured-worker-custody now derives both answers from the
worker-terminal list state coordinators see in worker-list: addressable is owned and not released,
and owed work is an active custody or an unsettled task dispatched to the same incarnation. The
owner's state is read through the remote dispatch attachment too, as the terminal transfer lookup
already does. Behaviour is unchanged; a settled worker awaiting its coordinator still rests.

* refactor(orchestration): owed work is an open dispatch on the worker's incarnation

A supervised worker's own dispatch context stays open exactly while the worker is active, so the
separate active-custody branch only repeated it. Owed work is now one fact, which also states the
policy that a worker awaiting its coordinator's decision may rest, and both custody decisions are
written once at the top of the module.

* docs(agent-status): a departed agent's failure ranks below live work on the worktree card

* fix(native-chat): a restart offer knows its continuations by a tag in their id

The offer recorded each continuation id in a list on its capsule entry, capped at 16, and a running
action's id in memory. Both could disagree with the journal: past the cap an old rejected
continuation read as the chat moving on, and a crash during a retry restored the failure's older
entry, which lacked the retry's id. Each continuation id now carries a tag derived from the offer
(its teardown and chat), then the action's own part, so any continuation of this offer, queued or
rejected, is recognised from the journal row and the marker alone. The persisted list, its cap and
the in-memory action map are deleted; the agent-start withdrawal keeps an offer whose own
continuation the start was for, read against the stored marker.

* test(runtime): the legacy-worker reveal test judges its stale snapshot inside the wait

The tui-idle probe reads through readTerminal, which now awaits the structured
worker check before the PTY read, so the probe's snapshot request starts a
microtask later. vi.waitFor missed it on its first check and polled again at
50 ms, the same moment the wait's own 50 ms timeout fired. The stale snapshot
then resolved after the wait had already timed out, so the test passed without
judging it, and the rejection landed before any handler was attached. Vitest
reported that as an unhandled error and failed the shard.

Polling every 1 ms sees the request within a few ms, so the snapshot is judged
while the wait is still pending.

* fix(native-chat): a message held behind /compact is drawn where it was handed over

A message typed while /compact runs was drawn above the compaction's result, between
itself and its own answer. The reducer kept every item at the sequence and timestamp of
the row that created it, and a queued message is created at acceptance, long before the
command it waits behind writes its result. The phone orders by that sequence and the
desktop by that timestamp, so both put the message first.

A queued message now takes its position from its handover row, the same row that already
states its turn scope. Everything the agent did before the handover, a command it waited
behind included, draws above it. This holds for every held message, not only /compact's,
and needs no client change: every client, older builds included, reads the position the
host publishes. A live batch already carries the item when its dispatch row lands, and
history pages cut the reduced timeline by sequence, so paging stays contiguous.

* fix(native-chat): a phone's send during /compact answers without waiting out the compaction

A client that predates accepted-send replies, which is every phone build, has its send
reply held until the host hands the message over. A message sent during /compact is not
handed over until the compaction ends, so the phone's 15 s request timeout fired first
and showed the message as unconfirmed.

That wait now also ends once the message is queued behind a running command. This is
read from the journal's running turn and needs no new state. Every other wait still
ends at the handover: behind a starting child or an ordinary turn, and for restart
resume, the command front door and orchestration, which keep the plain handover point.

* perf(native-chat): a rewind places provider items with one pass over the merged rows

A Codex rewind gives each provider item the old epoch never held the turn record for its
provider turn. It found that record by scanning every merged row, restoring each row's
body, once per provider item. That is quadratic, and it runs on the host's main thread
up to the journal's 10,000-row cap, twice per rewind. A rewind record written before
rows carried their scope holds no scope for any provider item, so it paid the full cost.

The merge now indexes turn records by provider turn id once, keeping the first match as
the scan did, and each provider item looks its record up.

* fix(native-chat): a view never restarts a chat whose last start failed

A Claude chat whose CLI exits during startup left one red row per start, and
every time a view bound to it (the chat opening right after its create died,
or the user switching back to it) the hold started the CLI again, so the same
launch-failure row repeated. Only a send retries a failed start now, the same
rule provider-exit recovery already applied; the rule lives in one predicate
the hold, exit recovery and the delivery loop share.

* fix(native-chat): a message waiting behind /compact is drawn after it until it is sent

A message sent while /compact runs is placed where it was handed over. It was still
drawn where it was accepted until then. /compact writes its result one step before the
handover, so for that step the waiting message sat above the compaction's separator.

A message the host accepted but has not handed over is not part of the conversation
yet, so both clients now draw it after everything the agent has done. The shared
projection moves it to the end, which is the order the phone draws. The desktop ranks
it with the other not-yet-sent rows, after the streaming preview. At handover it takes
its place from its handover row, which is also after the separator, so it never
appears above the compaction it waited for.

* fix(native-chat): the idle sweep reads owed work every tick

Owed work counted as activity, but the sweep read it only once the idle window had elapsed, so it
refreshed the clock at most once a window. Work that ended just before the next read left the
agent to be stopped at that read, moments after the work ended, which is the gap the refresh was
meant to cover. The sweep now reads owed work on every tick for a started agent, so the window
always runs from the last tick that saw work owed.

* fix(native-chat): a continuation handed to the agent stays sent

The offer read its own continuation as not reaching the agent while its dispatch was pending, which
also covered one already handed over and still unanswered. When the wait for that answer ended first,
the failure it filed read as retryable, and a retry sent a second continuation to an agent that may
have acted on the first. Only a continuation still queued, or rejected, is now read as unsent.

* test(native-chat): start the child the loop waits on with an attach, not a second view

A view no longer starts a child whose last start failed, so the R2 case that
waits on a child started since the failure now gets that child from a client
attach, the one non-send starter left.

* fix(native-chat): settle a gone generation's turn wherever a conversation opens

A send that opens a chat this process had not read yet (after a crash, from a
phone or the CLI) went through the delivery open, which never settled what the
dead generation left running; only the read restore and a successful acquire
did. When the send's start then failed, the turn stayed running for every
reader. The settlement now runs in the one journal open, at the crash boundary,
for every opener except an acquisition, which settles from the evidence it read
before its reserve; the read restore's separate step is gone.

* test(native-chat): prove the next child's start settles the turn an earlier child left

The R1 case lost its only settlement assertion when the latch it checked was
deleted. It now seeds the running turn the earlier child left and asserts it
ends at the exit's receipt, with the exit's row, before the message is handed
to the new child.

* test(native-chat): count a failed start's rows by row, not by text

Comparing the set of texts passed when two different rows carried the same
words, which is the duplicate the test exists to catch.

* test(cross-version): load the phone row readers without mobile's toolchain

Vite transforms a file against its nearest tsconfig, and mobile/tsconfig.json
extends expo/tsconfig.base.json, which the root-only cross-version lane never
installs. The worktree ps verdict suite imported the current phone row reader
from mobile/ directly, so CI failed with TSConfckParseError before any test ran.

The harness now imports a copy of the working-tree reader placed under the
checkout cache, where the root tsconfig applies, as it already does for the
release checkout's copy. Both readers are still the real files.

* test(cross-version): keep the checkout path-guard message and justify the copy import's cast

* fix(native-chat): a command ends only by its own provider answer or its child's end

Stop no longer settles a conversation command. It interrupts it like any turn,
and when the provider cannot take that (Codex has not opened the command's turn
yet, or Claude refuses the interrupt) it stops the child, whose dead-generation
settlement writes the verdict.

The pending command now lives on the provider child's own session instead of an
adapter-wide map keyed by session, so it dies with the child and nothing has to
release it. Claude's /compact is sent under a uuid the slot records, and only a
root result naming that input (or naming none) ends it; its outcome is read with
the ordinary result reading, so a stopped /compact is a cancellation.

* fix(native-chat): a command's settle answers its message before ending its turn

The two writes are not one batch. Writing the message's answer first means a
crash between them leaves a running command turn, which the stale-turn sweep
already settles, instead of an ended turn whose message reads as in flight
forever. The settle now writes only while the command turn is still running.

* fix(native-chat): "Worked for" counts from the handover, not the send

A message held behind /compact, or behind a cold start, used to count the wait
as the agent's work, although its row is drawn at the handover. Every handed-over
submission's turn, the command's own included, now starts at the handover row's
instant, falling back to the send time for a host that recorded none.

* test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget

* test(native-chat): the interrupted create's own retry continues again

The merge of main's lease-latch fix replaced that test's retry of the interrupted create, under its
own operation id, with a fresh start whose result nothing read. That fresh start passes with the
released-reservation continuation deleted, so the case the fix exists for went untested. The retry
and its assertion are main's again.

* docs(native-chat): three comments that still had views starting agents

A start with nothing queued now comes from a command, goal change or rewind; an interrupted compaction
left alone would refuse every send, so no agent would ever start to finish it; and a current host
raises the unattached read refusal only once quit began, with the attach window belonging to an older
host.

* test(native-chat): pin the open's and the send's start and row counts, however the view binds

Opening a fresh chat whose starts fail makes one start and one row, with two
views bound before or after the create's child died; one send makes one more
of each.

* fix(native-chat): a second Stop on a command ends its child; one compaction verdict for every provider

A Stop's note now names itself in its key, so a later Stop on a command still
running reads, from the journal, that the provider was already asked and never
answered, and stops the child instead of interrupting again. Nothing is held in
memory for it.

Adds the rule both translators will read a compaction's end by: only a
compaction the provider reported is a success; none after Orca's interrupt is a
cancellation; anything else is a failure. A real Claude capture, pinned as a
fixture, is why: a stopped /compact ends in the same success result as a
finished one.

* test(native-chat): a reader's open settles the turn a failed exit settlement left running

An exit whose settlement write failed leaves its turn running in the open journal. PR 1's open now
settles it, and this pins the two reads that reach it here: a reader reopening a chat the idle
sweep closed, and a read that opens the chat before the restart restore reaches it.

* test(native-chat): the view-start test's starting window outlasts two subscriptions on a loaded runner

A subscription reads the conversation before it returns, so under load the two views took longer
than the create child's 300 ms start, which then exited before the test checked that it had not.
The child now takes a second to fail.

* fix(native-chat): settle a gone generation's turn at every open but an acquisition's

The journal open skipped the settlement whenever the lease read reserved or
live, to leave an acquisition's own open to the acquisition. But a lease a
crashed process left in recovery also reads live, until the next acquire
resolves it. A send that opened such a chat, from a phone or the CLI after a
crash on a host that could not prove the old owner gone, skipped the
settlement; when its start then failed, the dead turn stayed running for every
reader. The acquisition now says it is the opener, and every other open
settles, whatever the lease still claims.

* test(native-chat): hold the create's start open until the views bind

The "view binds while the create is still starting" case gave the create a
300 ms head start and asserted the views bound before it died. On a loaded
runner the holds took longer, the create's exit landed first, and the case
failed its own precondition. The create's initialize now waits on a gate the
test releases once the views are bound.

* refactor(native-chat): the provider's translator ends a command's turn; the loop holds no command state

A conversation command is now a turn of the provider child's own journal
pipeline. The adapter-wide tracker, its promise and the loop's settle step are
gone.

- Codex: the translator claims the provider turn that carries the command, scopes
  its rows to the command's turn, and writes the command's end in the same batch
  that settles that turn. Codex's own compaction marker is the success row.
- Claude: the command's turn is the translator's open turn until the result that
  answers the /compact input ends it. The command's own frames, such as the
  continuation summary, its echo and "Compaction canceled.", draw nothing.
- Both read the end with the one compaction rule: success needs the provider's
  report of the compaction; none after Orca's interrupt is a cancellation.
- The message resolves at the provider's receipt, as any send does: the Codex
  ack, or the Claude slash-command waiter on its result. The host writes a
  command's end only when the provider never took it.
- The delivery loop stops while a command's turn runs, and every journal commit
  re-wakes it through the session's serialize, so an end that lands while a step
  decides to stop is never lost. A child that ends first is settled with it.

* test(native-chat): pin a command's end to real /compact frames and to each path it threads

The captured /compact frames drive the Claude translator's command turn: a
finished compaction ends as a success with only the separator drawn; a stopped
one ends as a cancellation with no failure row, and the next send answers in its
own turn; a result naming another input ends nothing. The command's end is
checked at each point the ordinary result path threads through: the reopen latch
after a failure, the settling of a child still working, the context facts the
result reports, and the provider's own error row.

On the host: a message held behind a command is handed over when the command
ends just as the loop stops for it, a refused command settles as a failure and
the loop moves on, and a Claude child that exits mid-command settles the command
and hands what waited to a fresh child.

* test(native-chat): tests merged from the base state which turn their rows belong to

* refactor(native-chat): drop the child-end waiter nothing waits on

A command no longer waits for its child here: its turn ends from the provider's frames or from
that child's settlement, and the delivery loop is woken by the commit. The waiter and its test
were left from the earlier shape.

* fix(native-chat): a command holds the queue only while its child runs it

The delivery loop stopped whenever the journal showed a command's turn running. When the
command's child ended and its settlement could not be written, that turn stayed running with
no child to end it, and the loop's gate kept it from ever starting the next child, which is
what settles a gone generation's leftovers. Every later send was held for good, and Stop had
no child to end.

The gate now holds only while the conversation has a child: with none, the command belongs to
a gone generation, and the loop's start settles it like any turn a dead child left running.

* fix(native-chat): a Claude /compact succeeds only on its compaction boundary

The command's evidence counted Claude's `compact_result: 'success'` status as the compaction
done. That status comes before the boundary that replaces the history, so a Stop landing
between the two read as a finished compaction even though no boundary was ever written. Only
the boundary now counts, as the rule for both providers states; the capture's finished
compaction carries one, so it still reads as a success.

* fix(native-chat): a Claude child's exit says why the turn it ended stopped

When a Claude child exited mid-/compact, the command showed "Worked for 0s" and no reason. The
child's translator ends its open turn the moment the exit is reported, stamped with the exit's
instant, so by the time the exit settlement ran nothing was running. The settlement recognises a
turn the exit already ended by that same instant, but the Claude lifecycle event dropped it on the
way to the host, which then used its own clock, matched nothing, and wrote no row. When the clocks
did agree, the row was scoped to the running turn, of which there was none, so it landed outside
the turn it explained.

The exit's instant now reaches the host, and the exit row belongs to the turn the exit ended:
still running, or ended by the translator at that instant.

* fix(native-chat): a message waiting behind /compact draws below its live activity

A message sent while /compact runs waits on the host until the command ends. Both clients moved
it to the end of the transcript rows, but the running turn's live activity line ("Compacting the
conversation") draws after every row, so the waiting message sat between the command and its own
live status.

A row that is queued, and not what the live turn is for, now draws after that live activity: on
desktop outside the transcript window, below the activity line; on the phone in the list footer,
below the live status. A message whose own start is pending still draws above the activity that
start reports.

* fix(native-chat): only a running command holds a message below its live activity

A message is accepted, then handed over a moment later, and in between it reads as waiting. Every
message waiting behind a live turn drew below that turn's activity line, so an ordinary message
sent while the agent was working crossed below "Thinking" and jumped back up once it was handed
over, on desktop and phone. Only a conversation command's turn holds the queue on the host.

A message now waits below the live activity only while the running turn is one a command opened,
read from the entry that opened it. The phone test also typechecks, which the mobile test ratchet
requires.

* test(codex): the claim test names its notification params as a record

* test(native-chat): a read that reaches a crashed chat before the startup reconcile settles its turn

On desktop the chat on screen at relaunch reads before startup reconciles the leases, while the
dead process's lease still reads live. The open settles the turn it left running anyway, and the
restore that follows finds it settled.

* refactor(native-chat): drop the composer's second error formatter

After the merge with main, every chat write in the composer path reports its
failure as a typed outcome worded by the refusal-notice table, so the send's
catch sees only a local throw. The {code, message} formatter this branch added
for it has no payload left to format, and its claim to be the one way a chat
words a failure is no longer true. The composer send is main's again.

* test(native-chat): pin the reason on a message rejected while its chat was closed

The reopen test checked only that the message reads as not sent; it now also
checks the Retry row carries the host's reason.

* docs(native-chat): drop the removed dispatch hold from six comments

A worker's session no longer takes a dispatch hold, and no release clock
rests a chat by visibility; the agent-launch comments, the abandon test,
the teardown test and the refusal census still said so.

* test(native-chat): rest the owner-status chat through the idle sweep, not a hold

The activation-gate test from #22808 put its chat at rest by holding and
releasing it, and passed the release-clock grace. This branch deleted both,
so the case threw before it reached its assertions. It now moves the host's
clock past the idle window and lets the sweep stop the agent and close the
conversation, then asserts the same owner answer and activation gate.

* fix(native-chat): show the structured pane's retrying line when a read fails

The read transport always hands the pane the host's words, so the error
state's "Orca keeps trying to load it" line, which showed only when there
were none, was never seen: the pane showed the host's text twice, as its
subtitle and on the status line under it. The structured pane now always
says its read keeps retrying, and the host's text stays on the status line.
The terminal-backed chat is unchanged.

* fix(native-chat): a send the provider never received after a restart has no verdict

Restart reconciliation rejects a crash-stranded send that is absent from a
trustworthy provider history with reason 'not_delivered'. Nobody failed that
send, but the verdict allowlist did not name it, so after a crash the chat
read Failed, was listed, and could notify "failed". Give the reason a shared
constant (persisted value unchanged), add it to the no-verdict set, and treat
it as an internal marker so the Retry row no longer shows the raw string.

* fix(native-chat): a failed Codex compaction's late completion writes no turn of its own

Codex ends a failed turn with an error and then still completes it as failed.
The error settled the compaction and released its claim on the provider turn,
so the completion read that turn as an ordinary one and wrote a stray record.
The claim now lasts until the completion, which adds nothing to a command the
error already ended.

* test(native-chat): the mid-command exit case resumes its next child as a real one does

The case's fake started every child as a newly created thread with the same generation. The
store refuses a created link once the conversation has a thread, so the next child's start
failed and wrote its own error row, which landed before or after the case read the journal.
The next child now resumes the thread under its own generation, and the case reads the
journal once the waiting message is delivered, which also proves the loop moved on.

* fix(native-chat): a /clear that never committed no longer locks the chat

A /clear wrote a durable "prepared, outcome unknown" record before starting
the replacement conversation. When that start was refused without a definite
answer (or Orca died), the record stayed forever, and while it did the chat
refused every send, /compact, a new /clear and rewind. Its only exit was a
rerun under the same operation id, which only the renderer held.

The record guarded nothing the process does not already know: a clear in
flight holds the session's serialize for its whole run and the command
controller refuses sends meanwhile, and the replacement's id and start
operation are pure functions of the clear's operation id. So the clear now
writes nothing durable before its commit, the gates refuse only a committed
clear (an older build's prepared record is inert), and a clear with no
committed answer reruns: a same-op retry re-attaches the same replacement,
a new op id runs a fresh clear.

A crash between the replacement's start and the commit leaves a replacement
record nothing points at. Verified: it has no tab, is not in the
replacement list, and a restart opens and starts nothing for it (restore
reads only the visible tab index); restart reconciliation releases its lease
like any dead owner's. In a live process its agent is stopped by the idle
sweep like any quiet agent. Session History lists provider transcripts and
only annotates them with an owner, so it can list this only if the provider
wrote a transcript for a thread that never got a message. Its record stays
on disk, as every closed chat's does; the store deletes none.

* fix(native-chat): a Codex rewind the provider did not keep no longer fails every attach

When Codex acknowledged a revert and Orca stopped before proving it, the
rewind stayed prepared with providerApplied set. On the next attach,
recovery read the provider's history, found the target turn still there
(provider-refused), and threw, because that settlement was limited to
reverts never sent. The throw ran inside the attach, so every attach, and
every send that needs one, failed for good.

The journal is replaced only once the provider proves the revert, so both
the provider and the journal still hold the target turn: settling the
rewind refused is consistent whether or not the provider acknowledged it.

* test(native-chat): a clear retried after a crash starts no second replacement

The replacement's id is the only thing that keeps a retried clear from leaving a second one, and no test held it across a restart.

* chore(native-chat): the clear rerun comment claims only the stable replacement id

* test(native-chat): wait for a send's background start before the refusal oracle removes its store

An accepted send wakes the delivery loop, which starts the agent in the background. The oracle's teardown disposed the loop but did not wait for that start, so its lease write could create a temp file in the store directory while the directory was being removed, failing the test with ENOTEMPTY about one run in four. The teardown now drains tracked starts before it closes the journals.

* fix(native-chat): a start a message waited on gets one failure row, the delivery loop's

When a queued message's start failed, two writers could report it under the same row: the delivery loop, when the adapter settled the start without proving it, and the exit settlement, when the child's exit landed. The last one won, so the chat's row could name a different cause than the one the message was rejected with, or be written twice.

The exit settlement now writes the start's row only when no message is queued and the loop has not already recorded that start. A start for a command, goal change or rewind, with nothing queued, still gets its row from the exit.

* fix(native-chat): a /compact whose start failed says to run /compact again

The failure-words context named only /clear as a command to retry, so a
/compact whose agent failed to start read "Send your message to try again."
on its row, its rejected message and the command reply. The context now
carries any conversation command; the host derives it from the oldest
message still waiting on the provider, which is the one a failed start
fails first, and the /compact reply names it directly.

* fix(native-chat): a Codex /compact ends only on its turn's completion, below Codex's own error row

Since only turn/completed ends a Codex turn, Codex's turn-ending `error` is a row
inside the still-open command turn, and the failed completion that follows it is
the command's end: completed, outcome failure, at the completion's receipt time.
The command's own "Compaction failed" row was written on that completion too, so a
failed /compact read its reason twice.

The command turn now notes when Codex's turn-ending error for the turn it carries
was written as a row, and its end then adds no second row. A retried stream error
ends nothing and is not counted. The flag that let the error end the command and
kept the claim until the completion is gone with the error-driven end.

A test replays the captured failed compaction from the real app-server through a
claimed command turn.

* test(native-chat): main's crash-turn test states its row's turn, and a dead /compact settles on its recorded exit

Two tests the main merge brought together:
- The crash-turn test from #23456 writes a turn record through the event sink
  without options; every row here states its turn scope, and a turn record's is
  the thread.
- The /compact whose exit settlement could not be written no longer stays running
  until the next start: main now settles an open chat from the exit it recorded, so
  the command reads interrupted before the next message, which is then delivered.

* test(native-chat): main's new journal tests state each row's turn

The crash-turn, stale-turn and sink-queue tests main added wrote rows without a
turn scope, which every item write now states. Rows written inside a running
turn name that turn; the sink-queue batch and a send handed over with no live
turn name the thread.

* fix(native-chat): draw a queued turn's message after the earlier turn's rows

A message sent while A runs is written to the journal when it is sent.
When the provider queues it (Claude answers it after A), A's remaining
rows - its last tool run and its answer - are written after that
message, and the message's own turn opens only after them. Grouping put
those rows in A's turn, but the transcript still drew them in journal
order, below B's bubble and bar, where A's answer read as B's reply. This
is the residual #23671 left open.

A message that opened a turn now draws after the earlier turns' rows the
journal wrote after it, just before its own turn's rows
(nativeChatTurnDrawOrder, returned by nativeChatTurnMembership as
drawOrder). Desktop and mobile both draw in that order. A steer, and a
message that has opened no turn yet, stay where they were written. It
applies on hosts that state turn scopes and, through journal order, on
older ones.

* test(native-chat): run #23026's Stop tests against #23059's command turns

Two of #23026's tests call APIs #23059 changed, and failed af…
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