Repository navigation
fix(native-chat): after a Stop whose exit can't be confirmed, the next message retries the stop instead of failing - #24333
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
Review status: ready for a merge decisionThe 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:
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):
Deferred:
Verified:
Not verified:
The PR is marked ready; I have not merged it. |
|
|
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 configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (20)
🚧 Files skipped from review as they are similar to previous changes (7)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review. 📝 WalkthroughWalkthroughThe 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 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 ReviewSecurity architecture risk: 🔵 Low · up to 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 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)
Full details: Docstring CoverageExplanation 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.)
✨ 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.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 02ab0a3e-4a2c-48cf-acc9-e6aa2f9f2e7f
📒 Files selected for processing (29)
src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.tssrc/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.tssrc/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-types.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host.tssrc/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.tssrc/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.tssrc/main/native-chat/agent-session-wire/structured-agent-session-provider-child.tssrc/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.tssrc/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.tssrc/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.tssrc/renderer/src/components/native-chat/agent-session-failure-words-text.tssrc/renderer/src/i18n/en-runtime-required.jsonsrc/renderer/src/i18n/locales/en.jsonsrc/renderer/src/i18n/locales/es.jsonsrc/renderer/src/i18n/locales/fr.jsonsrc/renderer/src/i18n/locales/ja.jsonsrc/renderer/src/i18n/locales/ko.jsonsrc/renderer/src/i18n/locales/zh.jsonsrc/shared/agent-session-failure-copy.tssrc/shared/agent-session-failure-words.tssrc/shared/agent-session-failure.tssrc/shared/agent-session-refusal-details.tssrc/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, { |
There was a problem hiding this comment.
🩺 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-wireRepository: 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.tsRepository: 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.tsRepository: 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-wireRepository: 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 = undefinedThere was a problem hiding this comment.
✅ 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 newopenForProviderWrite→finishOwedStoppath (option changes, prompt answers, background-task stops) callfinishOwedStructuredAgentSessionStopfirst; an exit still unproven returns thepreviousExitUnverifiablerefusal instead of assumingexited. - 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.
releaseLeasewakes 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/failedAton the owed record, andendProviderChild's optionalendedAt, 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.
structuredAgentSessionWindDownWaitHoldssuppresses 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.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
# Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts
There was a problem hiding this comment.
✅ 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.
finishOwedStructuredAgentSessionWindDownUnderSerializenow reports a failed retry throughcontext.deps.logger.warn(scopeowed-stop-retry) instead of the removedonEventSinkError; the test rigs moved torecordingStructuredAgentSessionLoggerand assert that scope. - Wind-down wait writer re-shaped.
recordStructuredAgentSessionWindDownWaitnow takessessionIdplus lazyconversationFence/failureTextContext; the delivery loop's privatewaitOnUnprovenExitwas inlined into its two hold branches with identical behavior. - Host wiring updated for the merge.
import * as agentStart,finishOwedStopadded to the mutation context,finishOwedWindDownthreaded through the conversation lifetime into the idle sweep, andwakeDeliveryadded 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.deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — incremental delta d9771b9..b2390ae (a second merge of origin/main), base main.
- Merged
origin/mainthrough#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.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…en retries that stop, then goes to a fresh Codex
There was a problem hiding this comment.
✅ 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.tsgains a case that runs a real turn, makesturn/interruptfail, and stubs the old connection so its firstclose()returnsfalse(exit unproven) while later requests reject onceclosed. Afterstop()leavesowesProviderChildWindDownset, the nextsendmust retry the stop, open a second connection, deliverturn/startthere, 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
launchbinding, so the test can setlaunch.resumeThreadId = THREADbefore 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 ✅.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ 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:nodeis clean at90dcb74.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…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.
…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.
…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."
* 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.







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 (for you):
Mechanism.
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 (codeagent_session_ownership_unknown, reasonpreviousExitUnverifiable,ownerVerdict: 'unverifiable'). Orca treats the exit as unverifiable, never as exited.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.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.
Why
Differences from the common pattern
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.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-inclaudebecause 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.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.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.noLiveOwner), and the waiting message still goes to a resumed Claude.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 withprovider_write_failed(writeFailed), and no new Claude started. A later Stop answeredcancelled: 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.
e10a1028729(13 tests): an option change refused whenever any stop is owed fails test 13.a8d87c343f7(12 tests): a held message skipping the note fails test 12.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).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.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 inagent-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. Onbc4af0dc411(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 at90dcb740b9eafter a conflict-free merge of newer main):pnpm tc:nodeclean; 214 test files, 1851 tests, all passing except one cross-version harness test that timed out locally under heavy machine load (CI covers it);oxlintand the anti-slop config clean on the changed files.Typecheck.
pnpm tc:nodeone10a1028729: clean.pnpm tc:webonbc4af0dc411: clean (nothing it compiles changed since).Lint. On
ad83641757c:oxlintand the anti-slopoxlintconfig on the changed files are clean, and one10a1028729check:code-quality:changedreported 0 new findings. The localization catalog, extraction and coverage checks pass onbc4af0dc411.Platforms: logic tests on macOS. Not exercised on a live Claude, 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
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)