Repository navigation
fix(native-chat): a failed start is shown on its message and retried, and later messages go ahead - #24340
fix(native-chat): a failed start is shown on its message and retried, and later messages go ahead#24340brennanb2025 wants to merge 102 commits into
Conversation
…aits out A `pending` dispatch row may now record a failed start, which puts its message back in the queue with the words, the typed fact and the next try. The reducer counts the attempts from those rows, and the field is optional on the wire, so older readers drop it.
…d try it again The delivery loop is now the only writer of a failed start. A child that exits before it proved its start writes nothing; it ends and wakes the loop, which records the failure on the message the start was for and on any message handed to that child, which took nothing. The message tries again at 15 s, 1 min and 5 min, then is rejected in the same words; one only the person can clear is rejected at once. Later messages go on meanwhile, and a message waiting for its next try reads as failed, not working. No start-failure row is written.
…e any message It is reported pending at once, with no note and no restart-list entry: the message itself says why and tries again, so the list never offers a Retry that could send it twice.
…t it The start resolves the agent's command where it always has; when neither that nor the spawn's own PATH and home hold it, the start is refused before any spawn as `providerMissing`, which only the person can clear, so the message is rejected at once with "<Agent> isn't installed. Install it, then retry." A spawn that fails after the command was found stays a start worth trying again.
Whoever sent it, the message shows its failed start in the reader's language and that Orca will try again shortly, with no Retry of its own until its tries run out.
…rt-failure-on-message - Delivery loop: keep main's newSession cause (a chat no agent ever ran failed to start, not restart) and this branch's per-message failure (startedFor, generation, continue). - Delivery notices: take main's admission module and failedHere argument; keep the retrying notice. The outbox's admission moved to its own file on main; the comment about a failed start waiting for its next try moves with it. - The replacement's first-send test now follows the per-message design: a start that fails for good rejects the message with no row; one that can clear keeps it waiting and starts it again.
…so tells you to try again A message waiting for its next start read "A Claude account switch is in progress. Try again after it finishes. Orca will try again shortly.": it told the person to retry and said Orca would. The account-switch sentence carried its "try again" step inside one translated piece, so the surface's flag that leaves out retry steps could not drop it. The piece is split, and a new words context, orcaRetries, says Orca starts the message again on its own: it implies retryControl and also leaves out when to try again. The waiting notice and the host's own sentence on the waiting message (startFailure.reason, the fallback for a fact a client cannot read) both use it. The rejection after the last try, beside its Retry, keeps "Try again after it finishes."
…one that ran and failed is rejected at once Every failed agent start used to leave its message waiting and try it again at 15 s, 1 min and 5 min, including starts that ran on this host and failed: the agent exiting while it started, a failed spawn or attach, a hung start Orca stopped. The common pattern retries only a start refused before it ran and fails a start that ran at once, with its reason and a Retry. `isResumableStartFailure` stays the one decision, now by how far the start got: a typed account switch, or a refusal code before handoff (owner reconciling, an operation conflict, ownership not settled), waits for its next try; a start that ran here (`agent_session_operation_invalid`, `owner_restart_failed`, no refusal at all: providerStartFailed, startFailed, hostStopped, providerExited, hostFault) is rejected at once. One table, `START_REFUSAL_STAGE`, now holds both what the words read (only a new chat continues) and what the retry schedule reads. Messages handed to a child that never proved its start are rejected with that start's failure, never in doubt. Since a handover is only ever followed by a host-side failure, a try that fails after its handover is final, so the attempt count can no longer reset into endless 15 s retries. A later message no longer waits on a failed-start child's exit when a retry is booked: it ends that child and starts afresh.
… only when it can go Two wake paths ran the delivery loop for nothing while a message waited out a refused start: - A try that came due while a /compact ran was held by the command, and the loop re-booked a 0 ms wake for it, about 500 serialized no-op steps a second until compaction ended. The loop now books a wake only for a try due in the future; the command's end already wakes it for one that is due. - Every journal commit, such as each streamed chunk of another turn, woke the loop and re-armed the retry timer. A commit now wakes the loop only when some message can go now; the booked wake covers the one that waits.
…age, and never as resumed After a restart, Continue for a chat whose agent then failed to start had two outcomes for one cause. A start that failed for good was filed in the restart list and noted "Orca couldn't continue this chat" beside the message that already said why; one waiting for its next try was filed nowhere, and the toast counted it as "Resumed 1 chat and asked it to continue". A continuation whose agent did not start for it now follows the rule for any message: the message says why, the restart list files nothing and the chat gets no note. Its outcome carries `startFailed` (optional, so older clients ignore it), and the desktop toast never counts such a chat as resumed: one rejected for good reads "couldn't be resumed", and one still waiting to start again is announced by neither toast, since its message says Orca will try again. Tests that used a failed start only to get a filed record now get it from the agent refusing the continuation after it started.
…mposer A /compact whose start was refused before it ran waits in the conversation for its next try, and its message says why. The host answered it `unknown` with no error, which the desktop reads as not accepted, so "/compact" stayed in the composer with no explanation and sending it again was refused. The host now answers it as started, which is what `completed` means for this reply, so the composer clears as for any command that went into the chat.
…start A message waiting out a refused start is not work in progress, so the composer offered no Stop, and the message could not be withdrawn: it stayed until its tries ran out, and /clear, /compact and rewind answered "Resolve pending or unconfirmed messages" with nothing to resolve. A host that stops the conversation already withdraws a waiting message on a Stop naming no turn, so the desktop now offers Stop while one waits.
…ished reads Failed and notifies A message waiting for its next start lets later ones go first. When a later message's turn succeeded and the waiting one then ran out of tries, the session's verdict stayed the later turn's: the sidebar read Done and no failure notification ever came, because the latest request was the last in the conversation rather than the last to settle. A send that failed after the latest request settled is now the newer verdict, so the sidebar reads Failed and the completion feed notifies once. A running turn still wins, and a failure that settled before the later turn ended still leaves that turn's verdict, as before.
…new message failed Hosts up to v1.4.218 wrote a failed agent start as a row of its own, and the desktop shortened the notice on a message that start rejected to "Your message was not sent." because the row above already said why. The pairing was by failure alone, so once this build stopped writing those rows, any old row silenced every later failure equal to it: a chat that was signed out once showed no reason the next time, and a failed /compact said nothing at all. A message now pairs only with the row of its own start, which the older host wrote after the message was sent and no later than its rejection. A command's reply pairs only with a row that appeared while it was being sent.
…e the chat's queue An operation that needs the agent (a goal change, a rewind, a send or command that must settle a rewind first) waited for a Claude start to prove itself while holding the chat's serialized queue. Claude's startup has no timer of its own, so a CLI that neither answered nor exited held that chat's queue for good: no Stop, no send, and the idle sweep, which steps every chat in turn, stalled for all of them. The start is still made inside the queue, but a child left proving its start now marks the preparation `startPending`, and the call leaves the queue to wait for it, as the delivery loop already does, then re-enters and finds that child there. A start that failed answers the operation with why, as before.
…rt-failure-on-message # Conflicts: # src/main/ssh/orcad-remote-node-runtime.ts
…s a failed start after main's merge
… holds the cards behind it A message queued mid-turn becomes a card, and a card the host returns blocks every card behind it until the person acts. Since a start that fails on the host is now rejected at once, a card whose agent failed to start was returned and held the whole queue, although later messages are meant to go ahead after a failed start, as plain sends do. `queuedCardHoldsQueue` now decides it in one place for the host's drain and Resume, the desktop's card labels and the phone's: a card returned for something only the person can resolve (its content, attachments or command refused, a full queue, a failed hand-over or Orca fault, the agent stopping before taking it) still holds the queue; one returned because its agent failed to start (`isFailedStartRejection`) holds nothing, and each card behind it starts the agent for itself. Mixed versions: an older desktop or phone labels the cards behind such a card "a message ahead needs attention" while this host sends them, and they leave the list as they go. An older host still holds them, which a newer client shows as plainly queued.
…tart skips the note and the restart list A continuation rejected before its handover was marked `startFailed`, which skips the chat's note and the restart list's filing, whenever its rejection read as a failure. That included rejections that are not a start failing, such as a full queue. It now uses the same `isFailedStartRejection` the queued cards read, so any other rejection is noted and filed as it was before.
…agent fails and tells its Run A structured worker's first turn is its task, sent as a preamble. When the worker's agent had not taken it within the readiness wait, the start was left unknown and the coordinator told to look. If the host then rejected the preamble for good (its start refused until the tries ran out, or a start that failed later), nothing read that rejection: the dispatch stayed unknown and the coordinator never heard that the worker never had its task. A held preamble is now watched for its own verdict through the send settlement waiter, with a new `verdict` point that waits through the retries of a refused start. On a rejection after the start was left unknown, the start is settled as a preamble rejected during the start already is: the worker's session is torn down and the start failed (`failWorkerStart` now also takes a start left unknown), and the Run gets one escalation, as for a worker that exits unexpectedly. Nothing happens if the preamble lands, or if the worker reported, stopped or was abandoned first.
…handed over A message handed to an agent whose start then failed is rejected with that start's failure, but kept the handover time its earlier `pending` row set. The queued-draft echo check reads "rejected after a handover" as "may have been written": a card retried after such a failure and then sent back to waiting counted that first hand-off as spent, so a matching provider echo could delete the card. The child never proved its start, so it took nothing it was handed. The reducer now drops the handover time when a rejection is a failed start (`isFailedStartRejection`), so every reader of the submission gets that truth from the row itself; a provider refusing what it was handed keeps its handover.
…stalled CI went red in every runtime test that runs a fake Codex or Claude: the check that a CLI is installed sat in the launch resolver, which every host shares, so it read the machine's real PATH even when a test supplied its own connection. CI runners have neither CLI, so each fake start was refused as "isn't installed"; a laptop with codex on PATH passed. The check now runs in the real child opener (`openCodexStructuredChild`, `openClaudeStructuredChild`), the default of the same `openConnection` seam a fake runtime replaces, against the spawn's own environment. A real start is still refused before it spawns when neither the spawn's PATH nor its home's version managers hold the CLI; a fake start never looks. Tested with codex and claude hidden from PATH: all 76 files that build a fake runtime or test the check pass.
…s as a failed start `isFailedStartRejection` left out `providerExited`, which the start writer records when the agent ends before the message is handed over. Such a continuation was noted and filed, and such a queued card held the queue, unlike every other failed start. It now counts; a full queue, a failed hand-over write and a provider's refusal still do not.
…ejected at once `agent_session_ownership_unknown` is what a start answers when its cleanup could not prove the process gone, or when it attached with no child to write to. Both happen after the start ran on this host, so they are host-side and rejected at once with the Retry, not tried again on a timer.
…ets its wake The delivery loop read the clock once to decide whether a waiting message could go, then again to book its next wake, and books a wake only for a try still in the future. A timer can fire a moment before the clock reaches its due time; if the clock reached it between the two reads, the step decided "not yet" and then booked nothing, and the message waited for good. The step now reads the clock once: the time it decided is the one the wake is judged against, and the delay counts from the clock when it books.
A send that failed after a later turn settled became the session's verdict. That held for a message still waiting for its next start too, so the sidebar flipped from Done to Failed and back with every failed try. Only a terminal rejection now outranks a later turn; a waiting message reads as failed only while it is the newest request.
…ger re-announces the last turn A message waiting for its next start reads as the session's failed request, and the completion feed marked it as announced. A Stop withdraws it, so the latest request fell back to the turn before it, which no longer matched the mark and was announced as just finished again. A waiting send now has no verdict for the feed: the mark stays on the request it last announced.
…nce, the moment it fails The completion feed waits while other work is owed, so it announces a batch once. With later messages going ahead of a failed start, that meant a message rejected while another was queued was never notified: the queued one ran, its turn became the newest verdict, and the person got only "finished". A send whose start failed for good is now announced through the same completion event the moment its rejection is written, whatever else is owed, and never again as the latest request. The sidebar still shows the latest state, and the chat keeps the message's Failed and Retry. One failure notification per message, alone or overtaken, and none while it only waits for its next try.
… once Follows the failed-start notification: two messages whose starts both failed on the host are each announced once, as each fails.
There was a problem hiding this comment.
Important
The prior review's finding at journal-dispatch-reducer.ts:86 is unchanged by this head's two commits: a providerExited rejection can still strip handedOverAt and make a message the provider may already have taken re-sendable through Retry in place. Details inline.
Reviewed changes
Re-review of the delta since the prior pullfrog review at 873afe7c5 (head is now 61845dcd4):
- Merge of
origin/main(6ce8c1c) — upstream churn only; no PR-authored behavior change. - Refusal wording moved to its own module (
61845dcd) — the per-reason wording table and its types/reader move verbatim fromsrc/shared/agent-session-refusal-notice.tsinto the newsrc/shared/agent-session-refusal-reason-words.tsand are re-exported from the original, so importers are unaffected. A mechanical extraction to get under the file's line limit; no logic change.
The rest of the PR was reviewed in the prior round; this run re-verified the still-open finding against the current head and reviewed the new delta.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
| delete submission.rejectionCause | ||
| } | ||
| const handedOver = submission.handedOverAt !== undefined | ||
| if (rejection && isFailedStartRejection({ reason: row.reason, rejection })) { |
There was a problem hiding this comment.
This strip is unchanged by the two commits since the prior review, and it still treats a proven child's exit as a failed start. isFailedStartRejection is true for providerExited, but providerExited is written only when the child had already proved its start (structuredAgentSessionEndedChildFailure with duringStartup === false), so a message its row deletes handedOverAt from may already have reached the provider. Without handedOverAt, failedBeforeHandover and then isRequeueableAgentJournalSubmission become true, so Retry in place re-sends it.
Technical details
# A handed-over message can be re-sent by Retry after a proven child exits
## Affected sites
- `src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts:86-89` — strips `handedOverAt` on any `isFailedStartRejection`, `providerExited` included.
- `src/shared/structured-agent-session-dispatch-rejection.ts:178-183` — `isFailedStartRejection` returns true for `kind === 'providerExited'` and `kind === 'hostStopped'`.
- `src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts:45-52` — a startup exit yields `{ exit: ... }` (which maps to `providerStartFailed`); a non-startup exit yields `{ failure }` / `providerExited`.
- `src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts:251-258` — the `!awaitedChild` branch passes `submissionsHandedToChild(awaited.fence)` to `fail` without checking `ended.duringStartup`.
## Required outcome
A message handed to a child that proved its start must not become `isRequeueableAgentJournalSubmission` off a `providerExited`/`hostStopped` rejection; Retry in place must not re-send a message the provider may already have taken.
## Suggested approach
- Narrow the strip to genuinely pre-handover failures (exclude `providerExited`; a startup exit is already `providerStartFailed`), or
- In `handOver`'s `!awaitedChild` branch, include `submissionsHandedToChild(...)` only when `ended?.duringStartup` is true.
## Notes
- The ordinary unexpected-exit path masks this: `settleStructuredAgentSessionDeadGeneration` calls `markPendingSubmissionsUnknown` before `endChild()`, so pending handed-over rows become `unknown` and `submissionsHandedToChild` sees none. The exception is the early return at `structured-agent-session-unexpected-exit.ts:89-94` (`record.lease.handoffStage !== null`), which ends the child with no settlement (and no `wakeDelivery` for a non-startup exit); a settlement-write failure at `dead-generation-settlement.ts:177-180` leaves the same state.
- `handoverRecorded` does not protect: it is true for every host-accepted send (`structured-agent-session-turns.ts:143`).
- `journal-reducer-start-failure.test.ts:134-162` pins `providerStartFailed` and `hostStopped` but not `providerExited`.
- The same strip skips `placeQueuedMessageAt` (because `handedOver` is read before the delete), so the message keeps its handover position while `resolvedAt` moves to the rejection.…t records it, as before this PR Undoes the interim that made every launch-prompt caller wait for the agent to take the prompt: - callers (notes, annotations, fix checks, recovery, work items, QuickLaunch) are told at record again; - a launch prompt rejected for good keeps the chat's own Retry; Stop restores it to the composer; - the phone's agent.launch answers journaled at record again, with no 60 s wait or unknown refusal; - copy back to "Install it, then retry."; the "Send it again from where you started it" and "didn't take the prompt" strings are gone. Only the GitHub/GitLab review reply will wait for the agent, derived from the message (next commits). Files are back to #24340's head (873afe7) where only the interim had changed them.
… that advertises running it With #24340's route, a structured chat now starts on whichever host owns the workspace, a paired server included, and an older server's strict send params would refuse the whole launch prompt. The host now advertises agent-session.review-reply.v1, and the checks panel plans the reply only for a chat whose host does (this client's own always does). A chat on an older paired server gets the prompt without it, and the panel writes the replies itself once the host has the message, as it did before this PR.
…rt-failure-on-message # Conflicts: # mobile/src/session/use-mobile-structured-agent-session.ts # src/main/codex/codex-structured-question-order.test.ts # src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts # src/main/native-chat/agent-session-journal/journal-submission-fold.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-dead-generation-settlement.ts # src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.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-start-failure-row.ts # src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts # src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx # src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx # src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx # src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx # src/renderer/src/components/native-chat/NativeChatMessageRow.tsx # src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx # src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts # src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts # src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts # src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts # src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts # src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts # src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx # src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts # src/renderer/src/components/native-chat/use-structured-agent-session.ts # src/shared/native-chat-types.ts # src/shared/protocol-version.ts # src/shared/structured-agent-session-message-projection.ts # src/shared/structured-agent-session-outbox.ts # src/shared/structured-agent-session-send-disposition.ts
…ssing claude never refuses it first The fixture test merged from main spied on the stream-json connection, but the spawn now checks for an installed claude before opening it, and CI has none.
There was a problem hiding this comment.
ℹ️ No new issues in this delta — the changes are a rebase onto
origin/mainplus a test-only fix.
Reviewed changes
Re-review of the delta since the prior pullfrog review at 61845dcd4 (head is now 08bc26756):
- Rebased onto
origin/main(31a28d40) — reconciles the branch with main's independently-landed rejected-in-place projection (structuredAgentSessionRejectedShownInPlace,placeRejectedMessage,agent-session.send-answers-proof.v1) and its "Sending…" notices. The branch's own additions survive: start-retry (startRetry, 15 s / 1 min / 5 min), Retry in place (agentSession.retryMessage/agent-session.retry-message.v1/requeueRejectedSubmission), the one-writer failed-start path,providerMissing, andstructuredAgentTurnVerdict. - Conflict resolutions —
journal-dispatch-reducer.tsadopts main'splaceRejectedMessageand drops the branch'splaceQueuedMessageAt; the worker preamble now routes through main'ssendAgentTurnwhile keeping the branch'swhenUndeliveredverdict wait; the renderer notices are main's with the branch'sretriesInPlace/retryWaitsForHostwiring. - Test fix (
08bc26756) — the Claude auth-isolation fixture supplies itsopenConnectioninstead of spying on the stream-json connection, so the pre-spawn missing-claudecheck never refuses it first.
I ran the merge's integration tests locally (79 tests across the journal reducer, rejected-message placement, retry-in-place, delivery notices, and the orchestration worker): all pass. An independent trace of the delta found no new path that makes a handed-over message retryable.
The previously-raised providerExited double-send finding at journal-dispatch-reducer.ts:84-87 is untouched by this delta (the merge left that line as-is), so it is not re-opened here; it remains outstanding on its prior thread.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…rt-failure-on-message
…ller uses It wrote a failed start's row and its messages' rejections in one append; this PR records a failed start on its message from the delivery loop alone, with no row, so nothing calls it.
…rt-failure-on-message # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.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.ts # src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts # src/shared/agent-session-journal-schemas.ts # src/shared/agent-session-refusal-reason-words.ts
…ailed start is not filed Keeps the restart offer host within its line limit after the merge, with the rule in one place.
…rt-failure-on-message # Conflicts: # src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.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-delivery-loop.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-stop-event.ts # src/main/native-chat/agent-session-wire/structured-agent-session-host-types.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-unexpected-exit.ts # src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts # src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts
…t now that it has landed main added src/main/provider-process while the ratchet still allowed it to be absent, so its own retirement test failed; the lane is required now, as the test asks of the PR that adds it.
…rt-failure-on-message # Conflicts: # src/main/native-chat/agent-session-journal/journal-dispatch-reducer.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-contracts.ts # src/shared/agent-session-journal-schemas.ts
…2025/chat-start-failure-on-message
…dded to the submission-positions test
…rt-failure-on-message
…rt-failure-on-message
…rt-failure-on-message
|
Closing this stale failed-start stack and carrying forward the remaining fix. A failed start should stay with its message, offer Retry, and allow later messages to proceed. |

Supersedes #23681 (rebuilt on the common pattern: one writer, the failure on the message, later messages go ahead). Builds on #24710, now on main, which draws a message Orca accepted and then rejected in the chat as "Not sent", where it was rejected.
ELI5
When Orca couldn't start a chat's agent (Codex or Claude) to deliver your message, it gave up on every message you had queued at once, added a separate red "did not start" row to the chat, and left you to send each message again yourself, even when the cause cleared seconds later. Now the failure is shown once, on the message it happened to, with its reason. The messages you sent after it still go ahead. If the start was refused for a moment before the agent ran (for example a Claude account switch was still finishing), Orca quietly tries that message again a few times. If you have to fix something first (install the CLI, change a launch setting), or the agent ran and failed, Orca says so right away. On the desktop, Retry sends that same message again, straight away, instead of making you type it again.
What Changed
The problem you saw.
What you see now, on the desktop.
/compact, goal changes, rewinds and/clearthat need the agent wait for its start outside the chat's queue (so Stop still works) and answer with the reason if it failed; they never wait for a retry.pendingwhile Orca retries, and fails withdispatch_preamble_undeliveredand the reason once the tries run out.What you see on the phone. The phone still hands a rejected message of its own back to its composer instead of drawing it as "Not sent", as on main. A failed start of a message sent from somewhere else (the desktop, a restart's Continue, a worker) is drawn as not sent, on a host that can retry in place. The phone has no Retry-in-place button yet, and shows a message waiting for its next try as still sending, with no reason and no Stop, until its tries run out (follow-up STA-9250).
The mechanism.
AgentSessionPreSpawnError) is retried; one that ran, or may have, is rejected at once. The site that throws a pre-spawn refusal marks it when only the person can clear it (providerMissing,managedAccountEnvOverride,managedAccountUnsupported), and that start is rejected at once too.startRetryon its submission (the typed failure, the host's sentence, when it failed, the tries so far, when the next is due), written by apendingdispatch row while it is still queued. A timer wakes the loop at the earliest booked try.failedBeforeHandover, decides for every reader whether a rejected message provably never reached an agent: notifications, sent-elsewhere drawing on the phone, and Retry in place.agentSession.retryMessagere-arms such a message: arequeueddispatch row puts the same submission back in the queue, first in line, with the failure cleared. It has its own operation id, so a second press, a replay or a press on another device does nothing more.failedBeforeHandoverholds, on a host that advertisesagent-session.retry-message.v1.Why
Alternatives considered.
Differences from the common pattern
Written rules this changes.
Linked Issue
Rebuilds #23681.
Visual Proof
Live QA on this PR's merged head e477d91 (after #24710), on a hidden dev build on a second Mac with an isolated profile. Grok 1.0.46 drove it. Claude ran through a rig wrapper that can hold an account switch, sign out or fail a start on demand. The second Mac's real Codex and Claude settings were hashed before, during and after the run and did not change.
The q1 shots black out an unrelated earlier Claude API error, which named an internal host. These cases were not re-run live on the merged build; tests cover them:
On main, each of these failures shows a separate red "start failed" row, and a message sent during an account switch gets a chat-level line instead of its own notice.
Testing
Every new rule has a test that fails with the rule removed. Among them:
After merging main (#24710 and the rest): typecheck for node, web, cli and mobile and the mobile test ratchet pass; 464 test files (5193 tests) across the PR's tests and main's native-chat, journal, runtime and shared tests pass, plus 34 mobile session test files; full-file oxlint, anti-slop, the changed-lines quality gate, the max-lines ratchet, the localization checks and the RPC catalog check pass. The real Claude CLI suite was not run.
Notes
startRetryis a new optional field. Older apps drop it, so a message waiting for its next try looks like it is still sending for up to about 6.5 minutes, then shows as not sent with its reason. This changes what the host publishes; it can't be gated per viewer.providerMissingis a new failure type. Older apps read it as "not delivered" and show the host's English sentence.agent-session.retry-message.v1. While the desktop doesn't know, Retry is disabled; on a host without it, there is no Retry.