Skip to content

fix(native-chat): after a Stop whose exit can't be confirmed, the next message retries the stop instead of failing - #24333

Merged
brennanb2025 merged 15 commits into
mainfrom
brennanb2025/send-after-unproven-stop
Oct 1, 2026
Merged

brennanb2025 merged 15 commits into
mainfrom
brennanb2025/send-after-unproven-stop

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​626 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​625
Prod 26 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​349 $\color{#cf222e}{\Huge{\mathbf{−}}}$​49 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​300

What this fixes, and where

The bug. You press Stop on a native Claude chat. Since #24235, that Stop ends Claude's process, and Orca must prove the process is gone before it starts a new one. Sometimes the proof fails: the process does not exit in time, or Orca cannot confirm that a process it spawned is gone. When that happened, the next message you sent failed at once with "Orca couldn't hand this message to the agent, so it was not sent." Every message after it failed the same way. Pressing Stop again did nothing, because the chat no longer read as working. The chat only recovered when Orca's background check for idle chats (it runs every 5 minutes; "the idle check" below) retried the stop, after about 30 minutes of quiet.

Why it happened. When the proof fails, Orca keeps the old process on record on purpose, so a later retry can still reach it and nothing new starts beside a process that may still be running. Orca calls that record an "owed stop": a stop that has not finished. But the old process's connection had already refused all input once its close began, so the next message was handed to a process that could not take it. Nothing that reaches the agent noticed the owed stop and retried it first. Model and effort changes, goal changes and rewinds had the same problem.

Scope. Native (structured) chats, on a local or SSH host. The change is on the host that runs the agent, so for SSH the retry runs on the remote host that owns the process. Claude reaches this most often, because Claude's Stop ends its process (#24235). Codex chats reach it too, less often: Codex's Stop ends its process only when it stops a running compaction, but closing the chat's tab, the 30-minute idle stop and the stop of a start that never finished also end the Codex process, and each can fail to prove the exit the same way. Everything below applies to both. Terminal agent tabs are unaffected. A stop that proves the exit behaves exactly as before.

ELI5

After a Stop, Orca has to be sure the old Claude has really quit before it starts a new one. If it couldn't confirm that, your next message used to bounce. Now your message first makes Orca check again. If the old Claude is gone, your message goes out. If not, the message waits in the chat with a note saying why, and goes out once a later check confirms the old Claude is gone: a change you make, sending a queued message now, or Orca's own check every 5 minutes.

What Changed

Before (for you):

  • After a Stop whose exit Orca couldn't confirm, every message failed with "Orca couldn't hand this message to the agent, so it was not sent."
  • Stop did nothing. The chat stayed broken for about 30 minutes.

After (for you):

  • Your next message makes Orca retry ending the old process first. If that works, the message goes to a fresh Claude that resumes the conversation. This is what happens when the old process was only slow to exit.
  • If the exit still can't be confirmed, the message is not refused. It stays in the chat as a waiting message, under one note: "Orca couldn't confirm Claude's previous process ended. Messages wait to be sent until Orca confirms it has ended." The note states the rule rather than promising this message, so it stays true if you later withdraw the message, close the chat, or Orca restarts.
  • The waiting message goes out once a later retry confirms the exit, whichever retry that is: a model or effort change, answering one of the agent's question or permission cards, stopping background tasks, Steer on a queued follow-up (or, with queueing off, another message), or the idle check every 5 minutes.
  • While a message waits, the chat, the sidebar and the session lists read Working, with the timer counting from that send, as they do for a message waiting on Claude to start. If the exit is never confirmed, that lasts until you press Stop. Stop withdraws the waiting message and the chat goes idle; Orca still hasn't confirmed the old process ended, and keeps checking every 5 minutes.
  • A follow-up you send meanwhile: with follow-up queueing on (the default, and always on mobile), it becomes a queued card above the composer, because the chat reads Working. It goes out after the waiting message's turn, as any queued card does. Pressing Steer on it makes it a waiting message too: it retries ending the old process once more and, if that still fails, waits under the same note. With queueing off, each new message does that directly.
  • Changing the model or effort, answering a card, changing a goal or rewinding also retries ending the old process first. If that works, the change goes through (a model or effort pick is kept for the next start). If not, it is refused with "Orca hasn't confirmed that this chat's previous agent stopped." followed by what didn't happen, such as "The setting wasn't changed." Trying again retries again.
  • One send is still refused: while a rewind is still in doubt, a send is refused as it is today, now with "Orca hasn't confirmed that this chat's previous agent stopped. Your message was not sent."

Mechanism.

  • One retry for everything that reaches the agent. Every operation that reaches the agent first checks for an owed stop and, if there is one, runs one more pass of that same stop with its original cause (finishOwedStructuredAgentSessionStop). Sends, goal changes and rewinds reach it through the step that starts the agent; option changes, card answers and stopping background tasks through the step that opens the chat for a write to the running agent. If the exit is still unproven, the operation gets a refusal (code agent_session_ownership_unknown, reason previousExitUnverifiable, ownerVerdict: 'unverifiable'). Orca treats the exit as unverifiable, never as exited.
  • A send waits instead of failing. When the step that hands queued messages to the agent gets that refusal, it leaves the message queued, the state a message is in while Claude starts, and writes the note once per unproven process. The note carries a new failure type, used only on that note, also named previousExitUnverifiable. Its sentence is translated in every catalog, but like every status note it shows the English sentence the host writes. Other operations reuse the existing refusal sentence, so they need no new copy.
  • One retry per message. Every write to the chat's history wakes the delivery step, including the note itself. So the delivery step skips the retry when every waiting message was accepted before the newest retry that failed. Orca keeps that position on the owed-stop record, in memory, and it ends with the record. Only a message accepted after it retries again; nothing else retries.
  • The stop that lands sends what waited on it. The owed stop is cleared in one place: the final step of the stop, which releases Orca's hold on the chat. That step now wakes the delivery step itself. So whichever retry proves the exit, the waiting message goes out without that caller having to send it. This is safe for every kind of stop, because the delivery step re-reads the chat's history: closing a chat rejects what is queued before it stops the process, and once Orca starts quitting, nothing is delivered.
  • A waiting message is ordered after the stop it waited on. The owed-stop record also keeps where the chat's history stood when the stop was asked for. When a retry finally proves the exit, the old process's end is recorded at that position, not at the retry. Only a retry keeps the old position; any new stop, even a second close of the same chat, takes a new one. The delivery step compares that end with each queued message to decide what a close closed and which start failed. Without this, a message sent after a tab close whose exit was unproven would be rejected as "The chat closed before this message was sent." once a retry landed.
  • When only bookkeeping is left. If the stop did prove the old process gone but a later step of its cleanup failed (such as releasing Orca's hold on the chat), a model or effort change, a card answer or a background-task stop retries that cleanup once and then goes ahead as it does on main, while a send still waits, because a new Claude can start only once that hold is released.
  • A stop owed for an earlier process never stops a different, live one. The retry runs only when there is no process on record or the one on record is the one the stop is owed for.
  • The idle check retries on its next run. Before, it retried a stop only once the process was proven gone and nothing was queued, so an unproven stop waited 30 minutes, and a waiting message blocked the retry altogether. Now it retries any owed stop on its next run, every 5 minutes, unless a delivery is in progress, which retries the stop itself.

The bound. Each retry is one pass of the existing stop, and each step of it keeps the existing 10 s deadline. Claude's exit proof takes up to about 2.5 s unsupervised and up to about 7 s under the POSIX supervisor (its 5.5 s stop, plus 0.5 s, plus 1 s after a forced kill), so a shorter deadline would cut a supervised proof short. A retry occupies the chat's one-at-a-time operation queue while it runs, so a Stop pressed meanwhile waits for it. The message already shows as waiting during the retry.

What ends each piece of state.

  • The owed stop (it already existed) ends when a retry proves the exit. Something now retries it on its own: the idle check's next run, every message that reaches the delivery step (with queueing on, only after Steer), every other operation that reaches the agent, and the shutdown cleanup when Orca quits. Before this PR, only the 30-minute idle window retried it. The two positions it now keeps are part of the same in-memory record and end with it.
  • A waiting message ends when it is delivered once the exit is proven, when you withdraw it with Stop, when you close the chat, or when Orca restarts (the next open rejects it as "Orca restarted before this message was sent.").
  • The note is a history row, not an obligation. Nothing reads it except the check that writes it only once per unproven process.

Why

  • Keep the proof; retry it where the user acts. Starting a new Claude beside one that may still run is the failure the proof exists to prevent: forgetting an unproven close is what once stranded a process forever while reporting success. What was missing was a retry on the path the user takes next, instead of trusting a record nobody re-checked for 30 minutes.
  • Reuse the queued state, not a new waiting state. A message waiting on a start is already queued and already drawn as waiting. Waiting on an owed stop is the same situation, so no new message state, wire field or client change is needed. The only new thing is the note that says why.
  • Mixed versions are safe. The refusal's reason is new under an existing code. An older client drops a reason it doesn't know and shows that code's own words ("The setting wasn't changed."), and an older host never sends it. The note's failure type is new too: older clients print the note's full sentence, which the host writes beside it.
  • Alternatives considered.
    • Give up after a deadline and start a new Claude anyway. Rejected: that can run two Claudes on one conversation, and a slow exit or lost contact is never evidence that the old one exited.
    • Retry in a tight loop until the exit is proven. Rejected: each retry occupies the chat's operation queue for up to 10 s, so a Stop pressed meanwhile would wait behind it, and a truly stuck process would be retried forever.
    • Reject the message with a clearer reason. Rejected: you'd have to retype it, though nothing about the message is wrong.

Differences from the common pattern

  • Intended: Orca waits for proof of exit and never starts a resumed process beside an unproven one. The common pattern waits a few seconds for the turn to stop, then closes the process without waiting for it to exit, so it has no exit-proof deadline that Orca's 10 s step deadline could be compared with. Orca keeps the proof because forgetting an unproven close is what stranded a process forever while reporting success (structured-agent-session-eviction.ts:15-18). Safe because the message waits visibly and can be withdrawn, and lost contact is never read as an exit.
  • Temporary: in the common pattern, the waiting message goes out the moment the stop settles. Here, if the old process exits on its own later (after the 10 s deadline, or after a proof that failed), Orca doesn't notice that exit, so the message waits for the next retry: your next action, or up to 5 minutes. Follow-up: fix(native-chat): a message waiting on an unconfirmed stop goes out as soon as the old process exits #24362 (stacked on this PR) keeps watching that process when a close leaves the stop owed, and retries the stop as soon as it exits, so the waiting message goes out then. It needs a new adapter-to-host event for Claude and Codex, so it is kept out of this PR.

Same as the common pattern: a message sent during the stop waits as a queued message with a visible reason and can be withdrawn; the stop's own completion sends it, not whichever caller retried; and an operation other than a send is refused while the old process's exit is unconfirmed.

Linked Issue

Follow-up to #24235.

Visual Proof

Regression check on a macOS dev build (M4 Air, head e10a1028729, a stand-in claude because the isolated test profile can't sign in): a normal Stop, then a message, still goes to a fresh agent process with no error and no note.

  1. First message mid-response, Stop available:
First message mid-response with Stop available
  1. After Stop: "Interrupted after 12s"; the process exited and its exit was proven:
After Stop: Interrupted after 12s
  1. The next message goes to a fresh agent process (pid 81823) and gets a reply, with no error and no note:
Second message after Stop answered by a fresh process

The waiting note itself can't be shown live: it needs a Stop whose exit proof fails, and on macOS Orca always observes its own child process exit, so that state can't be forced without admin rights. The host-level tests below cover it. All QA screenshots: #24333 (comment)

Testing

New host-level tests in structured-agent-session-claude-unproven-stop-send.test.ts. They run the shipping Claude adapter and host against a fake connection that, like the real one, refuses every write once a close begins. Sends are made with queueing off unless a test says otherwise.

  1. The proof fails once: the next message retries the close, the close succeeds, and the message goes to a resumed Claude, never to the old one.
  2. The proof stays unverifiable: the send is accepted, not refused; the message stays queued under one note with the expected text and failure type; no new Claude starts; a second message retries once more and shares the note; the idle check's run proves the exit, and both messages go out.
  3. A waiting message withdrawn with Stop: the owed stop still ends on the idle check's next run, Orca releases its hold on the chat, and no Claude starts.
  4. An option change while the exit stays unverifiable: it retries the stop once and is refused with previousExitUnverifiable, with nothing written to the old process. Trying again retries again; once the exit is proven, the pick is kept for the next start.
  5. A message waiting, then an option change proves the exit: the option is kept and the waiting message goes to a resumed Claude.
  6. A message waiting, then stopping background tasks proves the exit: that stop finds no agent left to stop (noLiveOwner), and the waiting message still goes to a resumed Claude.
  7. A tab close whose exit is unproven, then a message: the message waits, the idle check's retry proves the exit, and the message is delivered, not rejected as closed.
  8. A stop owed for an earlier process, with a different live process on record: the message goes to the live process, and neither the send nor the idle check stops it.
  9. Two waiting messages, then three unrelated writes to the chat's history: no further retry; the idle check then retries one pass per run until the exit is proven, and both messages go out.
  10. Follow-up queueing on (the default): a follow-up while a message waits becomes a queued card and retries nothing; Steer on it retries exactly once, it waits under the same note, and both messages go out once the idle check proves the exit.
  11. Two tab closes, the second unable to reject the waiting message itself: once a retry proves the exit, the message is rejected as closed, and no Claude starts.
  12. An option change's failed retry runs between a message's accept and its delivery: the message still waits under the note, and the note's own write retries nothing.
  13. The Stop proved the exit but its cleanup keeps failing, then an option change: the change is accepted and kept for the next start, and no Claude starts.

Codex, in structured-agent-session-codex-stop-row.test.ts: a Codex Stop whose interrupt fails ends the Codex process; when that exit is unproven, the next send retries the stop and goes to a fresh Codex, never to the old connection. Removing the retry fails it.

Card answers, goal changes and rewinds go through the same retry and the same wake, but have no test of their own.

The bug on main (the same setup on d4ae6a904be): both messages were rejected with provider_write_failed (writeFailed), and no new Claude started. A later Stop answered cancelled: false, did not retry the close, and left the stop owed. The option change was accepted and handed to the old process.

Ablations. Each reverts one piece and runs the new test file.

  • On e10a1028729 (13 tests): an option change refused whenever any stop is owed fails test 13.
  • On a8d87c343f7 (12 tests): a held message skipping the note fails test 12.
  • On baab367cd2b (11 tests): a stop continuing whenever the cause matches, instead of only on a retry, fails test 11; a queued card retrying the stop fails test 10 (3 close attempts instead of 2); holding against the note's position instead of the newest failed retry fails test 9 (6 close attempts instead of 3, one per write).
  • On 53118b3fd6d (8 tests): no wake from the stop itself fails tests 2, 5, 6 and 7; recording the end at the retry instead of the ask fails test 7; retrying whatever process is on record fails test 8.
  • On c0e87a31c05, before the later rounds (tests 1-4): the whole host fix, 4 of 4 fail; no retry before starting the agent, 3 of 4; the option change opening without the retry, test 4; the delivery step refusing the send instead of holding it, tests 2 and 3; the idle check's old retry rule, tests 2 and 3.

Suites. On e10a1028729: vitest on every test file in agent-session-wire, every test file that imports a module the PR touched, and every test built on the host test rigs: 177 files, 1469 tests, all passed. On bc4af0dc411 (the last copy change), the same plus the shared failure, refusal and failure-words tests and the i18n tests: 203 files, 1821 tests, all passed. Excluded: real-CLI, real-binary and integration tests. 4 mobile test files that import the shared failure modules did not run locally, because mobile dependencies are not installed here.

After merging main (#24312's host logger, #24334's Codex Stop, #24437's logger fix) at 5ed0911d2a8 (and again at 90dcb740b9e after a conflict-free merge of newer main): pnpm tc:node clean; 214 test files, 1851 tests, all passing except one cross-version harness test that timed out locally under heavy machine load (CI covers it); oxlint and the anti-slop config clean on the changed files.

Typecheck. pnpm tc:node on e10a1028729: clean. pnpm tc:web on bc4af0dc411: clean (nothing it compiles changed since).

Lint. On ad83641757c: oxlint and the anti-slop oxlint config on the changed files are clean, and on e10a1028729 check:code-quality:changed reported 0 new findings. The localization catalog, extraction and coverage checks pass on bc4af0dc411.

Platforms: logic tests on macOS. Not exercised on a live Claude, Windows, Linux or a real SSH host.

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

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

  • Security: no new inputs, paths or processes. The retry is the existing stop.
  • Cross-platform: the retry runs the same per-platform exit proof as Stop; nothing platform-specific is added.
  • SSH: the retry and the note run on the host that owns the process.
  • Mobile: the note and the refusal reach mobile through the existing shared failure and refusal modules; no mobile code changes.
  • Backwards compatibility: see "Mixed versions are safe" above.
  • Performance: one extra check per operation that reaches the agent; a retry only when a stop is owed.
  • Deferred (wording): when the stop proved the exit and only its cleanup failed, the waiting note and the refusal still say Orca couldn't confirm the old process ended. The exit is in fact proven there; only releasing Orca's hold on the chat is outstanding. It takes a repeated storage or history-write failure to reach.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…ld not verify

Adds the `previousExitUnverifiable` failure fact, a status row only, never a
reason a message was not sent: Orca couldn't confirm the agent's previous
process ended, and the message will send once it has. Copy in every catalog.
…ies the stop, then waits

When a Claude Stop could not prove its child gone, the child stayed in place
with a connection that refuses writes, so the next message failed with
"Orca couldn't hand this message to the agent" and every later one did too,
until the idle sweep retried the stop after 30 minutes of quiet.

The delivery loop now retries an owed stop before it starts or writes to a
child, once per new message. Still unproven, the message stays queued under one
warning row saying why, and the idle sweep's next tick retries the stop, child
or not, and hands the message over once the exit is proven.
…owed stop first

Option changes, card answers, background-task stops, goal changes and rewinds
went to a child a Stop could not prove gone, whose connection takes no input.
The retry now lives in one place, `finishOwedStructuredAgentSessionStop`:
`ensureStructuredAgentSessionAgent` calls it before reporting or starting a
child, and operations on the running child prepare through it. Still unproven,
it refuses with `previousExitUnverifiable` and the exit `unverifiable`; the
delivery loop turns that refusal into the visible wait, so a send still waits
under its one row instead of being refused.
…e that waited on it

- The stop itself wakes delivery where its owed wind-down clears, so a message held on an
  unproven exit goes out whichever retry lands: an option change, a background-task stop, the
  sweep or the next message. Only the sweep woke it before, so an option change that proved the
  exit left the message queued with nothing to send it.
- The owed stop keeps where it was asked for, and the child's end is ordered there. A message
  accepted while retries ran is no longer rejected as "chat closed" (a tab close) or failed as a
  host stop when the retry that proves the exit lands after it.
- A wind-down owed by an earlier child never stops a different live child in front of it.
…nd its note stays true

- A waiting message holds against the newest pass that failed to prove the exit, kept on the
  owed-stop record (`failedAt`), not against the note's position. The note is written once per
  process, so after a second message every later journal commit re-ran a retry of up to 10 s.
- The note no longer promises this message will send: "Messages wait to be sent until Orca
  confirms it has ended." stays true after a Stop withdraws the message, a close, or a restart
  rejects it. Every catalog follows; es and zh lose the mismatched informal possessive, and the
  French reads naturally.
- Tests: commits after a second waiting message retry nothing; with follow-up queueing on (the
  default) a follow-up becomes a draft, and Steer retries once and waits under the same note.
… a new ask

A second tab close of a chat whose exit stayed unproven kept the first close's position, so a
message sent between the two closes was delivered once a retry proved the exit, even though the
second close closed it (its own rejection of what was queued had failed). Continuation is now
explicit: only the retry of an owed stop passes `retry`; every other stop stamps a new position.

Tests: a second close whose rejection fails still closes the message it closed; the default-
queueing test waits for a draft's commit to settle before asserting it retried nothing, and bounds
Steer's retries; the one-retry-per-message test has the budget to show each commit's retry.
When another operation's retry of the unproven stop failed between a message's accept and its
delivery step, the step held the message (it had already waited through a failed retry) and
stopped before writing the note, so the message sat Working with no reason shown. The hold now
makes sure of the note too; it is written once per unproven process, so its own commit still
retries nothing.
…t on bookkeeping

When a stop proved the old process gone but a later step of its wind-down kept failing, an option
change retried that bookkeeping, and refused the change when it failed again. On main the change
was kept at rest. Operations that start no child (option change, card answer, background-task stop)
now hold back only while the exit itself is unproven, its child still on record; with the exit
proven, the bookkeeping retry is reported and the operation goes on as with nothing owed. Sends
still wait: a start needs the released lease that bookkeeping gives back.
@OrcaWin

OrcaWin commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

QA screenshots (M4Air)

Head SHA tested: e10a102872991eb9d459cb7b70c72dfafe5710ef. This was a dev build on macOS (M4 Air) with an isolated HOME and profile. Screenshots are CDP captures of a hidden window.

The real Claude CLI can't sign in from an isolated HOME, and no credentials were copied (screenshot 1). Every other chat ran against a stub claude placed on the rig's PATH. The stub speaks the stream-json protocol: initialize, turns, interrupt. Each reply prints the stub's process ID, so a new process is easy to spot.

Regression (normal Stop): PASS

  1. Real Claude, isolated HOME: "Claude is not signed in". This is why the stub was used.
    01
  2. First message is mid-response, with a partial reply showing and Stop available.
    02a
  3. After Stop: "Interrupted after 12s". The stub process exited and the exit was proven.
    02b
  4. Second message after Stop went to a fresh agent process (pid 81823) and got a reply. There was no "couldn't hand this message" error and no warning note.
    02c

Unproven-exit path: BLOCKED on this host. The waiting message, the warning note and the "hasn't confirmed" refusal could not be reached. On macOS the stop stays owed only if Orca never sees its own direct child (the provider supervisor) exit. That child is SIGKILLed through Orca's own process handle, so it always exits. A debugger attach that would hide the exit returns EPERM because Developer Mode is off, and enabling it needs admin rights. There is no SSH host to cut contact with.

Related case: a child that survives Stop. The stub spawned a child that ignores SIGTERM, in its own session, at the moment of Stop. Orca logged "root exited but a descendant survived the close". Because the root's exit was seen first-hand, that counts as final, and the next message was not held.
5. Stop with the surviving child (pid 97195) still running.
03a
6. The next message, sent while that child was alive, went straight to a fresh agent (pid 98614) with no hold and no note.
03b

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready for a merge decision

The problem. Since #24235, pressing Stop on a native Claude chat ends Claude's process, and Orca must prove the process is gone before it starts a new one. When that proof failed, every message sent afterwards was rejected at once with "Orca couldn't hand this message to the agent, so it was not sent.", and Stop did nothing. The chat only recovered about 30 minutes later, when Orca's idle check retried the stop. Codex chats can reach the same state through a tab close, the idle stop, or the stop of a start that never finished.

What changes for you. The next thing you do in that chat retries ending the old process first: a message, a model or effort change, a card answer, a goal change or a rewind. If the old process is now gone, your action goes through, and a message goes to a fresh Claude that resumes the conversation. If the exit still can't be confirmed, nothing starts beside it:

  • a message waits in the chat with one note: "Orca couldn't confirm Claude's previous process ended. Messages wait to be sent until Orca confirms it has ended."
  • any other action is refused with "Orca hasn't confirmed that this chat's previous agent stopped."

The waiting message goes out when any later retry confirms the exit, including Orca's own check every 5 minutes. While it waits, the chat reads Working, and Stop withdraws the message.

Fixed during review (each fix has a test that fails without it):

  • A waiting message was never sent when something other than a message confirmed the exit, such as a model change. Now the stop that confirms the exit sends what waited on it, whatever triggered the retry.
  • After a tab close whose exit was unconfirmed, a message sent later was rejected as "The chat closed before this message was sent." once a retry landed. The old process's end is now recorded where the stop was asked for, not where the retry happened.
  • A stop still owed for an earlier process could have stopped a different, live one. The retry now only targets the process the stop is owed for.
  • With queueing off, every unrelated update to the chat re-ran a retry of up to 10 seconds. Now each waiting message gets one retry.
  • A second tab close could keep the first close's position and let a message through after you closed the chat. Only a retry keeps the original position now.
  • In a narrow race, a waiting message could appear with no explanatory note. It always gets the note now.
  • When the old process was proven gone and only cleanup failed, a model or effort change was refused. It goes through as before this PR.
  • The note promised that "your message will send", which became false after you withdrew the message or closed the chat. It now states the rule instead. The Spanish, Chinese and French wording is also fixed.
  • A test helper failed CI's lint, and several weak tests and inaccurate PR-description claims were fixed.

Deferred:

  • Late exit (follow-up, not filed yet). If the old process exits on its own after the retry gives up, Orca doesn't notice until the next retry, so a message can wait up to 5 minutes. Fixing it needs a new exit signal from Claude and Codex to the host; it's out of scope here.
  • Inexact wording when only cleanup is left. If the exit was proven and only Orca's cleanup failed, the note and refusal still say the exit is unconfirmed. Reaching this takes repeated storage failures.
  • Nits: an extra no-op wake after each stop that lands, two confusingly similar function names, and the Spanish and French phrasing.

Verified:

  • Three full review rounds plus two narrow re-checks of the later fixes, and an architecture comparison.
  • The readiness checklist passed with no P0-P2 findings.
  • 13 host-level tests, with each fix's ablation failing its test.
  • 177 related test files (1469 tests) passing.
  • The node typecheck clean, the web typecheck clean on the last copy change, and lint clean.
  • Remote QA on the M4 Air (screenshots: fix(native-chat): after a Stop whose exit can't be confirmed, the next message retries the stop instead of failing #24333 (comment)). A normal Stop followed by a message still goes to a fresh process with no error and no note. This ran against a stand-in claude, because the isolated test profile can't sign in to real Claude.

Not verified:

  • The waiting note and refusals on a live app. On macOS, Orca always sees its own child process exit, so a failed exit proof can't be forced without admin rights. Only the host tests cover that path.
  • Real Claude, Windows, Linux and a real SSH host.
  • Mobile tests (CI covers them).

The PR is marked ready; I have not merged it.

@brennanb2025
brennanb2025 marked this pull request as ready for review October 1, 2026 10:30
@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High risk] Refactors agent session stop and wind-down retry logic across the chat system.

The PR appears safe to merge based on the reviewed changes.

Summary

The PR retries an unconfirmed native-chat stop before delivering another message, holds messages while exit remains unproven, and wakes delivery when the stop completes.

  • Adds Claude and Codex regression coverage and a status note for waiting messages.
  • Adds periodic retries through the idle sweep.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Send[Accepted message] --> Owed{Stop owed?}
  Owed -->|No| Deliver[Deliver to agent]
  Owed -->|Yes| Retry[Retry stop]
  Retry -->|Exit proven| Deliver
  Retry -->|Still unproven| Wait[Keep message queued with note]
  Wait --> Later[Later retry]
  Later --> Retry
Loading

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

@coderabbitai

coderabbitai Bot commented Oct 1, 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: 5692a400-7e6c-484d-b381-b04b72f370dc

📥 Commits

Reviewing files that changed from the base of the PR and between ad83641 and 90dcb74.

📒 Files selected for processing (20)
  • src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.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-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-types.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.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
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/renderer/src/i18n/en-runtime-required.json
  • src/renderer/src/i18n/locales/ja.json
  • src/renderer/src/i18n/locales/es.json
  • src/renderer/src/i18n/locales/ko.json
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/i18n/locales/zh.json
  • src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts

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


📝 Walkthrough

Walkthrough

The host now tracks owed provider-child stops and retries them before relevant provider writes or during idle sweeps. When the previous child’s exit remains unverified, queued delivery waits and a status row explains the hold. The changes also add failure and refusal details, translations, and integration tests for retry, delivery, option changes, and conversation closes.

Priority: ⬇️ Low

Merge Risk: 🔵 Low · up to 90dcb

The change lets the next message retry a Stop whose exit could not be confirmed, and holds messages until exit is proven. One narrow timing edge remains open: a late cleanup could clear a newer pending stop. Guarding that obligation is a small follow-up. Otherwise, the change appears mergeable.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 90dcb

The change keeps new work waiting when the previous process's exit cannot be confirmed. No new security issue was identified in the reviewed paths, but incomplete recovery coverage prevents a stronger assurance.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The reviewed control path affects queued messages, mutations, and process ownership for the selected chat session. It governs when an existing provider process can receive work or be replaced; these excerpts do not establish the process's broader credential, filesystem, or tool-access scope.

Trust Boundaries and Controls

  • observed — The changed host wiring uses internal lifecycle callbacks rather than adding a host command interface. Process startup retains lease reconciliation, recovery resolution, record and adapter checks, and resume eligibility. Provider-write admission cannot use its no-child exception while an unverified child remains recorded.

Resilience and Maintainability Implications

  • observed — Idle recovery retries a matching outstanding stop when delivery is inactive, before applying normal idle shutdown logic. This provides a recovery route for held work without treating an unverifiable exit as a completed exit.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.73% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 44 functions across 23 files. (7 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: retrying an owed Stop before the next message when the previous exit cannot be confirmed.
Description check ✅ Passed The description is detailed and aligned with the template. It explains the bug, user-visible behavior, implementation, rationale, linked issue, visual proof, testing, platform considerations, and chec…
Full details: Docstring Coverage

Explanation

Docstring coverage is 47.73% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 44 functions across 23 files. (7 skipped: 7 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 02ab0a3e-4a2c-48cf-acc9-e6aa2f9f2e7f

📥 Commits

Reviewing files that changed from the base of the PR and between ddd4927 and ad83641.

📒 Files selected for processing (29)
  • src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.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-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-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-idle-sweep.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts
  • src/renderer/src/components/native-chat/agent-session-failure-words-text.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-copy.ts
  • src/shared/agent-session-failure-words.ts
  • src/shared/agent-session-failure.ts
  • src/shared/agent-session-refusal-details.ts
  • src/shared/agent-session-refusal-notice.ts

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

return true
}
try {
await stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, {

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

ast-grep outline src/main/native-chat/agent-session-wire \
  --items all \
  --match 'releaseStoredStructuredAgentSessionOwner|StructuredAgentSessionTaskQueue|evictStructuredAgentSession' \
  --view expanded

rg -n -C 16 --type ts \
  'function releaseStoredStructuredAgentSessionOwner\b|class StructuredAgentSessionTaskQueue\b|releaseLease:' \
  src/main/native-chat/agent-session-wire

Repository: stablyai/orca

Length of output: 27974


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- host lifetime 150-270 ---'
sed -n '145,270p' src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts
printf '%s\n' '--- eviction deadline and call path ---'
rg -n -C 18 --type ts 'withStructuredAgentSessionEvictionDeadline|finishOwedStructuredAgentSessionWindDownUnderSerialize|stopStructuredAgentSessionAgentUnderSerialize' src/main/native-chat/agent-session-wire
printf '%s\n' '--- task queue ---'
cat -n src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts
printf '%s\n' '--- keyed serializer ---'
rg -n -C 12 --type ts 'function runKeyedSerializedOperation|export .*runKeyedSerializedOperation' src
printf '%s\n' '--- lease release full ---'
cat -n src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts

Repository: stablyai/orca

Length of output: 42810


🏁 Script executed:

set -e
sed -n '145,270p' src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts
rg -n -C 18 --type ts 'withStructuredAgentSessionEvictionDeadline|finishOwedStructuredAgentSessionWindDownUnderSerialize|stopStructuredAgentSessionAgentUnderSerialize' src/main/native-chat/agent-session-wire
cat -n src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts
rg -n -C 12 --type ts 'function runKeyedSerializedOperation|export .*runKeyedSerializedOperation' src
cat -n src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts

Repository: stablyai/orca

Length of output: 42217


🏁 Script executed:

set -e
printf '%s\n' '--- surface release implementation ---'
rg -n -C 24 --type ts 'function releaseStoredAgentSessionOwnerAfterSurfaceClose|export .*releaseStoredAgentSessionOwnerAfterSurfaceClose|transitionHandoff' src/main/runtime src/shared
printf '%s\n' '--- eviction runner ---'
rg -n -C 24 --type ts 'async function evictStructuredAgentSession|export async function evictStructuredAgentSession' src/main/native-chat/agent-session-wire

Repository: stablyai/orca

Length of output: 45081


Guard the obligation before clearing it.

The timed-out release-lease step can continue after Promise.race rejects. The keyed queue can then run a retry while the original transitionHandoff is still pending. When the original callback resumes, its unconditional assignment can clear a newer wind-down obligation. Clear the obligation and publish its completion only when the current obligation is the same owed object.

Suggested fix
-      session.owesProviderChildWindDown = undefined
+      if (session.owesProviderChildWindDown !== owed) {
+        return
+      }
+      session.owesProviderChildWindDown = undefined

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes — full 29-file diff at ad83641 (11 commits), base main (d4ae6a9).

  • One retry per path to the agent. ensureStructuredAgentSessionAgent (sends, goal changes, rewinds) and the new openForProviderWrite → finishOwedStop path (option changes, prompt answers, background-task stops) call finishOwedStructuredAgentSessionStop first; an exit still unproven returns the previousExitUnverifiable refusal instead of assuming exited.
  • A send waits instead of failing. The delivery loop catches that refusal and holds the message queued under one status note per unproven child, rather than rejecting it.
  • The landing stop hands over. releaseLease wakes the delivery loop itself, so whichever retry proves the exit delivers what waited. The wake is fire-and-forget and queues behind the held serialize, so there is no reentrant-lock hazard.
  • Ordering by ask position. requestedAt/failedAt on the owed record, and endProviderChild's optional endedAt, keep a close's end ordered where the stop was asked — a message accepted after a close is delivered, not rejected as closed.
  • Retry suppression. structuredAgentSessionWindDownWaitHolds suppresses re-retries from the note's own and unrelated commits while still retrying once per newly accepted message; the new tests pin this with exact close counts.

I traced the retry-suppression guard (including the acceptedSequence ?? 0 fallback — handoverRecorded and acceptedSequence are written atomically, so it is unreachable for queued rows), the provider-write path (a failed retry refuses whenever the child record is still present; it only lets the operation proceed when child is null, i.e. the exit is proven and only bookkeeping remains), and the endedAt = requestedAt change for first (non-retry) stops, where acceptance is serialized on the same lock so the rejection set is unchanged. No reachable regression in any of the three.

ℹ️ Acknowledged deferral: the note's wording when only bookkeeping is outstanding

When the stop proved the exit but a later cleanup step failed, child is null yet the owed record persists with failedAt, so the waiting note (and the write refusal) still say Orca could not confirm the previous process ended. The author documents this in the PR body. It is reachable only through a repeated storage/history-write failure, and the fix would need a distinct sentence, so it is reasonable to leave out of this PR — flagging only so the tradeoff is on the record.

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

# Conflicts:
#	src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes — incremental delta ad83641..d9771b9 (the merge of main @ 56c7642aae2 and the branch's adaptation to it), base main (d4ae6a9). The prior full review was at ad83641.

  • Failure reporting migrated to the required host logger. finishOwedStructuredAgentSessionWindDownUnderSerialize now reports a failed retry through context.deps.logger.warn (scope owed-stop-retry) instead of the removed onEventSinkError; the test rigs moved to recordingStructuredAgentSessionLogger and assert that scope.
  • Wind-down wait writer re-shaped. recordStructuredAgentSessionWindDownWait now takes sessionId plus lazy conversationFence/failureTextContext; the delivery loop's private waitOnUnprovenExit was inlined into its two hold branches with identical behavior.
  • Host wiring updated for the merge. import * as agentStart, finishOwedStop added to the mutation context, finishOwedWindDown threaded through the conversation lifetime into the idle sweep, and wakeDelivery added to the lifetime context.
  • Idle sweep retries on pendingProviderChildWindDown && !deliveryActive, so a queued message no longer blocks the retry while a different live child still does.

I re-traced the two hold branches, the finishOwedStructuredAgentSessionStopForProviderWrite "proven exit, bookkeeping only" escape, and the endedAt = requestedAt ordering; the merge is behavior-preserving. The new test file passes 13/13 at this head.

ℹ️ The branch tip alone does not typecheck (inherited from the pre-#24437 main snapshot)

pnpm tc:node on d9771b9 reports four errors, none in a line this PR owns: structured-agent-session-chat-stop.ts:88 and structured-agent-session-conversation-stop.test.ts:100 still reference the removed onEventSinkError, and structured-agent-session-repeat-press.test.ts:44 / structured-agent-session-repeated-stop.test.ts:103 omit the now-required logger. These come from the main snapshot this merge pulled in; #24437 (9bcdb6ad86) fixed exactly those four sites on main, so CI's typecheck (which runs on the merge ref) passes and a later merge of main clears the branch. Factoring only so a local pnpm tc:node failure is not mistaken for this PR.

Technical details
# Branch tip does not typecheck on its own

## Affected sites
- `src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts:88` — `context.deps.onEventSinkError` no longer exists on `StructuredAgentSessionHostDeps`.
- `src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts:100` — passes the removed `onEventSinkError` into the host deps.
- `src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts:44` — host deps omit the now-required `logger`.
- `src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts:103` — same.

## Required outcome
- The branch tip typechecks, or it is understood that only the merge ref does.

## Suggested approach (optional)
- Merge the current `main` (which contains `9bcdb6ad86`, "report a failed Stop child end through the host logger") before the next push. No change to this PR's own files is needed.

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes — incremental delta d9771b9..b2390ae (a second merge of origin/main), base main.

  • Merged origin/main through #24437. The branch-relevant part is #24437's migration of the Stop child-end report to the required host logger, which edits four files this PR does not own: structured-agent-session-chat-stop.ts, structured-agent-session-conversation-stop.test.ts, structured-agent-session-repeat-press.test.ts, structured-agent-session-repeated-stop.test.ts. The rest of the merge is the README badge update.
  • No PR-owned production or test file changed in this merge, and the merge is clean.

pnpm tc:node is now clean at b2390ae, so the branch tip typechecks again. No new issues.

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes — incremental delta b2390ae..5ed0911, base main. The prior full review was at ad83641.

  • Added Codex coverage for the unproven-stop send. structured-agent-session-codex-stop-row.test.ts gains a case that runs a real turn, makes turn/interrupt fail, and stubs the old connection so its first close() returns false (exit unproven) while later requests reject once closed. After stop() leaves owesProviderChildWindDown set, the next send must retry the stop, open a second connection, deliver turn/start there, never to the old one, and clear the owed record.
  • Fixtured the launch for the retry's start. The adapter fixture now receives a mutable launch binding, so the test can set launch.resumeThreadId = THREAD before the send, modeling the runtime resolving the launch from the record at each start.

I verified the new case is not theatre: it passes as-is (19/19 in the file), and with finishOwedStructuredAgentSessionStop removed from ensureStructuredAgentSessionAgent it fails at the two-connection assertion (expected [ … ] to have a length of 2 but got 1) — the send never opens a fresh Codex. No open Pullfrog threads remained to retire; every prior review was already a clean ✅.

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes — incremental delta 5ed0911..90dcb74 (a conflict-free merge of newer main), base main. The prior review was at 5ed0911.

  • Merged newer origin/main. The merge brings in 44 files of main-side changes (CI runner efficiency, runtime readiness/transcript harnesses, runtime-environment-* modules). None is a file this PR owns, so the PR's own diff is unchanged by this commit.
  • Verified the merged head typechecks: pnpm tc:node is clean at 90dcb74.

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

@brennanb2025
brennanb2025 merged commit fd5804d into main Oct 1, 2026
34 checks passed
@pullfrog pullfrog Bot mentioned this pull request Oct 1, 2026
7 of 9 tasks
brennanb2025 added a commit that referenced this pull request Oct 1, 2026
…rt-failure-on-message

Brings in main's native-chat Stop work (#24333 owed stop retried by the next
message, #24072 queue pause derived from Stop/Resume rows, #24334, #24437) and
#24312 (structured chat failures go through the host logger).

Conflicts, each keeping main's behaviour and this branch's design:
- queued-message-pause-table.ts: deleted, as #24072 did. Its "a returned card
  holds the queue only when it was not a failed start" rule (queuedCardHoldsQueue)
  is ported into main's queued-message-pause.ts (nextSendableQueuedCard and
  resumableQueuePause); the drain's pick in queued-messages.ts takes main's
  pause-aware nextSendableQueuedCard. New test pins it.
- delivery-loop: main's wind-down wait (#24333) goes into this branch's prepare
  step: a held owed stop records its waiting row and stops; a start refused
  because the previous exit is unverifiable waits the same way instead of being
  recorded as a failed start. onError becomes main's logger, including the
  deps type (now in delivery-loop-deps.ts). The retry-wake booking moved to
  structured-agent-session-start-retry-wakes.ts to stay under max-lines.
- dead-generation settlement: main's { ok, error } result with this branch's
  unprovenStart (the delivery loop writes a failed start); unexpected-exit and
  host-lifetime pass unprovenStart and log through main's logger.
- agent-start, attach-flow: main's logger calls; this branch's startPending
  preparation and at-rest create.
- conversation-lifetime, provider-child, host, host-delivery: main's
  finishOwedWindDown and endedAt with this branch's comments and
  startFailureRecorded; conversation delivery now takes the attach context
  (one line in host.ts, which was over max-lines).
- restart-continuation: both imports; the handed-over/verdict reading moved to
  structured-agent-session-continuation-verdict.ts for max-lines.
- tests: main's logger added to this branch's hosts and attach inputs; the
  provider-start log expectation uses main's logger line.
brennanb2025 added a commit that referenced this pull request Oct 1, 2026
…ts on that stop, not rejected as Orca's fault

Before the next message starts afresh, the delivery loop ends a child whose
start failed with a host stop. When that stop could not prove the child's exit,
it threw: the loop's catch-all rejected the next message as "Orca ran into a
problem", instead of the "still stopping" wait main added for an unproven stop
(#24333). Each later message hit the same throw.

A stop that cannot prove the exit leaves the wind-down owed. The loop now ends a
failed start's child only while no stop is owed for it, and a stop that throws
but left the wind-down owed is logged and not the message's failure: the next
step reaches main's wait (the owed-stop hold, or the start that retries the stop
and waits while it still cannot prove the exit), with its one note. Any other
error from the stop still fails the step as before.
brennanb2025 added a commit that referenced this pull request Oct 5, 2026
…d stop is joined instead of held (#24862)

* fix(native-chat): a child's root exit is reported even during its close, and bookkeeping after it never reads as unproven

- Both connections report the root process's exit once, with `expected` set when a close had
  begun. A close that came back unproven and whose root exits later is finished by the adapter,
  and its end reaches the host like any other.
- A Claude close whose resume-point write fails after the exit was proven, and a Codex close whose
  terminal row is refused, now end the session and report the failure, instead of keeping a dead
  child indexed as if its exit were unproven.
- A Codex close whose forced tree kill can't prove the descendants gone but saw the root exit
  reports the descendants and counts the root exit.
- Every child exit with an identity, expected or not, is forwarded to the host.

* fix(native-chat): the exit ends the child's record; an unfinished stop is the child's own close, which everyone joins

- The host keeps no stored "stop still owed" record any more. A stop begins the child's close
  (`child.close`), which lives on the child and ends with it. A second Stop, the idle reaper,
  quit, a send and an option/answer/goal/rewind all join that close instead of retrying a
  separate obligation.
- A caller waits on the close only as long as the step deadline; the close itself is never
  abandoned. A proof that lands after every caller stopped waiting reaches the host as the
  adapter's report of that exit, which ends the record through the same handler.
- Once the exit is proven, draining, settling, the lease release and the adapter's
  acknowledgement are each attempted and reported on failure; none keeps the child on record.
  A start, and the handle's close, write a release that failed from this host's proof of that
  exit, so a failed write never refuses a send.
- A start that meets a close still unverifiable is refused with `previousExitUnverifiable`, so the
  queued message is rejected with a send-again reason; nothing is held and nothing starts beside
  the old process.
- The idle sweep goes back to idle reaping only.
- Removes #24333's retry entry points, the wait row and its hold rule, the ask/failure cursors on
  the stored record, and the stop's own wake.

Tests replace the #24333 unproven-stop test: a send joining an unproven close and an in-flight
one, a late proof past the caller's bound, a root exiting after its close gave up, a proven exit
whose resume-point write and lease release both failed, an unverifiable close rejecting the send
and refusing an option change, a surviving descendant, quit and the idle reaper; and Codex's
unverifiable, late-exit and joined-close cases.

* fix(native-chat): a message refused because the old process's exit is unverifiable says so, and to send again

The start failure for a refusal with reason `previousExitUnverifiable` reads "Orca couldn't
confirm Claude's previous process ended. Send your message to try again." instead of "Claude
couldn't restart." The status-row kind and the refusal reason stay in the shared lists for rows
and hosts that still carry them; the catalogs keep one sentence for both.

* fix(native-chat): a close's verdict is the root's exit alone, and what follows it is logged

- A Claude close resolves as soon as the root's exit is proven: the session ends and its `ended`
  report goes out then. Saving the resume point runs afterwards and a failure is logged, so a slow
  or hung write never reads as an unproven exit or keeps a dead child on record.
- A root that exits after its close came back unproven finishes that close through the same path
  as any close, so the session's child work is published as ended (background tasks and subagents
  no longer stay shown running for a dead agent), and a failure there is logged.
- Codex logs a refused final row, and reports a root exit whose forced tree kill could not prove
  the rest of the tree gone the way Claude does, so the host logs it and blocks nothing.
- Both adapters take the host's logger for this bookkeeping.

* fix(native-chat): one handler ends every child's exit, and a join waits on the adapter's own close

- One exit handler (`structured-agent-session-child-exit`) ends a child's record for an exit
  expected or not. `expected` only changes what the chat is told: the stop's cause, its end at the
  stop's ask, the settlement id, and no crash outcome row. The lease release keeps the exit's
  evidence; the handoff guard, lifecycle barrier, sink release and adapter acknowledgement apply to
  both. A Claude journal-sink failure ends in the same step as its stop, as Orca's own fault.
- Joining a close is asking the adapter, whose close is memoized while it runs and bounded by its
  own kill escalation; the host keeps no attempt of its own and no 10 s caller bound. An ask after
  a close came back unproven runs the stop again.
- A close's end is stamped where its stop was asked for (a repeated ask moves it), so the closed
  chat and failed start checks order a message accepted meanwhile after it.
- A start refused because the old exit is unverifiable rejects what was queued in the same step.
- The end of a close the host asked for no longer waits on the cross-session recovery chain.
- The kill no longer waits for the stop event's write; the journal writes rows in order.

* fix(native-chat): an exit's lease release lands whatever the length of its reason

A crash's reason can carry kilobytes of the provider's stderr, and a lease whose death detail is
over 512 characters fails the store's own check. The exit handler cut it, but the release a start
or the chat handle's close re-derives did not, so after a crash whose own release failed every
message was refused as not resumable until restart. The record's builder now cuts the detail to
the record's bound, so no writer can hand it one too long.

* fix(claude): a proven close waits at most 2 s for the output it already wrote

Once the root's exit is proven, the close still waited for the SDK's output reader to end. Something
outside the process tree that holds the output open would keep that close, and every send, Stop
and quit joining it, waiting with no bound. The wait is now bounded; past it the close resolves as
proven and the open output is logged.

* fix(codex): an exit reported inside Orca's close keeps the reason Orca closed it for

The connection reports the app-server's exit inside the close that ends it, so that report ended
every Codex close and replaced the close's own reason (for example, a provider frame that could not
be recorded) with the connection's stderr text in the ended record and the lease's exit evidence.
The session now records Orca's close with its reason, and the exit it ends keeps that reason. The
test connection reports its exit inside close the way the real one does.

* fix(native-chat): quit stops delivery before it drains exit recovery

Every exit now wakes delivery, and teardown drained exit recovery before it stopped delivery, so an
exit settled in that window could start a fresh agent that teardown then killed. Teardown stops
delivery first; queued messages wait for the next launch.

* docs(native-chat): the unverifiable-exit refusal no longer names a caller's wait

The caller's bounded wait was removed; the comment describes the close as it is now.

* fix(native-chat): a stop whose kill did not take is logged, and the next ask kills again

When a close's kill leaves the agent's root running, the host now logs it. Tests pin what a later
ask does: each connection runs its whole stop again (Codex sends SIGKILL a second time), refuses
input meanwhile, and proves the exit once the kill takes.

* fix(native-chat): a start refused over the old process says Orca couldn't stop it

The host reaches an unverifiable verdict only after its own kill left the agent's root running, on
the machine that runs the agent, so the sentence now says that: "Orca couldn't stop {agent}'s
previous process." The refusal reason, failure kind and wire shapes are unchanged. The host test
also checks the failed kill is logged.

* docs(native-chat): an unverifiable close verdict is a root that survived the kill

The host's close runs where the agent runs, so lost contact never yields this verdict; the comment no longer says it does.

* fix(native-chat): a kill that did not take is reported once, by whoever met it

The log added at the close fired beside a Stop's own failure report for the same event. A stop
still reports it through its failure; a send or option change refused over it now logs it at the
refusal, the only place it is otherwise invisible.

* test(native-chat): a second Stop joins a close the first could not prove and retries its kill

* fix(native-chat): say a start refused beside an unstopped process plainly

The rejection now reads "Couldn't stop {{agent}} from before. Send your message again to try once more."
This kind has its own send-again step; every other failure keeps "Send your message to try again."
brennanb2025 added a commit that referenced this pull request Oct 6, 2026
* fix(native-chat): a child's root exit is reported even during its close, and bookkeeping after it never reads as unproven

- Both connections report the root process's exit once, with `expected` set when a close had
  begun. A close that came back unproven and whose root exits later is finished by the adapter,
  and its end reaches the host like any other.
- A Claude close whose resume-point write fails after the exit was proven, and a Codex close whose
  terminal row is refused, now end the session and report the failure, instead of keeping a dead
  child indexed as if its exit were unproven.
- A Codex close whose forced tree kill can't prove the descendants gone but saw the root exit
  reports the descendants and counts the root exit.
- Every child exit with an identity, expected or not, is forwarded to the host.

* fix(native-chat): the exit ends the child's record; an unfinished stop is the child's own close, which everyone joins

- The host keeps no stored "stop still owed" record any more. A stop begins the child's close
  (`child.close`), which lives on the child and ends with it. A second Stop, the idle reaper,
  quit, a send and an option/answer/goal/rewind all join that close instead of retrying a
  separate obligation.
- A caller waits on the close only as long as the step deadline; the close itself is never
  abandoned. A proof that lands after every caller stopped waiting reaches the host as the
  adapter's report of that exit, which ends the record through the same handler.
- Once the exit is proven, draining, settling, the lease release and the adapter's
  acknowledgement are each attempted and reported on failure; none keeps the child on record.
  A start, and the handle's close, write a release that failed from this host's proof of that
  exit, so a failed write never refuses a send.
- A start that meets a close still unverifiable is refused with `previousExitUnverifiable`, so the
  queued message is rejected with a send-again reason; nothing is held and nothing starts beside
  the old process.
- The idle sweep goes back to idle reaping only.
- Removes #24333's retry entry points, the wait row and its hold rule, the ask/failure cursors on
  the stored record, and the stop's own wake.

Tests replace the #24333 unproven-stop test: a send joining an unproven close and an in-flight
one, a late proof past the caller's bound, a root exiting after its close gave up, a proven exit
whose resume-point write and lease release both failed, an unverifiable close rejecting the send
and refusing an option change, a surviving descendant, quit and the idle reaper; and Codex's
unverifiable, late-exit and joined-close cases.

* fix(native-chat): a message refused because the old process's exit is unverifiable says so, and to send again

The start failure for a refusal with reason `previousExitUnverifiable` reads "Orca couldn't
confirm Claude's previous process ended. Send your message to try again." instead of "Claude
couldn't restart." The status-row kind and the refusal reason stay in the shared lists for rows
and hosts that still carry them; the catalogs keep one sentence for both.

* fix(native-chat): a close's verdict is the root's exit alone, and what follows it is logged

- A Claude close resolves as soon as the root's exit is proven: the session ends and its `ended`
  report goes out then. Saving the resume point runs afterwards and a failure is logged, so a slow
  or hung write never reads as an unproven exit or keeps a dead child on record.
- A root that exits after its close came back unproven finishes that close through the same path
  as any close, so the session's child work is published as ended (background tasks and subagents
  no longer stay shown running for a dead agent), and a failure there is logged.
- Codex logs a refused final row, and reports a root exit whose forced tree kill could not prove
  the rest of the tree gone the way Claude does, so the host logs it and blocks nothing.
- Both adapters take the host's logger for this bookkeeping.

* fix(native-chat): one handler ends every child's exit, and a join waits on the adapter's own close

- One exit handler (`structured-agent-session-child-exit`) ends a child's record for an exit
  expected or not. `expected` only changes what the chat is told: the stop's cause, its end at the
  stop's ask, the settlement id, and no crash outcome row. The lease release keeps the exit's
  evidence; the handoff guard, lifecycle barrier, sink release and adapter acknowledgement apply to
  both. A Claude journal-sink failure ends in the same step as its stop, as Orca's own fault.
- Joining a close is asking the adapter, whose close is memoized while it runs and bounded by its
  own kill escalation; the host keeps no attempt of its own and no 10 s caller bound. An ask after
  a close came back unproven runs the stop again.
- A close's end is stamped where its stop was asked for (a repeated ask moves it), so the closed
  chat and failed start checks order a message accepted meanwhile after it.
- A start refused because the old exit is unverifiable rejects what was queued in the same step.
- The end of a close the host asked for no longer waits on the cross-session recovery chain.
- The kill no longer waits for the stop event's write; the journal writes rows in order.

* fix(native-chat): an exit's lease release lands whatever the length of its reason

A crash's reason can carry kilobytes of the provider's stderr, and a lease whose death detail is
over 512 characters fails the store's own check. The exit handler cut it, but the release a start
or the chat handle's close re-derives did not, so after a crash whose own release failed every
message was refused as not resumable until restart. The record's builder now cuts the detail to
the record's bound, so no writer can hand it one too long.

* fix(claude): a proven close waits at most 2 s for the output it already wrote

Once the root's exit is proven, the close still waited for the SDK's output reader to end. Something
outside the process tree that holds the output open would keep that close, and every send, Stop
and quit joining it, waiting with no bound. The wait is now bounded; past it the close resolves as
proven and the open output is logged.

* fix(codex): an exit reported inside Orca's close keeps the reason Orca closed it for

The connection reports the app-server's exit inside the close that ends it, so that report ended
every Codex close and replaced the close's own reason (for example, a provider frame that could not
be recorded) with the connection's stderr text in the ended record and the lease's exit evidence.
The session now records Orca's close with its reason, and the exit it ends keeps that reason. The
test connection reports its exit inside close the way the real one does.

* fix(native-chat): quit stops delivery before it drains exit recovery

Every exit now wakes delivery, and teardown drained exit recovery before it stopped delivery, so an
exit settled in that window could start a fresh agent that teardown then killed. Teardown stops
delivery first; queued messages wait for the next launch.

* docs(native-chat): the unverifiable-exit refusal no longer names a caller's wait

The caller's bounded wait was removed; the comment describes the close as it is now.

* fix(native-chat): a stop whose kill did not take is logged, and the next ask kills again

When a close's kill leaves the agent's root running, the host now logs it. Tests pin what a later
ask does: each connection runs its whole stop again (Codex sends SIGKILL a second time), refuses
input meanwhile, and proves the exit once the kill takes.

* fix(native-chat): a start refused over the old process says Orca couldn't stop it

The host reaches an unverifiable verdict only after its own kill left the agent's root running, on
the machine that runs the agent, so the sentence now says that: "Orca couldn't stop {agent}'s
previous process." The refusal reason, failure kind and wire shapes are unchanged. The host test
also checks the failed kill is logged.

* docs(native-chat): an unverifiable close verdict is a root that survived the kill

The host's close runs where the agent runs, so lost contact never yields this verdict; the comment no longer says it does.

* fix(native-chat): a kill that did not take is reported once, by whoever met it

The log added at the close fired beside a Stop's own failure report for the same event. A stop
still reports it through its failure; a send or option change refused over it now logs it at the
refusal, the only place it is otherwise invisible.

* test(native-chat): a second Stop joins a close the first could not prove and retries its kill

* fix(native-chat): say a start refused beside an unstopped process plainly

The rejection now reads "Couldn't stop {{agent}} from before. Send your message again to try once more."
This kind has its own send-again step; every other failure keeps "Send your message to try again."

* Move provider process supervision and stream reading out of Codex

* Preserve teardown behavior with checked mock types after move

* Apply provider launch environment and caller teardown labels

* Share managed provider launch, exit observation, and close

* Preserve synchronous stdin closure before the grace wait

* Keep the stacked Claude adapter within the module size limit

* Handle nullable provider stdin and processless fixture identity

* Preserve close-time cleanup diagnostics after a late provider exit

* Separate provider root and descendant exit observations

* Give provider child env one owner and gate Codex contract on the shared reader

resolveProviderChildEnv is now the only place that overlays and strips a
provider's environment; the spawn spec and the request-scoped Codex session
both call it. supervisedPosixLaunch only accepts a launch without env fields,
so an override can no longer be silently ignored there. Edits to the shared
stream reader or the env rule now run the real-binary Codex contract job.

* Give every provider one close result, stderr tail and root-only default

- A close reports the root and the descendants with the one verdict vocabulary
  (live / unverifiable / exited); `tree` is null when the close made no
  descendant observation, and the fallback teardown's outcome is written into
  it instead of a side flag a caller could miss.
- The managed process drains stderr and keeps the 8 KiB tail, so no provider
  can forget to drain the pipe.
- Root-only providers take the default close policy and completion rule; only
  Claude overrides them. The one already-exited guard lives in the managed
  close and is recorded when no earlier close ran.
- A failed spawn is never read as an observed root exit.

* Report only observed descendant exits from the fallback teardown

The fallback teardown answered "accepted", and the close turned that into
`tree: 'exited'`, though a Windows tree kill's outcome is never read and an
unreadable process table observes nothing. It now returns what it observed:
`exited` only when the captured descendants were verified gone (or the kernel
reported the group empty), `live` / `unverifiable` as verification found them,
and null when it signalled without observing. Codex's process-tree diagnostic
fires in exactly the cases it did before.

* Give two test fixtures' casts a SAFETY rationale for the changed-code gate

* Claim no observation from an empty process group

ESRCH from the dedicated-group signal says only that the group is empty: a
descendant that left it, or a root that never led it, may still be running.
It now reports no observation instead of `exited`. The unreadable-table
comment says why that case stays "no observation" for root-only providers.

* fix(native-chat): a person's close joining a failed one still binds the turn its child end cuts

On main every close of the chat writes its own Stop and settle. Here a later close joins the
first and writes no row, and the first's settle closed when its kill failed, so a turn that opened
in between and was cut by the next close read as failed. A person's close joining a person's close
whose Stop opened a settle now reopens that settle until its attempt is done.

Tests: a close whose kill failed still closes its settle; a turn opened between a failed close and
the next reads as the person's cancellation (each fails without its half of the fix).

* test(native-chat): build the Stop-opened-turn test's identity with the opaque handle

The test (#25056) landed before the opaque provider handle (#24991), so main still built
the old {kind, threadId} handle.

* test(ratchet): require src/main/provider-process now that it has landed

* test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test

Main's #25706 made the same fix as this branch at a different line; the merge kept both imports.
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.

2 participants