Skip to content

fix(native-chat): the host writes chat failures for a person, with a typed fact beside them - #23116

Merged
brennanb2025 merged 148 commits into
mainfrom
brennanb2025/chat-failure-facts
Sep 28, 2026
Merged

brennanb2025 merged 148 commits into
mainfrom
brennanb2025/chat-failure-facts

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 100 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​4758 $\color{#cf222e}{\Huge{\mathbf{−}}}$​425 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​4333
Prod 136 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​4083 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1053 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3030

Merge order: #22821 has merged into main, so its permanent chat-history rows are now on main. This PR changes what those rows store, so it must merge before the next release. #22944 has also merged; its sidebar "was the last request a failure" check now takes its answer from this PR's classifier instead of reading the reason text.

Scope: which chat this affects

  • Structured chat only. This changes the experimental structured native chat (the experimentalStructuredNativeChat setting, off by default). Terminal agents and the terminal-backed chat view are unchanged.
  • Host side, shared types, and the shared refusal copy table. This PR changes what the host writes and returns, and grows the refusal copy table that fix(native-chat): say a refused chat write in plain words, and keep it with the write it describes #22999 added. Other desktop screens (held messages, Discard, Send again rows, the launch strip, the retry-status line, the unreadable-history pane) are the follow-up desktop PR.

ELI5

When a chat message could not be sent, or the agent failed to start, Orca saved its own debugging text into the chat's history, for example "The provider stopped before it finished starting: claude stream-json exited (code 1): claude: not signed in." That history is permanent, so no later fix to the chat screen could clean it up. Now Orca writes a plain sentence a person can read ("Claude stopped before it finished starting. Send your message to try again."), plus a typed label saying what went wrong, and keeps the agent's own words separate from Orca's.

What Changed

Before

  • Failure text was whatever error Orca had at hand where the error was caught: refusal prose, exception messages, the agent's error output, file-system errors, and internal code words. It was saved into the chat's permanent history as-is.
  • A failed start often said "The provider stopped…" even when the agent never ran (Orca could not launch it, Orca itself failed, or the agent refused to resume), and the agent's own explanation was dropped.
  • When the host turned a request away, it sent back only a code such as agent_session_operation_invalid. One code covered situations as different as "this conversation was cleared" (retrying can never work) and "wait for the current reply" (retrying later will work), so the app could not choose an honest next step.
  • A chat that could not be created showed the raw exception under "Chat could not be started." Claude's API retries wrote one red row per attempt reading "rate_limit", with the raw retry data behind Details.
  • Several places judged separately whether a message that was not sent should count as a failure.

After

  • Words chosen when the failure is recorded. Every place in the host that saves a failure row or a "message not sent" reason gets its sentence from one shared function (agentSessionFailureWords), together with a typed cause: a fixed list of situations, plus the agent's own text when there is some. The sentence type can only come from that function, so no writer can pair a hand-written sentence with a cause. Older desktop and phone builds show the sentence as it is; newer builds read the cause.
  • The agent's words kept apart. Text the agent wrote is captured where Orca receives it (Claude and Codex error output, Codex request errors, compaction errors), marked as written for a person or for a log, and capped at 512 characters. Log text never enters the sentence. Nothing guesses who wrote a string after the fact.
  • Honest start failures. Only an exit Orca actually saw reads " stopped before it finished starting."; any other failed start reads " couldn't start." or "couldn't restart.", each with the next step ("Send your message to try again.", or "Run /clear again." after /clear). Not signed in, history too large, a Claude account switch in progress, account variables that conflict with a managed account, and a WSL-only Claude account each get their own sentence, identical on the first answer and on a repeat of the same request.
  • Refusals typed by code. A refusal is now one shape per code, with optional details holding that code's own reason and facts (for example "the agent is still responding", "the agent is waiting for an answer", "the last /clear didn't finish", "this chat is still open in a terminal agent"). A reason the code does not list fails to compile. There is no catch-all reason: a place that cannot tell what happened sends the code with no details. The older fields released apps read at the top level of a refusal (which process runs the chat, the restart counter, a question's revision and answer, a rewind reason) are now filled in exactly one place, from the details. A refusal the host throws carries the same details in the error's data, with its code and message unchanged. The host's record of each request, used so a retry is answered the same way, stores the details too.
  • One copy table. fix(native-chat): say a refused chat write in plain words, and keep it with the write it describes #22999's table (code × kind of request → words) grows to code × reason × kind of request, and the compiler checks every reason has words. fix(native-chat): say a refused chat write in plain words, and keep it with the write it describes #22999's rows stay as the fallback for older hosts and unknown reasons. Failure causes use the shared sentence function, so each situation has one source of words. A new read-history request kind covers a chat whose history cannot be loaded. Two new shared files hold the non-copy halves: agent-session-write-failure.ts (request kinds, a queued message's saved failure and its parsing) and agent-session-write-notice-copy.ts (the sentence list); the copy table itself stays in agent-session-refusal-notice.ts.
  • Saved failures on queued messages. The desktop's queue of messages not yet confirmed saves each message's last failure. That saved failure gains an optional typed reason (plus rewind reason and whether the chat's process is proven gone) and the recorded rejection's cause; old saved entries load unchanged. A stored "process proven gone" only lets Retry use a fresh request id; the message keeps its place in the queue.
  • One reader of "was this a failure". classifyDispatchRejection decides from a table that must list every cause, and fix(native-chat): a request that failed reads as failed #22944's sidebar check now asks it too, so the sidebar and the chat always agree. A message withdrawn by Stop, one lost to an Orca restart, one lost to a chat close, and one the agent never received after a restart do not count as failures; a cause this build cannot read is treated the same way instead of guessing. Every "not sent" row must carry its cause, and only causes that can apply to a message are accepted; both are enforced by the compiler.
  • Leaks closed. A refused chat create now carries the curated sentence, with the raw error in the log. Claude's API retries write one row per retry run ("Claude is rate-limited and retrying." or "Claude hit a temporary problem and is retrying."), with the retry data behind Details as log text. A send into a chat whose /clear never finished says so ("The last /clear didn't finish. Start a new chat to continue.") instead of claiming the chat was cleared. A chat history that cannot be opened is either proven corrupt by the database ("Unable to load this chat.") or temporarily unavailable ("Orca couldn't open this chat's history right now. Try again."); an older host's bare code never promises either.
  • Honest mid-response stops. " stopped while this response was in progress. You can continue in this conversation." is written only when Orca saw the agent's own process exit while it was not closing it, for Claude and Codex alike; when Orca itself closed the agent (for example because it could not save the agent's output), the row says Orca ran into a problem instead.
  • One writer per failed start. When a start fails, the first place that records it decides the row and the words on every message it rejects; a later exit report no longer rewrites that row, so the row and the messages always agree.

Why

Failure text was decided wherever an error happened to be caught, then saved permanently into chat history. No later client fix could clean it, and one error code covered situations needing opposite next steps. The host now writes a plain sentence plus a typed cause at the moment it records the failure, and keeps the agent's own words separate. Old clients read the sentence, and new ones can choose an honest action from the cause.

The mechanism is one function that turns a cause into its sentence, used by every writer, with the compiler refusing a sentence that did not come from it. So a new failure cannot be recorded without also being classified, and a classification cannot drift from its words. The agent's words travel as a separate value from the moment Orca receives them, so the sentence never has to be picked apart later to decide what is safe to show.

Why not reword on the client only. A client table can reword a refusal it receives, but it cannot clean a sentence already saved in the chat's history, it cannot help older app versions that print the saved sentence, and it cannot tell the agent's "not signed in" from Orca's "ENOENT" inside one string. A remote host also cannot rely on every connected client being new enough.

Why one table per code with typed details. A single code cannot choose an honest next step, and an earlier draft's flat list of causes shared by all codes let a code carry a cause it can never produce. With one shape per code, each code lists only its own reasons and facts, the compiler rejects anything else, and the copy table is checked for completeness over every reason of every code.

Alternatives considered

  • Store a code word in the saved reason instead of a sentence. Released apps print any reason they do not recognise, so a new code word would appear raw on builds already in the field. A sentence reads correctly everywhere.
  • Rewrite rejected messages when a later report disagrees. A "not sent" message is final and older apps may already have read it, so the first writer decides instead.

Differences from the common pattern

  • The common pattern decides a stored failure's words on the server before saving it, and types errors as a union keyed by code, each code with its own details and reason list. This PR matches both.
  • Deviation: typed details are optional on the wire, where the common pattern requires them for the codes that have them. Hosts and apps update independently here, and an older host sends none; the code-level words are the fallback.
  • Deviation: the host also saves the sentence beside the cause, because released apps print it and a remote host cannot rely on the app to word it.

Linked Issue

No separate issue. Reported during live QA of #22918: after an idle stop and restart, the chat showed "Expected runtime fence 1; the session is at 3." #22812 removed that refusal; #22999 and this PR make every other failure readable.

Visual Proof

Live QA on a Mac (M4 MacBook Air) with dev builds, driven through Playwright over the Chrome DevTools Protocol with the window hidden. A stand-in claude makes every structured start exit with the error line claude: not signed in (rig), so a failed start is repeatable. "Before" is #22821's head (8f66a25); "after" is this PR (11b23cc; later commits change no screen these scenarios show, and the stand-in's exit is a real exit, so it still reads "stopped"). The planned QA agent hit its weekly usage limit before the final round, so the reviewing agent ran the same scripts itself over the test machine's shell.

Two messages queued behind a start that fails. Before: every row and the not-sent reason carry Orca's debugging text and the agent's error output. After: plain sentences naming the agent, with the next step.

Before (#22821) After (this PR)
before: queued messages after: queued messages

One message while the start fails.

Before (#22821) After (this PR)
before: one send after: one send

Chat create refused, and Retry once the agent can start. An earlier build of this branch (as on main) showed the raw exception under "Chat could not be started."; the refusal's message is now the curated sentence. That path depends on a race and did not come up in the final run, so it is covered by unit tests only. After Retry, the reply arrives and the error notice clears; the earlier failure rows stay as history.

Launch strip (earlier build) After Retry (this PR)
launch strip raw text after retry

Final head, after merging #22835 (d07508a). A failed start reads the same plain sentence on the row, the notice and the sidebar ("Failed"). A Stop pressed while a second message was queued behind a running reply left that message under "Cancellation requested." / "Working…" for minutes; that is an existing main bug that #23553 fixes (Stop does not ask Claude to withdraw queued messages when its capabilities read as empty), not this PR. Stop is not offered while a message waits on a start, which #23026 covers.

Failed start (final head) Stop with a queued message (existing main bug, #23553)
final head: failed start stop with queued message

Testing

  • I manually tested these changes locally
  • Automated tests added/updated
  • Live (macOS, remote M4 MacBook Air): a failed start with two queued messages and with one message, on fix(native-chat): a message is accepted, then delivered #22821 and on this PR; Retry after the agent can start on this PR; after merging fix(native-chat): the conversation outlives its agent #22835, a failed start again and a Stop with a queued message (see Visual Proof). No agent error output, "stream-json" or "The provider" appeared anywhere on screen after the change.
  • Checks at the final head: typecheck, full lint on every changed file, the changed-lines quality gate, the low-evidence and type-aware audits, React Doctor, the five localization checks, and the mobile typecheck.
  • Suites, run in batches: native-chat host and journal (about 1,640 tests), Claude and Codex structured (about 900), runtime structured and RPC, shared (about 9,800), renderer native-chat and lib, and mobile session (about 1,800). The Claude real-CLI test needs a real signed-in CLI and was excluded.
  • Every fix was checked by reverting it and watching its test fail. Several checks are compile-time: a reason a code does not list, a missing copy row for a reason, a missing verdict for a cause, a "not sent" row without its cause, a status-only cause on a message, and a new way for the agent process to end without a mapping all fail the typecheck.
  • Mixed versions pinned by tests: an old saved queued message loads unchanged; a cause this build cannot read is not a failure; the fixed code words older apps recognise read the same; the older top-level refusal fields match the previous build field by field; the first answer and the repeat of a refused create read the same sentence; old saved rows written with the earlier cause shape still parse.
  • Not run live: SSH-hosted chats, Linux and Windows. All wording is chosen on the host that runs the agent, no transport or platform code changed, and the unit tests run on every platform in CI, so the live macOS run covers what changed. Mobile screens were not run live; the phone reads the same shared table, and its typecheck and session tests pass.

Needs native review

30 new app strings (components.native-chat.writeNotice.*) were translated by hand into Spanish (es), French (fr), Korean (ko), Chinese (zh) and Japanese (ja). Please have a native reader check them.

Known weaknesses

  • English start-failure sentences in translated builds. Refusal notices that reuse a start-failure sentence show it in English on translated desktop builds (sentences the host saves are English anyway). Owner: desktop follow-up PR.
  • Details on thrown refusals are not read yet. The host sends them in the error's data; the app still picks words for a thrown refusal by its code. Owner: desktop follow-up PR.
  • Unreadable-history pane. The words for a corrupt or unavailable chat history exist and are exported, but the chat pane still shows the raw code; it must pass the refusal's reason through so the two read differently. Owner: desktop follow-up PR (or perf(native-chat): chat tabs list from saved state, and history opens on first use #23131).
  • A /clear that never finished stays stuck. Every later send, /compact and /clear on that chat is refused until a new chat is started; this PR only makes the refusal say so. Owner: fix(native-chat): an unfinished /clear or refused Codex rewind no longer locks the chat #23524.
  • Codex rewind that the agent applied but still reports can fail every later reopen of that chat; only a new chat recovers. Reachable only from programmatic clients today.
  • A /clear whose new conversation fails before any process starts appears to throw out of /clear rather than record a failed command. Older than this PR; not verified.
  • Repeat of a refused create after a mid-start record conflict answers with a different code than the first answer (agent_session_operation_invalid). Older than this PR; no user-visible harm found; follow-up recorded.
  • Close that fails after stopping the agent. Queued messages then read as a failed start or exit rather than "chat closed", as on main.
  • Unverified race. If Orca begins closing Claude before its exit event arrives, a start failure reads "couldn't restart" instead of "stopped before it finished starting". Still honest; the agent's words stay behind Details.
  • A failed /clear replacement start keeps its situation but not the agent's own words behind Details.
  • Downgrade. An older desktop build ignores the new typed fields on a saved queued message and shows the code-level words; no saved entry is dropped by this PR. Held-message state belongs to the desktop follow-up and is not introduced here.
  • Chat history that fails to open on a read still reaches the app as a generic runtime error carrying the raw error text; only writes classify it as corrupt or unavailable (older than this PR). Owner: desktop follow-up.
  • The idle sweep's stop reason is a hand-written sentence held in memory only; it is never saved or shown (the row and messages use the hostStopped sentence), noted for completeness.
  • Codex killed by its own connection. When Orca's connection ends Codex because Codex broke the protocol, sent an oversized record, or its input pipe broke, the row still says "Codex stopped…". Codex triggered those, so they were left as they are.
  • Fixed during review: walking a nested error that contains itself was unbounded; it is now bounded (11b23cc).

Review

Agent skill upstream boundary

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

Notes

  • Mixed versions. Every addition is an optional field. Older apps ignore failure, rejection, details and error.data, and read the sentence and the older top-level refusal fields, which are unchanged. Older hosts send none of them, and newer apps fall back to the code-level words and the fixed code words. The "withdrawn" and "queue full" code words are byte-identical; a failed hand-over is stored as the bare provider_write_failed code word, which released apps already replace with their own words. Reasons on messages whose delivery is in doubt are unchanged.
  • What older builds read differently. Start failures, attachment and compaction failures, the create refusal, a /clear that never finished, and Claude's API retries now read as a curated sentence instead of Orca's raw error text.
  • SSH. All wording is chosen on the host that runs the agent; nothing about transport changes.
  • Orca stopping a start that never finished. When Orca stops an agent that never finished starting (the idle sweep fix(native-chat): the conversation outlives its agent #22835 added), the row and each waiting message read " never finished starting, so Orca stopped it." with its own cause, hostStopped. It counts as a failure: Orca stopped the agent because its start hung, so the message never ran.

Checklist

  • This PR is small and focused (it is not small: it changes every host failure writer and the refusal shape together, because a partial change would leave writers choosing words in two ways)
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

brennanb2025 and others added 30 commits September 24, 2026 23:36
No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.
Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

Co-Authored-By: Claude <noreply@anthropic.com>
`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

Co-Authored-By: Claude <noreply@anthropic.com>
Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…ement

The removed restart handoff test pinned this branch; nothing else did.

Co-Authored-By: Claude <noreply@anthropic.com>
The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

Co-Authored-By: Claude <noreply@anthropic.com>
The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

Co-Authored-By: Claude <noreply@anthropic.com>
…alled

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

Co-Authored-By: Claude <noreply@anthropic.com>
A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.
The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.
…t decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.
…ation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.
…at changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.
…ration

A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.

Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.

Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.
A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.

A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.
…ackfill cannot supply its rewrite

The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.
The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.
Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.
# Conflicts:
#	src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts
A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".

A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.

Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.

A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.

Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.
A child that proved its start and then exited before its message was handed
over left the message queued: the exit settlement returned early when nothing
else was in flight. Delivery then started another child for it, and a child
that died the same way started another, without end and without a row.

A retried settlement for an earlier generation, run by the attach that
delivery started, did the opposite: with that generation's turn unfinished it
rejected the message queued for the child being attached.

The settlement now takes the rejection for queued messages from its caller.
The unexpected exit and the eviction pass one, and it applies even with no
other work in flight; the retry for an earlier generation passes none.
…hild

A Codex chat that Orca itself closed (a journal sink that could not take a
frame, or a forced close) said "Codex stopped while this response was in
progress", as if Codex had exited on its own.

Orca's own close path now reports hostFault. providerExited is left to the
app-server connection's exit callback, which the connection withholds while Orca
is closing the child, so it only ever reports the child's own exit.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status (stays a draft)

Head: d187bee. Base is now main: #22821 merged as bfe476f, and this branch merged main in 4eb3633. #22944 has merged too, and its sidebar verdict now comes from this PR's classifier. This PR must merge before the next release, because #22821's permanent chat-history rows are on main and this PR changes what they store. CI: green at d187bee (34 passed, 12 skipped, 0 pending, 0 failed), including static analysis, typecheck, all eight test shards, cross-version wire compatibility and the RPC recording pin.

Review loops

  • Loops 1–3 fixed:
    • attachment refusals naming the limit they broke (3592e14);
    • the chat tab table refusing with a typed reason (0ce944a);
    • Codex's own compaction refusal words (0188573);
    • an unreadable record no longer promising an update fixes it (570eff5);
    • sentences instead of code words for a restart or close (dd05f06);
    • logging an unreadable attachment (34a1e43).
  • Architecture challenge after loop 3. The same kind of finding kept coming back, so a separate review challenged the design. Verdict: refine. Writers could choose a sentence and a cause separately, and some guessed the cause. Loop 4 applied the fixes:
    • one function for sentence and cause, with a sentence type only it can produce (7243c96);
    • the compaction outcome typed where the agent's message is seen (44672f5, e9e40b9);
    • empty and non-user messages no longer typed as attachment problems (ac5e9c8);
    • a failed start without an observed exit no longer blames the agent (d19f909).
  • Refusals typed by code (approved design change): 93c5747, 73bc984.
  • Loop 5 found start refusals still guessing "the provider stopped". Fixed in 4634735, 2927227, b532cd9, e60047d.
  • Loop 6 clean. Follow-ups directed during review:
    • the create refusal's message is the curated sentence (79327d4);
    • Claude API retries become one sentence row (96a12ea);
    • /clear-aware next steps (d2ef1b6);
    • the agent is named in start failures (f160988, 82e6a2c);
    • typed account refusals before the process starts (0a50e3f, 248c23c).
  • Merge of fix(native-chat): a message is accepted, then delivered #22821, which by then contained fix(native-chat): say a refused chat write in plain words, and keep it with the write it describes #22999 (64be90f), then the grown copy table and saved shape (d1506de, 1e168ab).
  • Loop 7 clean, plus a refusal for a /clear that never finished (12e30c7).
  • Loop 8 clean, plus:
    • the "was this a failure" table (574e2ac);
    • a queued message keeps its place when its process is proven gone (c7b4330);
    • a bounded nested-error walk (11b23cc).
  • Loop 9 clean, plus:
    • the read-history words (2e89498);
    • one exhaustive table for how the agent process ended (e839453);
    • a host test for a close that fails after stopping the agent (57f1d54).
  • Directed after loop 9:
    • corrupt vs temporarily unavailable history, decided from the database's own error code (f4320d4);
    • the first writer decides a failed start's row, so the row and its messages always agree (53b2f63);
    • Orca's own Claude startup faults no longer read as the agent stopping (b36f709).
  • Loop 10 clean over all of that, including the main merges.
  • Directed after loop 10:
  • Final focused review clean over those commits and the merges; git diff origin/main...HEAD holds only this PR's files.

Readiness checklist

  • Start of review: ran.
  • End (at 12e30c7): ready with notes, no blockers. The notes were the English start-failure sentences in translated notices (known weakness) and the unbounded nested-error walk, since fixed in 11b23cc.

Mobile compatibility

No blocker.

  • The only mobile code change is where two shared helpers are imported from.
  • Released phone builds read the curated sentences and the unchanged code words, get message plus the unchanged top-level refusal fields, and ignore the new optional details, failure and rejection fields.
  • No pairing, protocol, route or saved phone state changed. The phone does not save the send queue.
  • Newer phone builds get the grown copy table from shared code. Mobile typecheck and session tests pass.

Seen during QA

Rig: a Mac M4 Air, dev builds, Playwright over the DevTools protocol with the window hidden. A stand-in claude fails every structured start with the error line claude: not signed in (rig). The planned QA agent hit its weekly usage limit, so the final round was run by the reviewing agent over the test machine's shell with the same scripts. No codex was launched in any rig terminal pane.

Two queued messages, and one message, while the start fails

  • Before (fix(native-chat): a message is accepted, then delivered #22821): each row and the not-sent reason read "The provider stopped before it finished starting: claude stream-json exited (code 1): claude: not signed in (rig)."
  • After: "Claude stopped before it finished starting. Send your message to try again." No agent error output appears anywhere on screen.

Retry once the agent can start

  • The reply arrives and the notice clears.

Launch strip for a refused create

  • It showed the raw exception on main and on an earlier build of this branch.
  • It is now the curated sentence.
  • That path depends on a race and did not come up in the final run; unit tests cover it.

Failure sentence shown three times: not caused by this PR

Raw rate_limit rows with the retry data: fixed here

  • These were on main since feat(native-chat): Codex structured native chat restructure #16729: one error row per Claude retry message.
  • Now there is one row per retry run, "Claude is rate-limited and retrying.", with the data behind Details as log text.
  • Owner of that message type: claude-structured-provider-fallback.ts with claude-api-retry-row.ts.

Two queued messages share one not-sent notice

  • App behaviour, the same before and after; owner is the desktop follow-up.

before: queued after: queued after: retry

Known gaps (not this PR)

  • A /clear that never finished stays stuck. Every later send, /compact and /clear is refused until a new chat is started. This PR only makes the refusal say so. Owner: fix(native-chat): an unfinished /clear or refused Codex rewind no longer locks the chat #23524.
  • Codex rewind the agent applied but still reports can fail every later reopen of that chat. Reachable only from programmatic clients today.
  • Details on thrown refusals are not read yet. The host sends them in the error's data. Owner: desktop follow-up.
  • English start-failure sentences show in translated desktop notices. Owner: desktop follow-up.
  • Unreadable-history pane. The words for corrupt vs unavailable history exist and are exported, but the pane still shows the raw code. It must pass the reason through so the two read differently. Owner: desktop follow-up / perf(native-chat): chat tabs list from saved state, and history opens on first use #23131.
  • A /clear whose new conversation fails before any process starts appears to throw rather than record a failed command. Older than this PR; unverified.
  • A failed /clear replacement start keeps its situation but not the agent's own words behind Details.
  • Repeating a create refused mid-start answers with agent_session_operation_invalid instead of the first answer's code. Older than this PR; follow-up recorded.
  • A close that fails after stopping the agent makes queued messages read as a failed start or exit rather than "chat closed", as on main.
  • Codex killed by its own connection (protocol violation, oversized record, broken input pipe) still reads "Codex stopped…"; Codex triggered those.
  • Unverified race. If Orca begins closing Claude before its exit event arrives, a start failure reads "couldn't restart" instead of "stopped before it finished starting". It is still honest.
  • Translations. 30 new strings were hand-translated into es, fr, ko, zh and ja and need a native check.

@brennanb2025
brennanb2025 marked this pull request as ready for review September 28, 2026 06:34
@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: 6e8a9eff-f863-4411-8cea-9fe43e91c053

📥 Commits

Reviewing files that changed from the base of the PR and between f6bca9f and 58cf1ee.

📒 Files selected for processing (3)
  • src/main/claude/claude-api-retry-idle-sweep.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.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 typed failure facts and refusal details across agent-session adapters, journals, runtime handling, and native-chat clients. Claude adds API-retry journal rows and typed attachment failures. Claude and Codex exit paths classify provider exits separately from host faults. Compaction uses explicit outcomes. Renderer notice handling and locale files add messages for session and history states.

Priority: ➖ Normal

Merge Risk: 🔵 Low · up to 58cf1

Queued messages may still incorrectly blame the provider when Orca evicts a session. The PR is mergeable with owner awareness of that wording issue.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 58cf1

Typed failures appear to improve the separation between readable chat messages and provider diagnostics, while preserving recovery safeguards. Review is still warranted because diagnostics can accompany durable failure facts, and their handling at every client-facing boundary has not been established.

Retained concerns

  • Medium · security · inferred: Typed rejection facts can carry bounded provider diagnostics into durable session data, but the available evidence does not establish that every downstream client-facing reader excludes log-audience detail. This is an exposure question, not a verified disclosure.
Security review details

Security Blast Radius

  • inferred — The relevant exposure is the structured-chat session and its durable history and consumers, rather than a demonstrated new provider credential or session-creation authority. The feature is described as off by default.

Security Findings and Attack Paths

  • inferred — Provider-supplied diagnostic text is the input of interest: it can accompany a failure fact, but the examined wording path does not interpolate it into the curated reason. No client-visible disclosure or bypass was verified.

Trust Boundaries and Controls

  • observed — Resume checks lease reconciliation, recovery, record presence, provider support, and resumability before attachment; the new diagnostic-bearing outcome is handled by an internal delivery loop. Its complete downstream read boundary remains untraced.

Resilience and Maintainability Implications

  • observed — Recovery keeps uncertain deliveries distinct from settled refusals, and typed rejection facts take precedence over contradictory prose. Those behaviors support stable ownership and failure classification across replay.

Hardening Proposals

  • proposed — Make the diagnostic audience rule explicit at durable-history and RPC serialization boundaries, so log-audience provider text cannot be rendered as a chat reason or returned where only person-facing text is intended.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.40% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 113 functions across 102 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: structured native-chat failures now use person-readable text with typed failure facts.
Description check ✅ Passed The description is comprehensive and follows the required structure. It explains the user impact, implementation, rationale, testing, visual proof, compatibility, limitations, and checklist status. It…
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.
  • 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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/shared/agent-session-journal-schemas.ts (1)

205-209: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Preserve failure metadata in the rewind record type.

AgentSessionRewindRecord is inferred from AgentSessionRewindRecordSchema, which embeds AgentJournalItemBodySchema. Because FailureFact omits attachment and retry, the retained-body type does not expose these fields. Rewind consumers then narrow the body back to AgentJournalItemBody with a cast. The admissibility guards retain runtime keys because they use only .success; this is a schema-derived type loss.

Suggested schema change
 const FailureFact = z.object({
   kind: z.string().min(1),
   detail: z.object({ text: z.string(), audience: z.string().min(1) }).optional(),
-  refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional()
+  refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional(),
+  attachment: z.looseObject({}).optional(),
+  retry: z.looseObject({}).optional()
 })

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 940d6db9-1489-4c7f-9c2d-cbe83ccae1f7

📥 Commits

Reviewing files that changed from the base of the PR and between 67581dd and d187bee.

📒 Files selected for processing (224)
  • mobile/src/session/mobile-structured-agent-session-rpc.ts
  • mobile/src/session/mobile-structured-operation-id-retirement.test.ts
  • src/main/claude/claude-api-retry-row.test.ts
  • src/main/claude/claude-api-retry-row.ts
  • src/main/claude/claude-stream-json-connection.test.ts
  • src/main/claude/claude-stream-json-connection.ts
  • src/main/claude/claude-structured-auth-parity.test.ts
  • src/main/claude/claude-structured-compaction.test.ts
  • src/main/claude/claude-structured-compaction.ts
  • src/main/claude/claude-structured-control-actions.test.ts
  • src/main/claude/claude-structured-dispatch-admission.test.ts
  • src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts
  • src/main/claude/claude-structured-dispatch-content.ts
  • src/main/claude/claude-structured-dispatch.test.ts
  • src/main/claude/claude-structured-dispatch.ts
  • src/main/claude/claude-structured-init-proof.ts
  • src/main/claude/claude-structured-launch-resolution.test.ts
  • src/main/claude/claude-structured-launch-resolution.ts
  • src/main/claude/claude-structured-options.ts
  • src/main/claude/claude-structured-prompt-ownership.test.ts
  • src/main/claude/claude-structured-provider-fallback.ts
  • src/main/claude/claude-structured-session-acquisition.ts
  • src/main/claude/claude-structured-session-adapter-turns.test.ts
  • src/main/claude/claude-structured-session-adapter.ts
  • src/main/claude/claude-structured-session-exit-blame.test.ts
  • src/main/claude/claude-structured-session-exit-lifecycle.ts
  • src/main/claude/claude-structured-session-recovery.test.ts
  • src/main/claude/claude-structured-session-startup-state.ts
  • src/main/claude/claude-structured-session-state.ts
  • src/main/codex/codex-app-server-connection.test.ts
  • src/main/codex/codex-app-server-exit-error.test.ts
  • src/main/codex/codex-app-server-exit-error.ts
  • src/main/codex/codex-app-server-record-dispatch.ts
  • src/main/codex/codex-app-server-request-error.ts
  • src/main/codex/codex-structured-compaction-refusal.test.ts
  • src/main/codex/codex-structured-dispatch-admission.test.ts
  • src/main/codex/codex-structured-session-acquire.ts
  • src/main/codex/codex-structured-session-adapter-lifecycle.test.ts
  • src/main/codex/codex-structured-session-adapter.test.ts
  • src/main/codex/codex-structured-session-adapter.ts
  • src/main/codex/codex-structured-session-close.ts
  • src/main/codex/codex-structured-session-exit-blame.test.ts
  • src/main/codex/codex-structured-turn-start.ts
  • src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts
  • src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts
  • src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts
  • src/main/native-chat/agent-session-journal/journal-open-failure.test.ts
  • src/main/native-chat/agent-session-journal/journal-open-failure.ts
  • src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts
  • src/main/native-chat/agent-session-journal/journal-reducer.test.ts
  • src/main/native-chat/agent-session-journal/journal-reducer.ts
  • src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts
  • src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts
  • src/main/native-chat/agent-session-journal/journal-row-builders.ts
  • src/main/native-chat/agent-session-journal/journal-row-schema.ts
  • src/main/native-chat/agent-session-journal/journal-store-close.test.ts
  • src/main/native-chat/agent-session-journal/journal-store-contracts.ts
  • src/main/native-chat/agent-session-journal/journal-store.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.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.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts
  • src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts
  • src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts
  • src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts
  • src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts
  • src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts
  • src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts
  • src/main/native-chat/agent-session-wire/structured-conversation-command.ts
  • src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts
  • src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts
  • src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts
  • src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts
  • src/main/native-chat/agent-session-wire/structured-session-compaction.ts
  • src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts
  • src/main/native-chat/claude-structured-managed-account-support.ts
  • src/main/native-chat/structured-agent-session-history-adoption.ts
  • src/main/runtime/agent-session-conversation-command-record.ts
  • src/main/runtime/agent-session-lease-transitions.ts
  • src/main/runtime/agent-session-operation-admission.test.ts
  • src/main/runtime/agent-session-provider-handle-transition.ts
  • src/main/runtime/agent-session-record-options.ts
  • src/main/runtime/agent-session-record-store-tab-table.test.ts
  • src/main/runtime/agent-session-record-store.ts
  • src/main/runtime/agent-session-recovery-capsule-entries.ts
  • src/main/runtime/agent-session-recovery-capsule.test.ts
  • src/main/runtime/agent-session-reservation-admission.ts
  • src/main/runtime/agent-session-surface-release-transition.ts
  • src/main/runtime/agent-session-tab-table.ts
  • src/main/runtime/claude-structured-exit-mid-response.test.ts
  • src/main/runtime/claude-structured-failed-start-resume.test.ts
  • src/main/runtime/claude-structured-resumed-start-failure.test.ts
  • src/main/runtime/claude-structured-send-held-for-startup.test.ts
  • src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts
  • src/main/runtime/claude-structured-session-integration.test.ts
  • src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts
  • src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts
  • src/main/runtime/rpc/errors.test.ts
  • src/main/runtime/rpc/errors.ts
  • src/main/runtime/rpc/methods/agent-launch-replay.test.ts
  • src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts
  • src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts
  • src/main/runtime/rpc/methods/structured-agent-session-create.ts
  • src/main/runtime/rpc/methods/structured-agent-session-gate.ts
  • src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts
  • src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts
  • src/main/runtime/rpc/methods/structured-agent-session-reveal.ts
  • src/main/runtime/rpc/methods/structured-agent-session.ts
  • src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts
  • src/main/runtime/structured-claude-runtime-adapter.ts
  • src/main/runtime/structured-claude-scripted-runtime-test-support.ts
  • src/main/sqlite/sqlite-read-failure.ts
  • src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts
  • src/renderer/src/components/native-chat/agent-session-write-notice-text.ts
  • src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts
  • src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts
  • src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx
  • src/renderer/src/i18n/en-runtime-required.json
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/i18n/locales/es.json
  • src/renderer/src/i18n/locales/fr.json
  • src/renderer/src/i18n/locales/ja.json
  • src/renderer/src/i18n/locales/ko.json
  • src/renderer/src/i18n/locales/zh.json
  • src/renderer/src/lib/structured-agent-session-launch-options.ts
  • src/renderer/src/lib/structured-agent-session-launch-prompt.ts
  • src/shared/agent-session-conversation-command.ts
  • src/shared/agent-session-failure-words.test.ts
  • src/shared/agent-session-failure-words.ts
  • src/shared/agent-session-failure.test.ts
  • src/shared/agent-session-failure.ts
  • src/shared/agent-session-journal-schemas.test.ts
  • src/shared/agent-session-journal-schemas.ts
  • src/shared/agent-session-journal-types.ts
  • src/shared/agent-session-lease-adjudication.test.ts
  • src/shared/agent-session-lease-adjudication.ts
  • src/shared/agent-session-mutation-envelope.test.ts
  • src/shared/agent-session-mutation-envelope.ts
  • src/shared/agent-session-operation-ledger.test.ts
  • src/shared/agent-session-operation-ledger.ts
  • src/shared/agent-session-refusal-details.ts
  • src/shared/agent-session-refusal-notice.test.ts
  • src/shared/agent-session-refusal-notice.ts
  • src/shared/agent-session-refusal-retry.test.ts
  • src/shared/agent-session-refusal-retry.ts
  • src/shared/agent-session-wire-refusals.test.ts
  • src/shared/agent-session-wire-refusals.ts
  • src/shared/agent-session-write-failure.test.ts
  • src/shared/agent-session-write-failure.ts
  • src/shared/agent-session-write-notice-copy.ts
  • src/shared/native-chat-types.ts
  • src/shared/structured-agent-session-dispatch-rejection.test.ts
  • src/shared/structured-agent-session-dispatch-rejection.ts
  • src/shared/structured-agent-session-latest-request.test.ts
  • src/shared/structured-agent-session-latest-request.ts
  • src/shared/structured-agent-session-outbox.ts
  • src/shared/structured-agent-session-projection.test.ts
  • src/shared/structured-agent-session-projection.ts
  • src/shared/structured-agent-session-send-disposition.test.ts
  • src/shared/structured-agent-session-send-disposition.ts
  • src/shared/structured-agent-session-status-block.ts

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

Comment on lines +274 to +275
// Reached only when an eviction's stop landed and a later step failed, leaving the conversation.
evict: providerEndFailure

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Map evict to a host fault, not a provider failure.

ENDED_CHILD_FAILURE sends an Orca-initiated eviction through providerEndFailure. When ended.failure is absent, that returns providerExited or providerStartFailed. Queued messages then read as if the provider stopped. The PR's rule is that only the child's own exit blames the provider, and host-stop already maps to { hostFault: true }.

  • src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts#L274-L275: return { hostFault: true } for evict unless ended.failure?.kind === 'providerExited'.
  • src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts#L754-L757: expect hostFault and the matching verdict for both evict cases.
🐛 Proposed fix
-  evict: providerEndFailure
+  // Orca chose to stop the child; only a recorded provider exit blames the provider.
+  evict: (ended) =>
+    ended.failure?.kind === 'providerExited' ? providerEndFailure(ended) : { hostFault: true }

Please confirm whether provider blame for evict was intentional.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Reached only when an eviction's stop landed and a later step failed, leaving the conversation.
evict: providerEndFailure
// Reached only when an eviction's stop landed and a later step failed, leaving the conversation.
// Orca chose to stop the child; only a recorded provider exit blames the provider.
evict: (ended) =>
ended.failure?.kind === 'providerExited' ? providerEndFailure(ended) : { hostFault: true }
📍 Affects 2 files
  • src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts#L274-L275 (this comment)
  • src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts#L754-L757

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No critical issues — one exit-attribution edge worth a look.

Reviewed changes

  • One wording point for host failures: agentSessionFailureWords turns a typed fact into the sentence a row or rejected message carries, beside the typed failure/rejection value; the legacy markers released clients hide are preserved for the same rows.
  • Typed failure facts and per-code refusal details: new agent-session-failure.ts and agent-session-refusal-details.ts, with compiler-checked completeness so a new kind or reason must be classified and worded before it compiles.
  • A single rejection classifier: classifyDispatchRejection is now the only reader of "was this a failure", and the sidebar check from #22944 asks it too.
  • Provider words kept apart: ProviderDiagnostic carries a person/log audience and is attached where Orca receives the provider's text; providerExitObserved marks a first-hand child exit.
  • Start-failure row and rejected messages agree: one wording point and one shared fact, with a guard so the exit settlement cannot reword a row the delivery loop already wrote.
  • Child-end outcomes table: structuredAgentSessionEndedChildFailure maps every end cause to what it means for queued messages.
  • Consumers: the renderer and mobile read the shared copy table, plus a new read-history write kind.

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

failure:
exit.session.startup.state !== 'proven'
? providerStartupFailureFact(exit.session.startup.failure ?? exit.error)
: providerExitObserved(exit.error)

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.

providerExitObserved is set unconditionally by the connection's onExit callback, and Orca reaches that callback after it has reaped a live child itself. When the SDK reader throws (or child.on('error') / stdin.on('error') fires) while the root is still alive, claude-stream-json-connection.ts calls tree.reap() — Orca's own killRoot() — without setting closing; the root's later exit event still fires onExit, so withObservedProviderExit marks it and this branch blames the provider. That is the opposite of the comment above and of the hostFault arm, which is meant to cover exactly an Orca-caused close.

Technical details
# Orca-induced reap is reported as `providerExited`

## Affected sites
- `src/main/claude/claude-stream-json-connection.ts` — reader catch (`!closing && !exited` → `void tree.reap()`), `child.on('error')`, `child.stdin.on('error')`. `tree.reap()` → `judgeTree()` → `killRoot()`.
- `src/main/claude/claude-structured-session-acquisition.ts:183-189` — the `onExit` callback marks every error it receives with `withObservedProviderExit(error)`, so a reap-induced exit is indistinguishable from the child's own exit.
- `src/main/claude/claude-structured-session-exit-lifecycle.ts:100-104` — after a proven start, `providerExitObserved(exit.error)` is then always true for this route, choosing `providerExited` over `hostFault`.

## Required outcome
- A proven-start exit that Orca itself induced (fault-path `tree.reap()` / Orca kill) should not read as `providerExited`; only a child that ended without Orca killing it should. The user-facing difference is "Claude stopped while this response was in progress" vs "Orca ran into a problem, so this didn't go through."

## Suggested approach (optional)
- Distinguish the connection's fault-initiated reap from an unassisted exit (the connection already tracks `closing` for `close()`; the fault reap needs a comparable signal the `onExit` callback can consult), and skip `withObservedProviderExit` when Orca initiated the kill.

## Open questions for the human
- Is an Orca-side transport/reader fault while the child is live considered the same accepted known-weakness as the documented Codex case ("Codex killed by its own connection")? If so, the inline comment at the marking site and the exit-lifecycle comment should say that, rather than asserting only the child's own exit blames the provider.

…gh the typed refusals

Three writers that arrived with the merge wrote refusals the old way:

- An operation that starts the agent itself, such as a goal change, turned any error the start
  threw into a refusal whose message was Orca's own error text, which released clients print.
  It now logs the error and says only that the agent couldn't restart.
- An option picked while the chat is at rest, for a key the provider would not accept, is refused
  with the rejected-option reason, like the same pick on a running agent.
- A restart continuation whose agent was refused a start filed the rejected message's sentence as
  the failure's reason. It files the refusal's code with its details again, which is what the
  restart-failure guidance keys on.
…s as that

The idle sweep now stops an agent whose start never finished and rejects the messages waiting on
it. The chat read "Orca ran into a problem, so this didn't go through. Try again." for that,
because every host stop was worded as Orca's own fault. It now reads "Codex never finished
starting, so Orca stopped it." in the chat's row and on each rejected message, carried as its own
failure kind so newer clients can tell it apart. The message counts as failed, like any start that
did not land.
…yped facts

The merge left two expectations on the old words: the close tests looked for the marker a close
used to write, and the host-stop test for the host-fault sentence. A close now writes "The chat
closed before this message was sent." with its fact, and a host stop the hostStopped sentence the
constructor gives, whatever reason the stop carried. Also folds the conversation command's two
imports from send preparation into one.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 8ad7028a-f41b-493f-8c86-27d06e5e0fbd

📥 Commits

Reviewing files that changed from the base of the PR and between d187bee and d07508a.

📒 Files selected for processing (79)
  • src/main/claude/claude-structured-options.ts
  • src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts
  • src/main/native-chat/agent-session-journal/journal-reducer.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts
  • src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts
  • src/main/native-chat/agent-session-wire/structured-conversation-command.ts
  • src/main/runtime/agent-session-record-options.ts
  • src/main/runtime/claude-structured-exit-mid-response.test.ts
  • src/main/runtime/claude-structured-resumed-start-failure.test.ts
  • src/main/runtime/claude-structured-send-held-for-startup.test.ts
  • src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts
  • src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts
  • src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts
  • src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts
  • src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts
  • src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts
  • src/main/runtime/rpc/methods/structured-agent-session.ts
  • src/renderer/src/i18n/en-runtime-required.json
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/i18n/locales/es.json
  • src/renderer/src/i18n/locales/fr.json
  • src/renderer/src/i18n/locales/ja.json
  • src/renderer/src/i18n/locales/ko.json
  • src/renderer/src/i18n/locales/zh.json
  • src/shared/agent-session-failure-words.ts
  • src/shared/agent-session-failure.ts
  • src/shared/agent-session-journal-schemas.ts
  • src/shared/agent-session-journal-types.ts
  • src/shared/agent-session-refusal-details.ts
  • src/shared/agent-session-refusal-notice.test.ts
  • src/shared/agent-session-refusal-notice.ts
  • src/shared/agent-session-write-notice-copy.ts
  • src/shared/native-chat-types.ts
  • src/shared/structured-agent-session-dispatch-rejection.test.ts
  • src/shared/structured-agent-session-dispatch-rejection.ts
  • src/shared/structured-agent-session-projection.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts
  • src/renderer/src/i18n/locales/ko.json
  • src/renderer/src/i18n/locales/fr.json
  • src/renderer/src/i18n/locales/es.json
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/i18n/locales/ja.json
  • src/renderer/src/i18n/locales/zh.json

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

agent_session_item_revision_stale: true,
agent_session_already_resolved: true,
agent_session_identity_required: false,
agent_session_journal_unreadable: true,

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Tell the person to start a new chat when a start is refused because the history is corrupt.

START_REFUSAL_RESUMABLE marks agent_session_journal_unreadable as resumable for every reason. couldNot therefore adds startRetry(context) ("Send your message to try again.") even when fact.refusal.details.reason === 'journalCorrupt'. src/shared/agent-session-refusal-notice.ts says that no retry reads past damage (journalCorrupt: causeWords('historyUnusable', 'hostFinding')). The two sentences for the same chat disagree. The status-row sentence promises that a retry can work, and that retry fails the same way.

Check the reason before the code-level lookup. This is the same approach the function already uses for claimConflicted.

Proposed fix
     const code = fact.refusal?.code
-    return code && !START_REFUSAL_RESUMABLE[code]
+    const corrupt = fact.refusal?.details?.reason === 'journalCorrupt'
+    return corrupt || (code && !START_REFUSAL_RESUMABLE[code])
       ? `${failed} ${START_NEW_CHAT}`
       : `${failed}${startRetry(context)}`

Also applies to: 125-128

@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 in the latest commits — one PR-description inconsistency worth reconciling, plus the still-open exit-attribution thread from the previous review.

Reviewed changes

Reviewed the three commits pushed since the prior pullfrog review (d187bee → d07508a): the merge of main and the integration fixes on top of it.

  • Removed Orca's raw error text from an operation-start refusal: ensureStructuredAgentSessionAgentForOperation now logs the thrown error and returns a generic agent_session_owner_restart_failed (words "The agent couldn't restart."), instead of putting the raw exception in the refusal message released clients print.
  • Typed the at-rest option-pick refusal: a key the provider would not accept is now refuse('agent_session_operation_invalid', { reason: 'optionRejected' }), matching the same pick on a running agent.
  • Filed the restart-continuation refusal's code and details: refusedBy now reads the refusal reference off the rejected message (readAgentSessionFailureFact(rejection)?.refusal) and the resume host throws it as AgentSessionRefusalError, so the failure ledger keeps the code and its details again — which the renderer's resumeFailureGuidance keys on.
  • Typed a host-stopped hung start as hostStopped: structuredAgentSessionEndedChildFailure maps host-stop to the new hostStopped fact (sentence " never finished starting, so Orca stopped it.") instead of the generic hostFault, with classifier (undelivered, verdict failure), copy table, and test coverage.
  • Cleared merged test debt: pinned the close and host-stop rows to their typed facts and awaited the now-async journalSnapshot/history readers.

I ran the touched suites locally (dispatch-rejection, operation-start-refusal, delivery-loop, provider-child-record, restart-continuation, restart-ownership, restart-offer-withdrawal, restart-failure-filing, refusal-details, failure-text) — all green.

ℹ️ hostStopped is classified as a failure, which the PR description says it must not be

The PR's Notes section says a hostStopped rejection kind should be added "with no verdict (an Orca-chosen stop is not a failure)", but the classifier gives it verdict: 'failure', so the sidebar check from #22944 will report the last request as Failed. The commit message deliberately says the message "counts as failed", so the code is almost certainly the intended half and the description is stale — worth reconciling so the next reader knows which rule holds.

Technical details
# `KIND_VERDICT.hostStopped` contradicts the PR body's stated rule

## Affected sites
- `src/shared/structured-agent-session-dispatch-rejection.ts:126` — `hostStopped: 'failure'` in `KIND_VERDICT`.
- `src/shared/structured-agent-session-dispatch-rejection.test.ts:57` — pins `['hostStopped', 'undelivered', 'failure']`.
- PR body, "Notes" section — "adds `hostStopped` to SubmissionRejectionKind with no verdict (an Orca-chosen stop is not a failure)".

## Required outcome
- The description and the classifier agree on the verdict. If `failure` is right (a send Orca stopped because the agent hung did not reach the agent, so the user must resend), update the notes; if "no verdict" is right, the sidebar should not flag this send.

## Notes
- No code change requested if `failure` is intended; this is a description/consistency question only.

</details>

<!--
Pullfrog review metadata. These findings were written against d07508a;
if commits have landed on brennanb2025/chat-failure-facts since, treat every specific bug, file, or
line callout as POTENTIALLY STALE and re-diff before acting on it.

- Mode: IncrementalReview (delta against prior pullfrog review)
- Files reviewed: 235
- Commits reviewed: 3
- Base: main (6835b9b4e3)
- Head: brennanb2025/chat-failure-facts (d07508a)
- Reviewed commits:
  - ad336b38b8 — fix(native-chat): route the conversation-outlives-agent writers through the typed refusals
  - 2b4f8c3549 — fix(native-chat): a start Orca stopped because it never finished reads as that
  - d07508afa3 — test(native-chat): pin the merged close and host-stop rows to their typed facts
- Prior pullfrog review: d187bee (https://github.com/stablyai/orca/pull/23116#pullrequestreview-5334961155)
-->


<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->
<sup><a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>&nbsp;&nbsp;| [Fix it ➔](https://pullfrog.com/trigger/stablyai/orca/23116?action=fix&review_id=5335673353) | [View workflow run](https://github.com/stablyai/orca/actions/runs/36394515683/job/108837898950) | Using `DeepSeek Flash` (free via [Pullfrog for OSS](https://pullfrog.com/for-oss)) | [𝕏](https://x.com/pullfrogai)</sup>

Reads now reach a chat through one accessor, which refused a missing record and a provider this
host does not run as a bare code with nothing beside it. Revealing the same chat already names
those reasons, so a client could tell "this chat no longer exists" and "update Orca" apart there
but not on the history or subscribe read that follows. The accessor now throws the same typed
refusals. The wire code and message are unchanged; the reason rides only in the error's data,
which released clients ignore.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No critical issues — the delta is a small, correct consistency fix; one sibling-read gap is noted in the body.

Reviewed changes

Reviewed the one commit pushed since the prior pullfrog review (d07508a → f6bca9f).

  • Typed the read accessor's refusals: conversation now throws agentSessionRefusalError for a missing record (agent_session_identity_required / recordMissing) — both before the lock and when host.open returns null — and for an adapter that cannot run the chat (structured_agent_session_unsupported / hostUnsupported), the same reasons revealStructuredAgentSession already used. Wire code and message are unchanged; the reason rides only in error.data.refusal.
  • Covered it end to end: structured-agent-session-at-rest.test.ts asserts the missing-record read returns the code plus data.refusal.details.reason = 'recordMissing', and the unsupported case returns runtime_error with message structured_agent_session_unsupported plus reason = 'hostUnsupported', and adds vi.restoreAllMocks() to teardown.

I ran structured-agent-session-at-rest.test.ts locally (15 passed) and traced the mapping in runtime/rpc/errors.ts: agent_session_identity_required is in AGENT_SESSION_RPC_ERROR_CODES (passthrough), structured_agent_session_unsupported is not — both wire shapes match the commit's claim.

ℹ️ Two sibling reads still emit the bare code with no reason

agentSession.options and agentSession.handoffStatus reach the same "missing record" and "host cannot run this chat" situations through their own lookups, which still throw new Error(code) rather than a typed refusal, so they keep returning the code with no data.refusal. The commit's goal — a read of a chat this host cannot open says why — is not reached for those two reads.

Technical details
# Sibling reads bypass the typed accessor

## Affected sites
- `src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts:27-30` — `readStructuredAgentSessionOptionsAtRest` throws `new Error('agent_session_identity_required')`; `readStructuredAgentSessionOptions` reaches it for a missing record (line 101) before it calls `context.conversation` (line 103), so `agentSession.options` still returns the bare code.
- `src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts:12-19` — `structuredAgentSessionOwnerStatus` throws bare `agent_session_identity_required` / `structured_agent_session_unsupported`, even though its own comment says "Same refusal as reveal"; `agentSession.handoffStatus` is the reader.

## Required outcome
- Both reads name the same situations with the same reasons as `conversation` and reveal, so a newer client can tell "this chat no longer exists" / "this host cannot run it" apart for options and handoffStatus too. Wire code and message stay as they are today.

## Suggested approach (optional)
- Throw `agentSessionRefusalError(code, { reason: 'recordMissing' | 'hostUnsupported' })` at both sites, mirroring `conversation-lifetime.ts` / `reveal.ts`.

## Open questions for the human
- If these two are intentionally outside this commit's scope, a follow-up ticket would keep the "reads say why" claim from looking universal.

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

…lure-facts

# Conflicts:
#	src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts
…onversation open

Every api_retry frame publishes the journal, and that publish is the activity the idle sweep reads, so a retry run revised into one row still renews the clock on each attempt.
@brennanb2025
brennanb2025 merged commit a68d629 into main Sep 28, 2026
49 checks passed
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status (update)

Head: 58cf1ee. Base is now main: #22821 merged as bfe476f, and this branch merged main in 4eb3633. #22944 has merged too, and its sidebar verdict now comes from this PR's classifier. This PR must merge before the next release, because #22821's permanent chat-history rows are on main and this PR changes what they store. CI: green at 58cf1ee (37 passed, 12 skipped, 0 pending, 0 failed), including static analysis, typecheck, all eight test shards and cross-version wire compatibility.

Review loops

Readiness checklist

  • Start of review: ran.
  • End (at 12e30c7): ready with notes, no blockers. The notes were the English start-failure sentences in translated notices (known weakness) and the unbounded nested-error walk, since fixed in 11b23cc.

Mobile compatibility

No blocker.

  • The only mobile code change is where two shared helpers are imported from.
  • Released phone builds read the curated sentences and the unchanged code words, get message plus the unchanged top-level refusal fields, and ignore the new optional details, failure and rejection fields.
  • No pairing, protocol, route or saved phone state changed. The phone does not save the send queue.
  • Newer phone builds get the grown copy table from shared code. Mobile typecheck and session tests pass.

Seen during QA

Rig: a Mac M4 Air, dev builds, Playwright over the DevTools protocol with the window hidden. A stand-in claude fails every structured start with the error line claude: not signed in (rig). The planned QA agent hit its weekly usage limit, so the final round was run by the reviewing agent over the test machine's shell with the same scripts. No codex was launched in any rig terminal pane.

Two queued messages, and one message, while the start fails

  • Before (fix(native-chat): a message is accepted, then delivered #22821): each row and the not-sent reason read "The provider stopped before it finished starting: claude stream-json exited (code 1): claude: not signed in (rig)."
  • After: "Claude stopped before it finished starting. Send your message to try again." No agent error output appears anywhere on screen.

Retry once the agent can start

  • The reply arrives and the notice clears.

Launch strip for a refused create

  • It showed the raw exception on main and on an earlier build of this branch.
  • It is now the curated sentence.
  • That path depends on a race and did not come up in the final run; unit tests cover it.

Failure sentence shown three times: not caused by this PR

Raw rate_limit rows with the retry data: fixed here

  • These were on main since feat(native-chat): Codex structured native chat restructure #16729: one error row per Claude retry message.
  • Now there is one row per retry run, "Claude is rate-limited and retrying.", with the data behind Details as log text.
  • Owner of that message type: claude-structured-provider-fallback.ts with claude-api-retry-row.ts.

Two queued messages share one not-sent notice

  • App behaviour, the same before and after; owner is the desktop follow-up.

before: queued after: queued after: retry

QA after the #22835 merge (final head, same rig):

final head: failed start stop with queued message (main bug, #23553)

Known gaps (not this PR)

  • Chat history that fails to open on a read still reaches the app as a generic runtime error with the raw text; only writes classify it. Owner: desktop follow-up.
  • A /clear that never finished stays stuck. Every later send, /compact and /clear is refused until a new chat is started. This PR only makes the refusal say so. Owner: fix(native-chat): an unfinished /clear or refused Codex rewind no longer locks the chat #23524.
  • Codex rewind the agent applied but still reports can fail every later reopen of that chat. Reachable only from programmatic clients today.
  • Details on thrown refusals are not read yet. The host sends them in the error's data. Owner: desktop follow-up.
  • English start-failure sentences show in translated desktop notices. Owner: desktop follow-up.
  • Unreadable-history pane. The words for corrupt vs unavailable history exist and are exported, but the pane still shows the raw code. It must pass the reason through so the two read differently. Owner: desktop follow-up / perf(native-chat): chat tabs list from saved state, and history opens on first use #23131.
  • A /clear whose new conversation fails before any process starts appears to throw rather than record a failed command. Older than this PR; unverified.
  • A failed /clear replacement start keeps its situation but not the agent's own words behind Details.
  • Repeating a create refused mid-start answers with agent_session_operation_invalid instead of the first answer's code. Older than this PR; follow-up recorded.
  • A close that fails after stopping the agent makes queued messages read as a failed start or exit rather than "chat closed", as on main.
  • Codex killed by its own connection (protocol violation, oversized record, broken input pipe) still reads "Codex stopped…"; Codex triggered those.
  • Unverified race. If Orca begins closing Claude before its exit event arrives, a start failure reads "couldn't restart" instead of "stopped before it finished starting". It is still honest.
  • Translations. 30 new strings were hand-translated into es, fr, ko, zh and ja and need a native check.

brennanb2025 added a commit that referenced this pull request Sep 28, 2026
…atus-reads-interrupted

Main's #23116 made the delivery loop's table of end causes the one place that says whether a child's end fails what is queued. The user's close joins it as failing nothing, and the separate disposition this branch added is folded into it. The close's rejection now carries main's "chat closed" words. Main's new tests close with 'evict', the cause main's close always used.
Jinwoo-H added a commit that referenced this pull request Sep 28, 2026
…n the host and pane (#23602)

* test(native-chat): await the async history and journal snapshot in three tests (#23560)

#22835 made history() and journalSnapshot() async; tests from #23502 and #22944 still call them synchronously, so the typecheck job is red on every PR while main pushes do not run it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(usage): show ZCode Coding Plan quota on current main (#23520)

Shows the ZCode Coding Plan quota in the status bar alongside the Claude and Codex usage readouts, reading the key from the user's own ZCode config.

Credentials are scoped tightly: the host must be an exact match in the allowlist, HTTPS on port 443 only, `redirect: 'error'`, and the key is checked for CR/LF before it reaches a header. The key itself is never stored or logged — account identity is an HMAC.

Both JSON inputs (a user-edited config file and the remote quota response) are narrowed at runtime rather than asserted, and the request cancels an unread response body on the error path so it cannot trip the undici parser crash (orca#8695).

Co-authored-by: guanbear <guanbear@users.noreply.github.com>

* fix(mobile): paired clients re-derive a kept terminal after a cold restore (#23109)

* fix(mobile): paired clients re-derive a kept terminal after a cold restore

A renderer frame published before a cold-restored terminal's PTY registered
was fenced to an empty tab list and recorded as accepted, and the renderer
never resends unchanged content. When registerPty binds a surface the
accepted frame fenced out, re-merge that frame so the fence reads current
state.

* test(mobile): drive the live desktop window through the runtime's desktop seam

* test(mobile): the re-derive path never flushes the store synchronously

* test(mobile): a re-derived frame must not bring back a surface the host retired after accept

* fix(mobile): a re-derived frame changes membership only for the registering surface

The replay re-ran the whole accepted frame, so a surface the host retired
after accept (a phone close whose remote PTY is still exiting, or a closed
chat tab) came back. Every other surface now keeps the host's current
decision; the removal repair is extracted from the terminal retirement
helper so non-terminal tabs are removed the same way.

* test(mobile): a re-derived frame must not drop or disown a phone-created terminal the desktop has not published

* fix(mobile): a re-derived frame does not infer renderer retirements from its older frame

* revert(mobile): drop the replay of a fenced renderer frame

Reverts the production parts of a88e1eaa0a, f5b99d0003 and 5af1c6d97a: the kept
renderer frame, rederiveFencedRendererSurface and its registerPty call, and the
mergeRendererMobileSnapshot / removeMobileSessionSnapshotTabs extractions. The
fence will instead read the host's saved membership record. The test file stays
and is rewritten for that mechanism.

* fix(mobile): the paired-list fence admits a terminal the saved session still lists

After a cold restore the in-memory mobile snapshot and PTY table start empty,
so in a repo with host-authoritative terminal membership the fence dropped a
restored terminal whose renderer frame arrived before its PTY registered, and
paired clients never listed it. The fence now also admits a surface the host's
saved workspace session still lists (tab under the worktree, leaf in its
layout), and registerPty pushes the listing so pending-handle turns ready at
once. A restored pane whose PTY never returns is listed as pending-handle, as in
repos that are not host-authoritative.

* test(mobile): keep the desktop window stub's type assertion on its SAFETY line

* fix(mobile): coalesce the registration push for a listed restored terminal

registerPty pushed the paired list immediately on every registration that backs a
listed surface. The desktop's graph sync after a spawn already publishes the same
pending-handle to ready flip on the 50 ms coalescing window, so each restored pane
cost two pushes, and a restore of N panes cost N immediate full-list pushes per
client. The touch now rides the same coalescing window, which still covers a
registration no graph change follows.

The test's "unchanged" sync dropped the graph's tab, which is itself a change, and
its no-extra-push assertion ran before any coalesced push could fire; both are
fixed, and a restore of two panes is asserted to push once.

The fence comment no longer claims the new clause keeps pending leaves out of the
graph: once the surface is listed, its leaves pass the shared predicate through
that listing, as any listed surface's do.

* fix(mobile): read saved membership only from the worktree's own session partition

For a runtime-host workspace, emptying the owning partition re-routes session reads to a single
other partition that still lists the worktree. If that older copy lists a surface a retirement just
removed, a lagging renderer frame could re-admit it. The saved-membership check now reads only the
partition the worktree's host names, so it never trusts a fallback copy.

* test(mobile): pin the own saved partition for every workspace host kind

Also correct the immediate-emit comment: only an exit bypasses the window; a registration's ready flip coalesces.

* Fix terminal focus when Cmd+J wakes a workspace (#23546)

* fix: retain workspace terminal focus through wake restoration

* test: reset CPU throttling after wake focus assertion

* fix: require terminal textarea readiness before claiming focus

* test: configure React act environment for dialog regression

* fix(mobile): size a terminal's first subscribe from the document's reported cell box (#23080)

* fix(mobile): size a terminal's first subscribe from the document's reported cell box

#22960 sent phone dims on a terminal's first subscribe by opening a throwaway
empty terminal (init 80x24 ""), awaiting its ready and measuring, behind a
per-document first-subscribe mark whose lifetime was tied to web-ready. That
cost a second xterm/WebGL instance and ~150 ms per open, plus lifecycle state.

The document now measures the cell box without a terminal (xterm 6's
CharSizeService strategy, rounded as the renderer rounds it) for every
text-size preset and reports it with its viewport in web-ready; a table,
because the text scale only reaches the document after that notify. Each
init's ready reports the box xterm actually laid out, which replaces the
probe's entry. The controller answers fitDimensions/measureFitDimensions from
that table and the view's layout with no message; without a table it asks the
document as before.

The session seeds an unmeasured viewport synchronously in subscribeToTerminal,
so the first subscribe carries dims by construction. Deleted: the empty init,
its awaitReady gate, deferFirstSubscribeUntilViewportMeasured and the
subscribedDocuments mark. The fit pass is unchanged and still covers a
document that reports no cell box.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): read the reported cell box through in-narrowing, not Reflect.get

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): correct the probe's cell-box guess from the box xterm lays out

The web-ready probe is a guess: building the WebGL addon creates no context,
so a context that fails on load lands on the DOM renderer, whose width is not
snapped and depends on the column count. Before, a ready box that differed was
only logged; the first subscribe had carried the wrong column count, the host
echoed it, the fit pass saw the viewport equal to the host's dims, and the grid
stayed slightly shrunk. The store also kept the WebGL width after a context loss.

The document now reports the box xterm laid out whenever it changes (from
onRender, which covers a renderer swap and a DPR change that
onDimensionsChange does not fire for, and at ready). The store replaces the
guess; when that changes the current text size's entry, the view calls
onCellBoxChange with xterm's grid and the session re-fits, running the bounded
fit pass if the dims moved (one resubscribe). Equal boxes do nothing.

The RN layout box now survives a document reload; the document's own viewport
only stands in until the view reports a layout (on the page, web-ready arrives
first). The mismatch console.log is gone, and the probe's rounding names the
xterm version it copies.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): correct each cell-box guess at most once, so a DOM renderer cannot loop

On the DOM renderer the cell width is the rounded canvas width divided by the
column count, so every re-init at new cols reported a new box. Each one counted
as a correction, a floor over floats could flip the fit between two sizes, and
each flip landed converged, which reset the resubscribe budget: an unbounded
series of full-snapshot resubscribes.

Only the first laid-out box for a guessed text size may be a correction; later
reports still update the store, so fits stay truthful, but never resubscribe on
their own. The fit's floor gains a 1e-6 epsilon so floating-point error at an
exact boundary cannot flip a column or row. New document tests pin the render
report after a renderer swap and the report at ready for a paused renderer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): make xterm the only terminal cell measurer

The document builds its real terminal before web-ready, at the app's
text scale, and reports the box xterm laid out; the first init reuses
that terminal. The page-side prediction, the per-scale guess table and
the once-per-document correction are gone. The app remembers the box
per text scale for its lifetime, so a later open at a known scale
subscribes with phone dims at once. A box that changes at the same grid
(renderer swap, pixel ratio) refits the open terminal in place.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep commands queued before the terminal WebView first loads

A subscribe sized from the stored cell box can queue init before the
native WebView reports its first load start, which cleared the queue
and left the terminal blank. Only a reload now drops queued commands.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): re-init a document that lacks the subscription's init, and fit one frame width

- Web-ready now says whether the document holds the terminal's latest init
  (a reload before the first ready drops a queued one); the session
  resubscribes any initialized terminal whose document lacks it.
- One grid fit, shared by the app and the document, fed the unrounded frame
  width React Native laid out; it keeps exact fits whole at fractional pixel
  ratios. The document's viewport-width fits are gone.
- The page builds every document at the scale the view mounted with, as the
  native WebView does.
- A new document's first cell box is compared against the grid the
  subscribe fitted from the stored box.
- The terminal built before ready stays hidden until its first init.
- The cell-box census matches glyph-measurement techniques, not names;
  the store's unused clear() is gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): build the terminal before ready only for the view shown at mount

A session mounts one terminal view per tab, and each built xterm and a
WebGL context before ready: 20 tabs made 20 contexts at load, past the
~16 a page (or Android's shared WebView renderer) holds, and native logged
32 context losses. Only the view shown when it mounts builds early now;
the rest build at their first init as before. Deferring the WebGL addon
instead would change the reported box: the DOM renderer lays out 7.8x15
where WebGL lays out 7.667x15 at the same font.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write a WebView document's start values into its page, not an injected script

Android ran the pre-content injected script after the document's own in
1 of 22 documents on the emulator; that document started with no text
scale or shown flag and built a terminal it should not have. The values
now sit in the page ahead of the document script, one source object per
start pair so a render never reloads the WebView.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): the pre-ready terminal measures and reports while hidden

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): measure only the laid-out frame, and refit on a new grid, not a new width

- A measure needs both of the frame's dimensions from React Native; the
  document's viewport-height fallback is gone, and before the first layout
  the handle answers no fit without asking the document.
- A frame width change that still fits the PTY's grid from the stored box is
  a no-op, so sub-pixel layout jitter no longer re-measures. The width ref is
  written in that effect rather than during render (react-doctor).
- One "last grid" ref: the last reported grid, or the one a subscribe fitted
  from the stored box.
- The page render rig measures through the frame it laid out, as the session
  does, and lets the replay's fit settle before its resize-refit witness.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let only the current terminal document's ready flush

A reload kept the WebView and its onMessage, so the old document's late
web-ready flushed the queue into the reloading view and the new document
got a second init. Each document now gets its own view (keyed on a
generation the controller owns), every notify carries the generation of
the view that received it, and a web-ready from a replaced document
flushes nothing and stamps nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop every notify from a replaced terminal document

One rule at the receive boundary: a notify from any generation but the
current one is dropped, whatever its type, not only web-ready.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make fitDimensions a pure question; name each generation counter

- fitDimensions no longer records the grid. A width change to a new grid
  asked it first, so the DOM renderer's report of that grid's box read as
  "same grid, new box" and refit again. Only the first-subscribe seed
  (seedFitDimensions) records the grid the document's first report is
  checked against.
- viewGeneration counts the views, readyGeneration counts web-readies.
- replaceDocument no longer resets the load flag; the load-start reset
  stays as the guard for a view that reloads itself.
- The name-based lifecycle census is replaced by a behavioural test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): typecheck the handle mocks, drop the unused cell-box get

- The two handle mocks carry both fitDimensions and seedFitDimensions,
  and the fake-timer acts return nothing, so the three test files check
  under tsconfig.test.json again.
- terminalCellBoxes.get had no product caller; the store's tests assert
  through fit.
- The load-start comment says what the controller does now.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold the grid the document has, ignore a replaced view's load start, dispose a failed pre-ready terminal

- The document reports a new grid even with an unchanged box, so an
  in-place reflow on WebGL is held before a later renderer swap at that
  grid; the swap then refits. The app's apply paths do not hold the grid
  themselves: the DOM renderer's box follows cols, and a grid held on
  apply would read its own box as a renderer change and loop. One
  writer (holdGrid) holds the seeded or reported grid.
- A load start from a view a replacement unmounted is ignored, as its
  notifies already are.
- A terminal whose open throws before ready is disposed, not only
  unreferenced.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): ignore every native event from a replaced terminal view

One wrapper binds each WebView lifecycle event (load start, error, HTTP
error, render process gone, content process terminated) to the view's
generation, so a replaced view's late event cannot reset, replace or
put an error over the current document.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): a DOM seed refits once on its first report, not on the refit's own

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): subscribe a terminal only after its document is ready

The document still builds its terminal before ready and reports the cell
box xterm laid out in web-ready; the app now subscribes after that ready
and fits from that box, so nothing is sent to a document before it is
ready. Everything that made a pre-ready subscribe safe goes: the
app-lifetime box store, the seed fit, the per-document view generations
and their event filtering, the init tracker and the hasInit resubscribe.
The native view reloads in place again and web-ready keeps main's reload
rule. Boxes are kept per view; the grid a document last reported still
guards the in-place refit against the DOM renderer's cols-dependent box.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): hold one reported cell box and the grid the subscribe fitted

The controller keeps only the box the current document last reported,
not a per-text-size store: the document re-reports on a scale change.
The subscribe after ready fits from that box and holds the grid it
fitted, so the DOM renderer's first report at that grid (a new box)
refits once in place and converges; refit and apply paths hold nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fit only a ready box at the app's scale; forget a reloaded document's box and grid

A reload keeps the document's mount scale, so a ready after a text-size
change reports a box at the old scale; that box no longer sizes the first
subscribe, which then takes the no-box path. A readiness reset drops the
old document's box and held grid, so the new document's first DOM report
at the same grid does not refit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give the terminal document its frame at init, and fit text scale over it only

A subscribe sized from the ready box sends no measure, so the document
had no frame when the text size changed and reported the pre-refit row
pitch. The app's init now carries the frame it laid out, in the fields a
measure uses; the router takes it from either. The text-scale fit reads
only that frame, with no viewport fallback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say why a frameless text-scale change skips the resize

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): one cell box per terminal notify, not an array

web-ready and cell-metrics carry `cellBox: {fontScale, cellWidth,
cellHeight} | null`; the document's `laidOutCellBox` returns one or
null and the parser validates one object. The text-scale match moves
from web-ready into `handle.fitDimensions`, the one place a box is fitted.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): fit terminals in the app from the reported box; drop the measure round trip

The app already holds the box the document reported, so the refit and
the fit pass await the init's ready and call `handle.fitDimensions`
instead of posting `measure` and waiting on `measure-result`. The
document's measure, its retries, and the measure promise and timeout go.
The document still resizes locally on a text-size change, so every grid
the app sends (init, resize, reflow) carries the laid-out frame it was
fitted to. `holdSubscribedGrid` replaces `subscribeFitDimensions`, so
the only fits are `fitDimensionsFromCell` and `handle.fitDimensions`.
The render rig reads its fit from the ready box. The recorder adapter
mounts the new handle with the same recorded effects; the goldens it
mounts move on their adapterSha256 header only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): keep the terminal frame in one ref, and notify a new width imperatively

The session held the frame in a height ref, a width ref, a width state
and the refit's own width ref. It now holds one `terminalFrameRef`
({width, height} | null until the first layout; a hidden 0x0 layout
keeps the last box). onLayout notifies a new width imperatively, as it
does height, and the refit's notify skips a width whose fit is the grid
the PTY has. `terminal-frame-width-refit.ts`, the width state and its
effect go. The subscribe's layout gate reads "no frame yet" directly.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): subscribe a held-back terminal on the frame's first layout only

`handleTerminalFrameLayout` ran on every onLayout; it now runs once, when
the frame first has a size. Later layouts only notify a new width.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): size the first subscribe inline in subscribeToTerminal

`sizeTerminalViewportFromCellBox` wrapped five lines in a 37-line
module; the subscribe now fits the ready box against the frame, holds
that grid and records the diagnostic itself. The helper's tests fold
into the subscription tests, which move to the subscription's name.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the unreachable font-size guard on the reported cell box

xterm 6.1.0-beta.303 updates the render service's cell box in the same
task that sets `options.fontSize`: CharSizeService.measure fires
onCharSizeChange, and RenderService.handleCharSizeChanged runs the
renderer's `_updateDimensions` (DomRenderer.ts:359, WebglRenderer.ts:229).
`term.onRender` fires from RenderService._renderRows after the rows
are drawn (RenderService.ts:213, CoreBrowserTerminal.ts:538), and the
document writes its text scale and the font size in one task
(text-scaling.ts applyTextScale, terminal-init.ts init). So no report
can read a box between the font and the scale; the guard and its test
go. A new test pins the real order: no report when the font is set,
the new box at the new scale on the next render.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): one start seam, no source cache, the reported box as an object

- `useState` already pins each view's WebView source at mount (a new
  test re-renders at another text scale and gets the same object), so
  the module-level `webViewSources` Map goes.
- `initialTextScale` and `buildsTerminalBeforeReady` become one
  `start(): { textScale, shown }` seam.
- `reportedCellBox` holds the last reported box and grid, not a string key.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to this branch and re-record

The terminal refit now fits in the app from the reported box and reads
one frame ref, so the recorder's terminal adapter mounts the new handle
(`awaitReady` + `fitDimensions`) and options (`terminalFrameRef`),
keeping its recorded effects. `baseline` is repinned to 21954dbd2f1, the
last commit to touch a fenced path, and every golden is re-recorded.
Proof by class against HEAD: 787 header-only, 0 body moved, 0 added,
0 deleted. Header keys moved: `baseline` on all 787, and `adapterSha256`
on the 14 goldens `terminal-mount-adapters.ts` mounts (query-reply 3,
accessory-raw-send 4, takeover-report 4, viewport-refit 3). No recorded
traffic moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): hold the reported cell box and its grid in one ref

The controller kept the box in `cellBoxRef`, the grid in a string
`lastGridRef` and wrote it through `terminal-held-grid.ts`. One
`heldRef` now holds `{ cellBox, grid }`, as the document's own
`reportedCellBox` does: web-ready writes the box, every cell-metrics
report writes both, `holdSubscribedGrid` writes the grid, and a
readiness reset clears it. Same write points, so the one-refit bound
holds; the DOM-loop and refit-once tests pass unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to the hold-rule commit and re-record

H (f00bebba483) touched a fenced path after the last repin, so
`baseline` moves to it and every golden is re-recorded. Against the
corpus before this branch's refreshes (21954dbd2f1): 787 header-only,
0 body moved, 0 added, 0 deleted; `baseline` on all 787 and
`adapterSha256` on the 14 goldens `terminal-mount-adapters.ts` mounts.
Against the previous refresh: `baseline` only. No recorded traffic moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to the main merge and re-record

The merge (b3b1b0def22) is the last commit to touch a fenced path, so
`baseline` moves to it and every golden is re-recorded. Against
97b5bb2b9ac: 787 header-only, 0 body moved, 0 added, 0 deleted;
`baseline` on all 787, and `adapterSha256` on the 14
session.diff-review-actions goldens whose adapter #22951 edited. Against
origin/main: 787 header-only, 0 body moved/added/deleted; `baseline` on
all 787 and `adapterSha256` on this branch's 14 terminal goldens.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): the terminal document holds the grid and decides each refit

The document already kept the last reported box and grid; the app kept
a mirror of both to decide the refit. Now the document decides: its
`cell-box` notify carries `{ cellBox, refit }`, sent only when the box
changes, with `refit` a box that changed at a kept grid. web-ready
records the pre-ready terminal's box at its 80x24 grid, and the first
init that reuses that terminal holds the init's grid, so the DOM
renderer's first report refits once, as the subscribe's hold did. A
re-init no longer clears the record, so a new renderer at the same grid
still refits. The app keeps one `cellBoxRef` and `holdSubscribedGrid`,
`heldRef` and the grid on the notify go. The one-refit, DOM-loop and
renderer-swap tests move to the document with the same scenarios.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): one init options object, and a frame on every grid

`init` takes `{ cols, rows, data, preserveScroll, oscLinks, frame }`
instead of six positionals, and `init`, `resize` and `reflow` (handle
and messages) require `frame: TerminalFrame | null`. The refit's reflow
check reads `!dims` alone, and the controller's test file is named for
the `cell-box` notify it now covers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): one notifyTerminalFrame for the frame's layout

The frame's onLayout made four calls and held the classification
itself. It now calls `notifyTerminalFrame({ width, height })`, and the
session's terminal-webview hook keeps the one frame ref, notifies the
height, subscribes the document held back for the first layout, and
notifies a later width change. `handleTerminalFrameLayout` is named for
what it does: `subscribeIntendedActiveTerminal`. The layout tests move
to that hook.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to the round-8 head and re-record

85d421963c6 is the last commit to touch a fenced path. Against
a676c1b65a7: 787 header-only, 0 body moved/added/deleted, `baseline`
only. Against origin/main: 787 header-only, 0 body moved/added/deleted;
`baseline` on all 787 and `adapterSha256` on this branch's 14 terminal
goldens. No recorded traffic moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): name the init option initialData, as the message does

The init option `data` becomes `initialData`, the message field's name,
so the controller passes it through unrenamed. The `preserveScroll` why
stays on the message type only, and the document test's title names the
three grids that carry the frame.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to the round-9 head and re-record

486566c82b4 is the last commit to touch a fenced path. Against
3371c397150: 787 header-only, 0 body moved/added/deleted, `baseline`
only. Against origin/main: 787 header-only, 0 body moved/added/deleted;
`baseline` on all 787 and `adapterSha256` on this branch's 14 terminal
goldens. No recorded traffic moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* ci: rerun checks against main with #23560 landed

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(ai-vault): show ZCode CLI session history (#23513)

Surfaces ZCode CLI session history in AI Vault, so past ZCode sessions show up next to the other agents' instead of being invisible.

ZCode stores sessions in the same SQLite shape OpenCode uses, so this reuses the existing OpenCode lister and parser rather than adding a second scanner — the worker only varies the agent it stamps on each row. Discovery covers the native home and any WSL homes.

SQLite rows are narrowed at runtime rather than asserted: the statement API returns untyped column values, so the declared row shape is only a claim until something checks it, and a drifted schema or a database written by another tool reaches the same code.

Co-authored-by: guanbear <guanbear@users.noreply.github.com>

* test(mobile): repin the RPC recording corpus to main after #23080 (#23565)

#23080 squash-merged a corpus pinned to its branch commit 486566c82b4, which
the squash left unreachable from main. Repin baseline to main's tip and
re-record; every golden moves only its baseline header.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* perf(ci): diff against the merge commit's first parent so PR checkouts can be shallow (#23562)

Every changed-path gate asked git for `--merge-base "$BASE_SHA" "$HEAD_SHA"`,
which needs the event payload's base SHA to be in the local graph. That is the
only reason two jobs cloned all 8127 refs' history. On a pull_request checkout
HEAD is already the merge commit, so its first parent is the base side and no
merge base has to be computed. config/scripts/git-pull-request-diff-base.mjs
resolved that for the two Node gates; the workflow's inline gates now use the
same helper through a small CLI rather than open-coding it.

code_paths gates all 22 jobs, so its checkout is charged to the start of every
one of them: measured 20.7s to 1.6s, keeping blob:none because its sparse tree
is ~7 files and leaves no blobs to refetch. Static analysis drops the filter
instead, since populating all 30,226 files makes blob:none force a second
promisor fetch: 23s to ~11s.

Verified on a real merge ref. At depth 50 the old and new forms produce
identical changed-file sets. At depth 2 the new form still works and the old one
fails with `fatal: bad object`, which is the failure a stale base would have
caused once the checkout stopped being complete.

Also drops the dead resolveBase + merge-base prelude in the changed-code gate,
whose result resolvePullRequestDiffBase already discarded on every PR.

* feat(mobile): the keyboard covers the page like a native screen, and the shell says its height (#23110)

The shell no longer shortens the WebView for the keyboard; it publishes the keyboard height like the safe-area insets, so native's keyboard lift, refit hold and dismiss key run on the page unchanged. Keyboard and inset arithmetic read the shell's OS through a host-os seam. One page-version floor (manifest pageVersion, shell floor 1) replaces per-feature accept negotiation; a page below the floor gets the existing update wall, a desktop with no bundle keeps native screens. iOS shell drops the form accessory bar and its own keyboard observers. Native session screens untouched.

* fix(agent-session): wait for in-flight session-store writes before teardown returns (#23545)

* fix(agent-session): stop lease renewal before the renewal's write lands

Clearing the renewal interval only cancelled the next tick. A tick already
past its guard still had a whole-file store transaction to commit, and the
store's transaction lock re-creates the store directory before it writes, so
that commit could land after host teardown had finished releasing everything
it touches.

`stop()` now resolves once the tick in flight has finished writing, and host
teardown's stop-lease-renewal phase waits for it. The three test harnesses
that model a host vanishing without a clean quit shared a copy of the same
incomplete shutdown; they now share one helper that waits.

The symptom was a CI flake: the refusal-oracle spec removes its temp
directory in `afterEach`, and a renewal landing mid-removal put the store
directory back, so the removal failed with ENOTEMPTY on the temp root.

* fix(agent-session): wait for the delivery loop's restart when abandoning a host

The abandon helper disposed the delivery loop and moved on. Disposing only
stops the loop's NEXT step: a step already past that check keeps going, and
the restart it runs for an accepted send reserves an owner, which is a store
commit. The store re-creates its own directory before every commit, so that
commit put the directory back under the temp-directory removal the test does
next, and the removal failed with ENOTEMPTY.

Quit already waits for exactly this work, in its drain-attaches phase — every
attach is registered with the task queue from enqueue. The helper now runs
the same drain, in quit's order, so it waits for both producers that reach
the store after the last awaited call returns.

Adds a regression test that holds the loop's restart inside its provider
acquisition and asserts abandoning does not return until it lands.

* chore: re-trigger PR checks

The push to 2ecc9c6e emitted no pull_request event, so the matrix never ran.

* fix(sidebar): clip worktree card content to its border (#23566)

* fix(runtime): answer terminal.subscribe at once for a pane the desktop already has mounted (#23512)

* fix(runtime): answer terminal.subscribe at once for a pane the desktop already has mounted

A mobile subscribe to a PTY with no headless model asked the renderer to
mount its tab and waited for a newer serializer settle. The renderer drops
mount requests for tabs it already has mounted, so a reattached daemon PTY
whose restored provider snapshot outranked the live renderer held the reply
for the full 3 s deadline. A live renderer screen now proves attachment and
is adopted directly; an unmounted pane still requests the mount and waits.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): treat any renderer answer, even a blank screen, as an attached pane

A fresh shell that has printed nothing has a registered serializer and an
empty screen; requiring non-empty data sent it back through the dropped
mount request and the 3 s wait. A blank screen skips the wait but does not
replace the chosen snapshot, so a parked pane cannot erase provider history.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): treat a mounted pane with unsettled output as attached

The stable renderer snapshot returned null both when no renderer answered
and when output advanced under every retry, so a desktop pane printing
continuously still took the dropped mount request and the 3 s wait. It now
returns a typed outcome (settled, moving, absent); moving skips the mount
and publishes the chosen snapshot, and late recovery still requires settled.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): adopt only a renderer-ordered screen when probing a mounted pane

The attachment probe read the terminal before knowing it would adopt, which
can reach the provider snapshot on the unmounted path; the read now follows
the decision. A seq-less renderer screen would replay every buffered chunk
on top of itself, so the probe keeps the chosen snapshot for it. The probe,
adopt and mount wait move into their own module to stay under the line cap.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): adopt a seq-less renderer screen when no output is pending

The seq gate only prevents a double replay of buffered output, so a settled
non-blank screen without a seq is safe when nothing is pending. That keeps
the better screen for a pane right after a deferred cold restore, before it
is renderer-ordered. The rule now applies after the mount wait as well.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(runtime): decide renderer attachment from the host's serializer flag

The mounted-pane probe re-derived attachment by serializing the renderer up to six
times, which cost ~7.5 s for a registered but unresponsive renderer on a busy PTY.
The host already holds that fact in the serializer readiness flag. The flag is never
cleared when a pane closes over a live PTY, so one null serializer answer falls back
to the mount wait: worst case is the old 3 s plus one 750 ms serialize.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(runtime): let the serializer's answer alone prove renderer attachment

serializeRendererTerminalBuffer already answers null when the host's serializer flag is
unset, so the separate flag accessor was redundant. The numeric-seq adopt test now
replays only the byte past the seam.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* Revert "perf(ci): shard the anti-slop audit across processes instead of one JS runtime (#23543)" (#23575)

This reverts commit fae0ae7a464c2991bd25885e7e61689c7f471a0f.

* perf(ci): cache pnpm verification records on Linux (#23568)

* perf(ci): pilot pnpm verification record caching on Linux

* test(ci): review pnpm verification record in mobile cache audit

* fix(native-chat): the host writes chat failures for a person, with a typed fact beside them (#23116)

* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stalled snapshot drain without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stand-in roster decoder without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* fix(native-chat): name a chat write by its target, not the owner generation

A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.

Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.

Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.

* fix(native-chat): every journal append reaches the chats that are open

A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.

A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.

* test(native-chat): an epoch replacement reaches the open chat

* test(native-chat): each row reaches an open chat once, and a live handle enters only through the map

* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite

The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.

* test(worktree-activation): restore the OMP surfaced-agent resume test

The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.

* perf(native-chat): a publish behind a delivered commit reads nothing

Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.

* test(native-chat): state why the teardown test's fake journal is safe to cast

* docs(native-chat): say mutation admission checks only the writer lease

* docs(native-chat): drop the send rebase from comments that still described it

* fix(native-chat): a message is accepted, then delivered

A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".

A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.

Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.

A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.

Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.

* fix(native-chat): settle queued messages only for the child that ended

A child that proved its start and then exited before its message was handed
over left the message queued: the exit settlement returned early when nothing
else was in flight. Delivery then started another child for it, and a child
that died the same way started another, without end and without a row.

A retried settlement for an earlier generation, run by the attach that
delivery started, did the opposite: with that generation's turn unfinished it
rejected the message queued for the child being attached.

The settlement now takes the rejection for queued messages from its caller.
The unexpected exit and the eviction pass one, and it applies even with no
other work in flight; the retry for an earlier generation passes none.

* fix(native-chat): an adoption that fails to import keeps the conversation open

The attach now writes into the conversation's own open journal, but a failed
transcript import still closed it as if it were the attach's provisional one.
The conversation stayed indexed with a closed journal, so every later send
answered "could not be recorded" and every attach failed again until the app
restarted. The import now closes only a journal the attach opened for itself.

* perf(native-chat): the recovering open reads the journal once

Every conversation open now goes through the recovering open, including the
read restore of every chat at startup, which used to replay its journal once.
The recovering open replayed it twice: once to probe it and again inside the
open. The probe is now handed to the open as its load.

* fix(native-chat): an attach that fails after indexing its child leaves no child behind

A failed attach now keeps the conversation open, but a failure after
`onAttached` indexed the child (the rewind or compaction recovery, or the
attach's own success record) left that entry claiming a child the failure
path had already released. The next send found the phantom, skipped the start,
and wrote at a fence the journal had moved past, so the message stayed queued
for good. The entry now drops the released child and its event sink, and
follows the record's fence, as a failure before indexing already did.

* fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer

The error strip for a message the host accepted and then did not deliver matched the entry before
the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not
send your message" with nothing to retry. It now reads the reconciled entry.

A rejection the journal records before the send's own pending answer lands is final as well:
that answer no longer puts the entry back to dispatching with no Retry.

* fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down

The preamble waits for its submission to be delivered while the worker's agent starts. When that
wait ran out it threw operation_unknown, and the failed-start teardown then closed the session,
which rejected the very preamble the host was about to deliver. It now reports a turn start
nobody observed yet: the worker is start-unknown with its session kept, the host delivers the
preamble when the agent starts, and the worker's report settles the dispatch as for any
unobserved start. The receipt no longer suggests reading a screen a structured worker lacks.

* fix(native-chat): a message rejected while its chat was closed reads as not sent

A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it
meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked
every later message behind a Retry and no reason, and the delivery probe, seeing the journal
already answered, never ran. The reconcile now settles it as rejected like a dispatching one.

* test(orchestration): name why the readiness settlement fakes are cast

* fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent

* docs(native-chat): drop the fence from the admission the send effects run behind

* docs(native-chat): give the fence move on release the reason that still holds

* docs(native-chat): stop citing a write fence check in launch and mailbox comments

Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease.

* refactor(native-chat): the provider child is its own record

A conversation now outlives any number of provider children, so the child is one record on the
conversation's entry instead of five loose fields beside its journal. It is written in one place:
indexed only once an attach has fully succeeded, and ended through one function that an exit, a
failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence.

- A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence
  patch after it are gone.
- Conversation writes read the record's fence, the way mutation admission already does; a child's
  own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of
  the conversation's fence, are gone.
- The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer
  dropped when an attach replaced the whole entry.
- Stop on a child still proving its start stops only the child: its lease goes back and the chat
  is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus
  the conversation's close.
- The settlement retry uses the conversation's own journal, opened through the host's one open.

* fix(native-chat): the delivery loop alone settles a message its start or child failed

A queued message was settled by whichever path happened to end the child first: the loop, the
unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that
rejected every pending row. That gave two failure rows with different tones for one start, a loop
that could hand over to a different child than the one it waited on, and a Claude start that died
while starting reading unlike every other failed start.

- The loop remembers the child it waited on. At handover, if that child is gone or replaced, it
  reads how it ended: a Stop continues; anything else writes one failure row and rejects every
  queued message with the same words, then stops. A child still starting whose start the adapter
  says did not land fails the same way. The exit, eviction and the settlement retry only settle
  the handed-over and legacy rows of the child that ended.
- One failure row, always an error, keyed by the start. A start a view began that dies with
  nothing queued writes the same row through the same builder, so a second report revises it.
- The open no longer rejects leftovers; the loop's first step does, and the open wakes it.
- `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the
  failure before the exit is processed.
- Quit closes every conversation the way closing a chat does: what is still queued is rejected as
  closed, with or without a child, and a start the loop already has in flight is waited for so the
  child it produces is stopped rather than left behind.

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

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

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

* 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

* 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`.

* feat(native-chat): a typed failure fact beside every failure sentence

Adds the shared vocabulary the host writes a failure with: a closed failure kind, a
provider diagnostic that says who it is for (a person, or a log), and a refusal cause
beside the refusal code. Status rows gain an optional failure fact and rejected
submissions an optional rejection fact; the dispatch row carries it, the reducer reads
it field by field, and the projection forwards it. Older rows and older readers are
untouched: every field is optional and the schemas stay open.

* fix(native-chat): durable failure rows and rejection reasons are written for a person

Every host writer that records a failure now writes a sentence for a person beside a
typed fact, instead of embedding a refusal's message, an exception or a composed exit
string. A provider's own words travel as a separate diagnostic from the places Orca
composes them - the Claude and Codex exit stderr (a log), Codex's JSON-RPC message,
Claude's compact_error and Codex's turn error (for a person) - and are never inferred
from a string afterwards. Not signed in and oversized history are typed at the
adapter that detects them.

Covers start and restart failures, the delivery loop, dispatch rejections (content,
queue-full, write failures, provider refusals), cancel and answer confirmation rows,
compaction, the rewind fallback, and not_delivered, which released clients printed
as it was. Two leaks close on the way: a settlement retry no longer writes Orca's
probe evidence into the exit row, and an attach or journal-sink failure is recorded
as Orca's fault rather than as the provider stopping. The legacy rejection markers
and the reasons on sends in doubt stay byte-identical.

* feat(native-chat): refusals name their cause, and a failed start is worded in one place

A refusal now carries an optional cause beside its code: one closed enum of the situations
a chat write can meet, set at every emitter a structured-chat write reaches. Returned
refusals build it with refuse(code, cause, message). Store and host paths that raised a
bare Error(code) now throw AgentSessionRefusalError, whose message is still the code and
which has no code property; the RPC error mapper handles it before any other passthrough,
keeps today's wire code and message byte-identical, and adds { refusal: { code, cause } }
to the error's data. The hold throws it, and restart-resume files the cause beside the
unchanged reason. The operation ledger stores the cause beside the code, so a replay names
the same situation as the first answer. The sto…
brennanb2025 added a commit that referenced this pull request Sep 29, 2026
The compaction tracker files #23116 edited stay deleted; their typed failure
facts move into the command-turn modules:
- a compaction's result row carries `compactionFailed` (with the provider's
  words for a person) or `compactionUnconfirmed`, from both translators;
- a provider refusal at handover rejects the message with its typed fact and
  words its row as the compaction failing;
- a queued command the chat's state refuses at handover is rejected as the new
  `commandRefused` kind, carrying the refusal;
- an older build's prepared /compact replays as `compactionUnconfirmed`.
Rows keep their turn scope and error tone beside #23116's words and facts.
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