Skip to content

fix(native-chat): a failed start is shown on its message and retried, and later messages go ahead - #24340

Closed
brennanb2025 wants to merge 102 commits into
mainfrom
brennanb2025/chat-start-failure-on-message
Closed

brennanb2025 wants to merge 102 commits into
mainfrom
brennanb2025/chat-start-failure-on-message

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 79 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​4506 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1080 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3426
Prod 114 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2763 $\color{#cf222e}{\Huge{\mathbf{−}}}$​937 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1826

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.

  • You send two messages while Codex or Claude is starting, and the start fails. Both messages read "not sent", a red "did not start" row appears, and you have to send both again, even when the cause cleared seconds later.
  • On main after fix(native-chat): show a message Orca accepted and then failed to deliver as "Not sent" in the chat #24710, those messages are drawn as "Not sent" where they were rejected, with no Retry ("send it again" means retyping it). The red row is still there, and every message still queued when the start fails is rejected with it.
  • Underneath, two parts of the host could each report the same failed start (the delivery loop, and the clean-up that runs when the agent's process exits), with first-report-wins logic so the chat wouldn't show it twice. A message handed to an agent whose start never finished could end up "unconfirmed" after a restart, although that agent provably took nothing.

What you see now, on the desktop.

  • A failed start is shown once, on the message that was waiting for it, with the reason in your language. There is no separate red "did not start" row, and no "Stopped" row for the agent Orca ends because its start failed.
  • Messages you send after it are not held up: they start the agent themselves and are delivered if that start works. That includes queued messages above the composer: a queued message whose start failed no longer holds the ones behind it. A queued message returned for something you must fix (for example the provider refused it) still holds them, as before.
  • Refused before the agent ran, likely to clear by itself (for example a Claude account switch still in progress): the message says why and "Orca will try again shortly." Orca tries again after 15 seconds, 1 minute and 5 minutes. If the last try fails, the message shows as not sent with its reason. While it waits, the composer offers Stop, which takes the message back into the composer unsent. Because later messages go ahead, a message that waits can reach the agent after one you sent later; it is then drawn where the agent received it.
  • Refused before the agent ran, and only you can fix it (the CLI isn't installed, a launch setting overrides the managed Claude account, an account Claude chats can't use): not sent at once, with the step to take, for example "Claude isn't installed. Install it, then retry." No countdown.
  • The agent ran, or may have, and its start failed (signed out, history too large to restore, the process exited while starting, attaching to it failed, Orca stopped a start that hung): not sent at once, with the reason. No automatic retry.
  • Orca couldn't stop the agent's previous process (it can't yet prove a process it was stopping has exited): only the message that ran into it is not sent, with "Orca couldn't stop Claude's previous process." (or Codex's). It is never retried automatically, and it has Retry, as any failed start does. Each message after it tries the start again for itself. On main since fix(native-chat): the agent's exit ends its record, and an unconfirmed stop is joined instead of held #24862, every message still queued was rejected with it at once.
  • Retry. A message whose start failed never reached any agent, so it has a Retry that sends that same message again, now, ahead of anything Orca is still holding: the bubble goes from not sent back to sending, and there is never a second copy. This works for a message sent from your phone, by a restart's Continue or by an orchestration worker too, and for messages with images. Every other "Not sent" message keeps fix(native-chat): show a message Orca accepted and then failed to deliver as "Not sent" in the chat #24710's rule: a message Orca recorded and then rejected for another reason has no Retry, because the agent may have seen it; one Orca refused before recording it keeps its Retry. While the desktop doesn't yet know whether the host can retry in place (for example a remote host it hasn't heard from), Retry is shown but disabled. On a host that can't, there is no Retry, as on main.
  • /compact, goal changes, rewinds and /clear that 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.
  • When the agent's command can't be found on the launch environment's PATH, Orca's own PATH, or the usual install folders, the start fails as "isn't installed" instead of a generic start failure.
  • Sidebar and notifications. While a message waits for its next try, the chat reads neither Working nor Failed. Once the tries run out, the chat reads Failed and you are notified once. A message rejected because you closed the chat, quit Orca or the host restarted while it waited does not notify; it still reads Failed with its reason when you reopen the chat.
  • After an Orca restart, Continue on a chat whose agent then can't start follows the same rule: the continuation message carries the reason, there is no extra "Orca couldn't continue this chat" note, and the chat is not filed on the restart list. Other continuation failures keep the note and the filing.
  • An orchestration worker whose first message is refused before the agent ran reports pending while Orca retries, and fails with dispatch_preamble_undelivered and 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.

  • The delivery loop is the only code that records a failed start. When an agent's process exits before proving its start, the exit handler writes nothing; it ends the child and wakes the loop, which records the failure on the message the start was for and on any message handed to that child (a child that never finished starting provably took nothing). The start-failure row writer, the first-report-wins logic, and the one-append "row plus rejections" writer fix(native-chat): show a message Orca accepted and then failed to deliver as "Not sent" in the chat #24710 added for that row are deleted. Ending that child is not a Stop: it writes no Stop event.
  • Where the start failed decides whether Orca tries again: a start refused before any provider process was spawned (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.
  • A waiting message carries an optional startRetry on its submission (the typed failure, the host's sentence, when it failed, the tries so far, when the next is due), written by a pending dispatch row while it is still queued. A timer wakes the loop at the earliest booked try.
  • One predicate, 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.retryMessage re-arms such a message: a requeued dispatch 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.
  • Where a rejected message is drawn, and its words, come from fix(native-chat): show a message Orca accepted and then failed to deliver as "Not sent" in the chat #24710: the host places it at its rejection, and the desktop draws it from the host's record. This PR adds only the Retry above, for messages failedBeforeHandover holds, on a host that advertises agent-session.retry-message.v1.

Why

  • One writer means one record of each failed start: no "which report came first" logic, no duplicate rows, no row whose wording must match the message's.
  • The failure sits on the thing it happened to, the message, so the chat, the sidebar, the restart flow, the phone and a reply to a waiting client all read the same record.
  • Retrying only what was refused before the agent ran keeps the retry honest: no agent saw the message, so trying again can't send it twice, and those refusals usually end on their own. Three tries (about 6.5 minutes) means it always ends.
  • Retry in place keeps one message with one identity, so no device shows a stale copy or sends the words twice. It applies only where no agent took the message, which is exactly where sending it again is safe.

Alternatives considered.

  • Keep later messages waiting behind a failed one. Rejected: a start failure isn't caused by the message it carried, and holding everything behind it turns one failed start into a stuck chat.
  • Retry every start failure, including ones where the agent ran. Rejected: such failures rarely clear in seconds, and the retries would hide the reason for minutes.
  • Make Retry send a copy that names the message it replaces. Rejected: every device must then hide the original, two devices pressing Retry send twice, and the link lives in the journal forever.
  • Keep fix(native-chat): show a message Orca accepted and then failed to deliver as "Not sent" in the chat #24710's atomic "row plus rejections" append. Rejected: with no start-failure row there is nothing to write atomically, and keeping an unused writer is dead code.

Differences from the common pattern

  • Intended: a start refused before the agent ran is tried again at 15 s, 1 min and 5 min before it shows as not sent. Orca's pre-spawn refusals (a Claude account switch still finishing, ownership of the chat still settling) end on their own within seconds to minutes, no agent has seen the message, and the bound means the message always ends.
  • Intended: a message whose start failed has a Retry; other "Not sent" messages have none, as in the common pattern. Re-arming the same message is safe only because no agent ever took it.
  • Intended: a pre-spawn failure only you can fix ends at once instead of spending the retries.
  • Intended: a message that fails for good notifies you once, at that moment.
  • Temporary: the phone hands its own rejected messages back to the composer, has no Retry in place, and shows a waiting message as sending. Follow-ups: STA-9250 (Retry and waiting state on the phone), STA-9328 (the phone draws not-sent messages in place).
  • Temporary: a new chat whose start is refused at create keeps the chat-level "Chat could not be started" line. Follow-up: fix(native-chat): a new chat starts at rest; its first message starts the agent #24450.
  • Temporary: on a host without retry in place (older or not-yet-updated), a failed start has no Retry, as on main. It ends as older hosts age out. Follow-up: STA-9504 (a one-click resend that can't duplicate, for hosts without retry in place).

Written rules this changes.

  • The mid-turn queue: "a returned card blocks everything after it until the user acts" now applies only to a card returned for something you must fix.
  • "A rejected message is final; a retry is a new message" has one exception: a rejected message that provably never reached an agent can be re-armed in place.
  • The delivery loop: a failed start no longer stops the loop; it is recorded on its message and the rest of the queue goes on.
  • fix(native-chat): the agent's exit ends its record, and an unconfirmed stop is joined instead of held #24862's rule that a previous process Orca couldn't prove stopped rejects every queued message: it now rejects only the message that met it, and each later one tries for itself.

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.

Case This PR (e477d91)
(q1) Refused before the agent ran (account switch in progress): it waits, a later message goes ahead, then it goes out by itself ALFA, sent during an account switch, reads "A Claude account switch is in progress. Orca will try again shortly." in place, with no Retry; the composer shows Stop After the switch ended, BRAVO was sent and answered; ALFA, still waiting, is now below BRAVO's exchange and its own try is running About 16 s after it was sent, ALFA was delivered by Orca's own retry with no Retry pressed: one bubble each, no notice left, drawn where Claude received it
(q2) Start failed for good (signed out): Retry in place sends the same message CHARLIE reads "Claude is not signed in for the selected account. Sign in first." with Retry on the message; the sidebar row reads Failed After signing in and pressing Retry, the same single CHARLIE bubble has its CHARLIE-OK reply; no failure text or Retry is left A later DELTA message is delivered below CHARLIE's exchange
(q3) A message the host received and rejected: its words in place, no Retry A .bmp image is attached in the composer The message reads "Claude accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent." in place, with no Retry; nothing was re-sent A later ZULU message is delivered; the image message keeps its sentence in place

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:

  • Claude exiting while it starts;
  • Claude not installed;
  • closing the chat while a message waits;
  • Retry while a turn runs.

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

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

Every new rule has a test that fails with the rule removed. Among them:

  • one writer: an exit before or after the loop sees the failure is recorded exactly once;
  • a message handed to an agent that never finished starting is rejected with that start's failure, never "unconfirmed";
  • a pre-spawn refusal waits and is tried at 15 s / 1 min / 5 min, then fails with its last reason; a post-spawn failure and the three refusals only you can fix fail at once;
  • later messages overtake a waiting one, which goes when its try is due;
  • Retry in place: same message, sent now ahead of the queue, one operation per press, after a restart, idle sweep or close; offered only for a failed start, disabled until the host answers;
  • the sidebar reads neither Failed nor Working while a retry is booked, Failed and one notification when tries run out, and close/quit/restart leftovers stay quiet;
  • mixed versions: new fields are optional, and Retry shows only on a host that advertises retry in place.

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

  • Mixed versions (desktop, phone and host update independently):
    • startRetry is 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.
    • providerMissing is a new failure type. Older apps read it as "not delivered" and show the host's English sentence.
    • Retry in place needs the host to advertise agent-session.retry-message.v1. While the desktop doesn't know, Retry is disabled; on a host without it, there is no Retry.
    • An older app reading a message retried in place sees it go from not sent back to sending, and draws it once.
    • Temporary (rollback only): an older host reading a newer chat treats a message waiting for its next try as handed over (it shows as unconfirmed at the next open), and a message retried in place may show as not sent. That is doubt or a visible "Not sent", never a false "sent".
    • Chats written by v1.4.218, which added a red start-failure row, still render those rows, and the reason isn't shown twice: a row is paired only with the message whose start it was written for.
  • SSH and WSL: structured chats are switched off on SSH and WSL execution hosts, and both launch resolvers refuse anything but the local host, so the new paths run only on the native host. Loss of contact is never recorded as a failure: the delivery loop runs on the host itself.
  • Known limits:
    • A message waiting for its next try reaches the agent only when its try comes due, even if a later message started the agent meanwhile (up to 5 minutes before the last try).
    • If Orca crashes in the instant between an unproven agent's exit and the loop recording it, those messages show as unconfirmed at the next open.

…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
… 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.
@brennanb2025
brennanb2025 marked this pull request as ready for review October 3, 2026 16:31

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

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 from src/shared/agent-session-refusal-notice.ts into the new src/shared/agent-session-refusal-reason-words.ts and 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

delete submission.rejectionCause
}
const handedOver = submission.handedOverAt !== undefined
if (rejection && isFailedStartRejection({ reason: row.reason, rejection })) {

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.

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.

brennanb2025 added a commit that referenced this pull request Oct 3, 2026
…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.
brennanb2025 added a commit that referenced this pull request Oct 3, 2026
…o chat-start-on-first-message

Launch files keep this PR's review reply on top of #24340's per-host structured route; locale
catalogs merged as JSON.
brennanb2025 added a commit that referenced this pull request Oct 3, 2026
… 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.

@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 this delta — the changes are a rebase onto origin/main plus 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, and structuredAgentTurnVerdict.
  • Conflict resolutions — journal-dispatch-reducer.ts adopts main's placeRejectedMessage and drops the branch's placeQueuedMessageAt; the worker preamble now routes through main's sendAgentTurn while keeping the branch's whenUndelivered verdict wait; the renderer notices are main's with the branch's retriesInPlace / retryWaitsForHost wiring.
  • Test fix (08bc26756) — the Claude auth-isolation fixture supplies its openConnection instead of spying on the stream-json connection, so the pre-spawn missing-claude check 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.

Pullfrog  | Fix it ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

…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.
@brennanb2025
brennanb2025 marked this pull request as draft October 5, 2026 17:46
…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
@brennanb2025
brennanb2025 marked this pull request as ready for review October 6, 2026 03:08
@brennanb2025
brennanb2025 marked this pull request as draft October 6, 2026 07:43
@brennanb2025

Copy link
Copy Markdown
Contributor Author

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.

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