Repository navigation
fix(native-chat): a message waiting on an unconfirmed stop goes out as soon as the old process exits - #24362
fix(native-chat): a message waiting on an unconfirmed stop goes out as soon as the old process exits#24362brennanb2025 wants to merge 26 commits into
Conversation
…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.
…he fingerprint's own shape
…hat stop at once A stop that could not prove the child's exit (its proof failed, or ran past the 10 s step deadline) left a waiting message until the idle sweep's next 5-minute tick, even when the old process exited moments later: the connection saw that exit but told no one, and a stop that proved it late had no caller. Both are now reported. The Claude and Codex connections report a root exit seen after a close gave up; the adapters publish it as an exitAfterClose lifecycle event for that exact child; and a stop step that proves the exit after its pass gave up says so too. Either way the host retries the owed stop on the session's own lane, only for the child it names, and the stop that lands hands over what waited on it. The sweep stays the backstop.
…fter its stop gave up Claude and Codex, on the shipping adapters and host: a late exit report, and a stop that proves the exit after its deadline, each send the held message with no sweep tick. A report naming another child, or from a connection that is no longer the chat's, touches nothing. Connection-level checks pin when an exit is reported (after a close gave up, never during a running one), and the delivery keeps the report off the cross-session exit chain.
… as a late exit Node reports a child's end twice: 'exit', then 'close' once its stdio drains. When 'close' landed after a close's proof had settled true, the connection reported an exit after a close gave up, because it gated on "no close is running" rather than "the last close gave up". The adapter usually dropped it (the session was already gone); when settlement admission had refused, the host ran one needless retry. The gate now reads the exit proof itself, which drops an attempt only when it was unproven, the same rule the Claude connection uses.
… passes gave up on it Every stop pass started while a Claude close hangs joins that one close, so a Stop, a held message's retry and each sweep tick could all give up on it. When the exit finally proved, each of them asked for a retry of the owed stop. If the first retry then failed a bookkeeping step, every other ask ran another full pass back to back, each with its own 10 s step deadline, holding up that chat. Now only the pass whose proof actually ended the child asks; a failed retry waits for the sweep or the next action, like any other.
…it already wraps The host built the restart lease reconciler only to hand it to the restore factory and to attach and start. The factory now builds that one instance from the host's probe and clock and exposes it, so attach and start share the same in-flight reconcile as before. The host file regains the room to keep its explicit return types on flushStreamedEvents and subscribe.
…wed stop and starts no agent
Review status (head 9d91204): review finished; draft until a final verification passThe problem. After #24333, if you press Stop on a native Claude or Codex chat and Orca can't confirm the old agent process ended, your next message waits under the note "Orca couldn't confirm Claude's previous process ended. Messages wait to be sent until Orca confirms it has ended." If that process then exited after Orca's retry had given up, nothing told Orca. The message sat there until your next action or Orca's background check, which runs every 5 minutes. What changes for you. When that old process exits, Orca notices right away, re-checks that it's gone, and sends the waiting message to a fresh agent within moments. You don't need to do anything else. If nothing was waiting, the chat is simply ready again. A normal Stop, where the exit is confirmed at once, behaves exactly as before. Fixed during review (each with a test that fails without the fix):
Deferred (small, none user-visible):
Update: merged #24333's latest branch (cef967c, which brings in current Update: #24333 landed on Update: merged Verified:
Not verified:
|
# Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts
…en retries that stop, then goes to a fresh Codex
…-stop' into brennanb2025/late-exit-wakes-held-send # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.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.ts # src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts # src/main/runtime/structured-agent-session-lifecycle-delivery.ts # src/main/runtime/structured-agent-session-runtime.ts
…t-wakes-held-send # Conflicts: # 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-lifetime.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.ts
…fecycle events The exit-after-close event and the lifecycle union had moved to their own file only because the adapter contract file was over its line limit on the stacked base. Main's split of that file left room, so both go back beside the started and ended events, and the extra file is gone.
|
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. 🧰 Additional context used📚 Code guidelines (1)No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (11)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review. 📝 WalkthroughWalkthroughClaude and Codex connections now report child exits observed after close gives up proving exit. Session lifecycle handling routes these events to serialized recovery for matching owed wind-downs. Runtime delivery tracks non-ended event settlements, and tests cover late exits, stale child reports, retries, and held messages. The host restore factory now constructs and exposes lease reconciliation. Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to This change delivers a waiting message promptly once an old Claude or Codex process exits after a stop that could not be confirmed. Recovery is limited to the matching process, so a stale report cannot affect a newer one. No merge-blocking issue was found in the supplied changes. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The change resumes waiting messages after a matching stopped process exits. Identity checks and ordered cleanup constrain that handoff, and no concrete security regression was found in the reviewed path. Broader exposure and rollback coverage remain incomplete. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
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. Comment |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Connections report a late exit.
claude-stream-json-connection.tsandcodex-app-server-connection.tsgainonExitAfterClose, fired only when the root is seen exiting after a close that already gave up (Claude:closing && !closePromise; Codex:exitObserved && closing && !exitProof.holdsAttempt). The newRetryableProcessExitProof.holdsAttemptgetter is what distinguishes "a proven attempt is held" from "the attempt failed". - New
exitAfterClosehost lifecycle event. Both adapters turn the callback into it (reportClaudeExitAfterClose/reportCodexExitAfterClose, each filtered on connection identity; Codex also onsession.ended), carrying fence + acquisition generation. It rides thestarted/endedpath, stays off the cross-chat crash-recovery chain, and is still drained at teardown. - Late-proof retry in the host.
stopStructuredAgentSessionAgentUnderSerializenow flagsgaveUpwhen its deadline aborts, andonProviderChildStoppedschedulesfinishStopAfterExitonly for the pass whose proof actually ended the child. Both signals converge onfinishOwedStructuredAgentSessionWindDownAfterExitUnderSerialize, which re-checkspendingProviderChildWindDown+sameProviderChildbefore re-running the existing owed-stop retry with the stop's original cause. reconcileLeasesrefactor. Moved intocreateStructuredAgentSessionHostRestore; verified behavior-equivalent (one instance, sameprobe/now/probeMany, returned to attach/start).- Tests. New host-level Claude/Codex late-exit files, connection-level report tests, and a lifecycle-delivery drain test; each new behavior has a stated ablation.
I traced the concurrency and fence guards independently (two specialist passes): the per-session serialize lane, endProviderChild's one-winner match, execution-time identity re-check, and the mutually-exclusive A/B triggers hold for the interleavings I could construct, including several passes giving up on one hung close and a newer child starting.
ℹ️ Non-blocking: Claude only clears closePromise on the return-false path
Worth noting, not a request for changes — this mirrors an existing contract and I could not demonstrate a reachable trigger.
Technical details
# Claude `close()` reset is asymmetric with Codex
## Affected sites
- `src/main/claude/claude-stream-json-connection.ts` — `closePromise = null` happens only inside the
non-throwing `if (!proven)` branch; a close IIFE that *rejects* leaves `closePromise` non-null and
`closing` true forever.
- `src/main/codex/codex-app-server-connection.ts` + `src/shared/child-process/retryable-process-exit-proof.ts` —
`RetryableProcessExitProof.run` clears `inFlight` on both `false` and rejection, so Codex still reports.
## Required outcome
None required for this PR. Recorded so a future change that makes a Claude close able to reject knows the new
`onExitAfterClose` report silently depends on `closePromise === null`.
## Notes
- No test exercises a throwing close; all new Claude tests go through `proveClaudeChildExit` resolving `false`.
- A fix would be one line (clear `closePromise` on rejection, as Codex does), but it guards a case that is not
currently reachable, so it is out of scope here.deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…t-wakes-held-send
… end of the turn it cut The stop that lands after the old child exits late is the owed stop's retry, so it writes no Stop event of its own: the person's Stop stays the latest, and the turn it cut, ended only by that retry, reads as their cancellation.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run reviewed the delta since the prior pullfrog review at 72279e34f5: a single test-only commit. No production code changed, and the underlying mechanism still matches the previously approved shape.
- Late-exit Stop-event test.
structured-agent-session-claude-unproven-stop-send.test.tsgains"keeps the person's Stop as the cut turn's end when the old child exits late, writing no Stop of its own". It drives an unproven Stop deliberately without framing an interrupted result, holds a message, then firesonExitAfterCloseand asserts the late retry writes no Stop event — the person'suser-stopmark stays latest — and the cut turn still readsinterrupted/cancellation. This pins the #24311 contract that the late retry reuses the owed stop rather than issuing a new Stop. The test is non-vacuous: it first asserts the captured mark matches{ event: { reason: 'user-stop' } }, so a missing mark fails before the equality check.
The connection predicates (closing && !closePromise for Claude; exitObserved && closing && !exitProof.holdsAttempt for Codex), the host's gaveUp/finishStopAfterExit retry fence, and the off-chain lifecycle delivery are unchanged from the reviewed-and-approved head.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
|
Superseded by #24862, which removes the root cause this PR worked around: #24333's stored unfinished-stop record that a waiting message sat behind. In #24862 the process's own exit ends that record, and the next start joins the one shared close, so there is no late-exit signal to add. Branch kept for reference. |







What this fixes, and where
The problem. After #24333, when you press Stop on a native chat and Orca can't prove the old agent process exited, your next message no longer fails: it waits in the chat under the note "Orca couldn't confirm Claude's previous process ended. Messages wait to be sent until Orca confirms it has ended." It goes out once a later retry proves the exit. But if the old process exits on its own after Orca's retry gave up, nothing told the host. The message kept waiting for the next retry: an action of yours (a model change, Steer on a queued card), or Orca's background check for idle chats, which runs every 5 minutes. So a process that exited a moment after the retry still left your message stuck for up to 5 minutes.
Why it happened. A retry can give up two ways, and neither left anyone listening:
This was a known gap in #24333, listed there as a Temporary difference.
Scope. Native (structured) chats, Claude and Codex, on a local or SSH host. Everything here runs on the host that owns the process, so over SSH it runs on the remote host. Terminal agent tabs are unaffected. A stop that proves the exit the first time behaves exactly as before.
ELI5
When Orca stops an agent, it waits to be sure the old process is gone before starting a new one. If it couldn't be sure, your message waits. Before, if the old process then quit on its own, Orca didn't notice until its 5-minute check. Now Orca notices the moment that process quits, double-checks, and sends your message right away.
What Changed
Before (for you):
After (for you):
Mechanism.
onExitAfterClose, called when the process is seen to exit after a close of it gave up, and only then. An exit seen while a close is still running stays the close's own business, as before, so a normal stop reports nothing new. Both connections decide this from the close's own result: Claude reports when it is closing and the last close returned unproven; Codex reports when its exit proof no longer holds an attempt, which happens only after an attempt failed to prove the exit (a proven attempt stays held). So the second "process ended" notice Node sends once the process's output pipes close, which can land after a close already proved the exit, reports nothing.exitAfterClose, carrying the exact process it is about (the lease fence and the per-process generation). It travels the path the existingstartedandendedevents already use, from the adapter to the host's lifecycle handler. Likestarted, it stays off the queue that handles crashed-process recovery one at a time across all chats, so a retry waiting on one chat never delays another chat's recovery; teardown still waits for it. The event is declared besidestartedandendedinstructured-agent-session-adapter.ts.finishOwedStructuredAgentSessionWindDownUnderSerializepass fix(native-chat): after a Stop whose exit can't be confirmed, the next message retries the stop instead of failing #24333 uses, with the stop's original cause) only if the owed stop is for exactly the process named; a report about any other process does nothing. When the retry lands, the stop's own last step wakes message delivery, as in fix(native-chat): after a Stop whose exit can't be confirmed, the next message retries the stop instead of failing #24333, so the waiting message goes out. Being a retry, it writes no Stop event of its own, so the person's Stop stays the one the cut turn's end reads (fix(native-chat): a Stop still reads as yours after Orca restarts, because the turn's end reads the Stop's event #24311): that turn still reads as their cancellation.What ends each piece of state. No new stored state. The host keeps nothing new, and neither connection adds a flag: each reads "a close gave up" from the close's own result. Each report is used once: it either finds the owed stop for its process and retries it, or it finds nothing and is dropped. A retry that still can't prove the exit leaves the stop owed exactly as before, and the existing retries (your next action, the 5-minute check, quit) keep going.
Why
endedwithunexpected-exit). Rejected: the host settles that as a crash (it says the agent exited and records the turn as cut off by the exit), but this process ended because the user stopped it. The Stop's cause and its place in the chat must be kept.Differences from the common pattern
Linked Issue
Follow-up to #24333 (its Temporary difference: a late exit isn't noticed). Builds on #24235.
Visual Proof
N/A: the state can't be forced on a real host. It needs a stop whose exit proof fails or runs past 10 s, and on macOS Orca always sees its own child's exit within the proof, so this can't be shown without admin rights (the same limit as #24333). The host-level tests below drive it on the shipping adapters and host.
Testing
New host-level tests run the shipping adapter and host against a fake connection that, like the real ones, refuses every write once a close begins.
Claude, in
structured-agent-session-claude-unproven-stop-send.test.ts(#24333's file):exitAfterClosefor a different generation retries nothing and the message keeps waiting.Also in that file, for #24311's Stop events: when only the late exit's retry ends the turn the Stop cut, the latest Stop event is still the person's, and that turn reads as their cancellation.
Codex, in the new
structured-agent-session-codex-late-exit.test.ts(adapter events routed to the host as the runtime routes them):7. The old app-server exits after its close gave up: a tab close unproven twice (the close and the message's retry), then the exit report; the message is sent on a new app-server as a
turn/start.8. A report from an app-server that is no longer the chat's: after that, the old connection reporting again publishes nothing and does not close the new app-server.
Connections and delivery:
9. Claude connection (
claude-stream-json-connection-close.test.ts) and 10. Codex connection (codex-app-server-connection.test.ts): an exit after an unproven close is reported once throughonExitAfterCloseand not as an unexpected exit; an exit seen while a close is running reports nothing, and for Codex neither does the late second notice after a close proved the exit.11. Lifecycle delivery (new
structured-agent-session-lifecycle-delivery.test.ts): a report whose handling waits on its own chat does not hold up another chat's crash recovery, and teardown's drain still waits for it.Ablations. Each reverts one piece and runs that piece's test file(s). Counts are the file sizes when each was run (the first set before tests 5 and 6 were added):
exitAfterClose: tests 1, 4, 7 and 8 fail (4 of 19, Claude and Codex files together).Suites. Locally, every test file that imports a module this PR touched, the new files, and the tests main's merge brought into these areas: 243 files, 2206 passed, 1 skipped. One real-process Claude connection test (
proves a natural SDK exit and cleans up its descendant before recovery) failed its exit proof once under that full parallel run and passes alone (3 of 3) and with its whole file. CI runs the suites at9d91204b70d.Lint.
oxlinton the changed files: clean.check:code-quality:changed: 0 new findings.Typecheck.
pnpm tc:node: passes.Platforms: logic tests on macOS. Not exercised on a live Claude or Codex, Windows, Linux or a real SSH host.
Review
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
structured-agent-session-host.tswas at its 300-line limit. The restart lease reconciler, which only restart restore, attach and start use, now lives in the restore factory that already wrapped it (its ownrefactorcommit, no behavior change: still one instance with the same in-flight sharing). That leaves room for the new wiring with every explicit return type kept. The new lifecycle event sits beside the others instructured-agent-session-adapter.ts, which stays under its limit.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)