Repository navigation
Share the managed process lifecycle for structured providers - #25204
Conversation
…se, 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.
…p 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.
… 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.
…t 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.
…ts 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.
…f 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.
…dy 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.
…a 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.
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.
…ller's wait The caller's bounded wait was removed; the comment describes the close as it is now.
…ext 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.
…dn'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.
…ved 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.
…er 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.
…p-exit-ends-record # Conflicts: # src/main/codex/codex-app-server-connection.test.ts
…p-exit-ends-record # 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-eviction.test.ts # src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts
…ove and retries its kill
…p-exit-ends-record # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.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
…inly
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."
…cess' into brennanb2025/acp-a6-provider-lifecycle
…rovider-lifecycle
…rovider-lifecycle
Review summary (head
|
main #24864 (a Stop binds only the turn it stopped): recordStopEvent now resolves to the settle a person's close opens, and the stop closes it once the child's end is done. Re-applied on this branch's child close: the close's `recorded` carries that settle (null when no row is written), and the stop closes it in a finally around the restart snapshot and the close join (which awaits the exit settlement), proven or not. A second stop joining the same close closes it again, which is a no-op.
…he 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).
This branch carried an earlier copy of #24862's commits; main now has its final, squashed form. Main's #24862 wins for #24862's code; this branch's own changes (shared managed provider process, close result, stderr tail) are re-applied on top: - host-lifetime.ts, host-types.ts, stop-settle-binding.test.ts: main's version. Final #24862 already holds this branch's earlier merge resolution (the close's recorded settle) and its later fix (a repeated close reopens the settle), with its own test for it. - claude-stream-json-connection.ts, codex-app-server-connection.ts: this branch's managed-process version; main's side of these files equals the earlier #24862 copy this branch replaced (checked with a three-way merge over old-#24862-on-main as base). Drops a duplicate processTreeUnproven getter the plain merge left outside the conflict.
|
Merged current main into this branch twice (now
|
…d-turn test Main's #25706 made the same fix as this branch at a different line; the merge kept both imports.
…ositions handle fix)
|
Earlier CI merges missed main's restored provider-handle import and the test-only parser dependency still needed by old release checkouts. Merged main |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (30)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review. 📝 WalkthroughWalkthroughThe changes add a managed provider-process lifecycle that tracks root exits, processless spawns, stderr, and descendant-tree verdicts. Claude and Codex connections now use that lifecycle for spawning, exit handling, and close operations. Teardown reports whether the process tree was verified or whether its status remains unknown. Claude structured-session startup waiting and event delivery now use dedicated helpers. Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to No merge-blocking behavior change was established. The PR is mergeable after normal checks. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The reviewed integrations preserve their existing shutdown guarantees and launch permissions. The main risk is applying shared lifecycle rules consistently across integrations and platforms; coverage of those wider behaviors remains incomplete. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 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 |
…agent-tree-job-object Main moved the provider close ladder into the shared managed-process close (#25204) and kept the Windows creation-time refusal in Codex launch resolution. Resolved by keeping that refusal removed, taking main's pinned config variable, and re-expressing this branch's Windows close rules on the shared close: the Windows teardown reports taskkill's verdict ('exited' on exit 0, else 'unverifiable'), and a Claude root that leaves on its own after stdin end with no forced reap on its tree is a proven close (Claude's close policy on win32).
Main's #25204 moved every structured provider's close onto one shared close (closeProviderProcess, driven by a close policy) and spawn (spawnManagedProviderProcess). That is the same concept as PR1's stopSupervisedProvider and requestProviderClose, so PR1's copies are removed and its callers move onto main's: - The Codex connection and the Claude spawn and exit proof take main's versions. - The close request a gone owner gets is derived from the provider's close policy inside spawnManagedProviderProcess (signalSupervisorOnClose -> 'stdin-end-and-sigterm', else 'stdin-end'), so one source drives both the owner's close and the supervisor's. - stopSupervisedChildProcess (agent one-shots) runs main's closeProviderProcess with the root-only policy and the caller's close request. - The Codex close-request constant and its CLI build entries go: nothing uses them now.
ELI5
The problem. A structured chat runs the agent (Claude, Codex, and soon Grok) as a process Orca starts and later stops. Stopping one safely takes several steps: close its input, wait, force it if it doesn't exit, check whether any processes it started are still running, and retry if Orca can't confirm the stop. Claude and Codex each had their own copy of these steps. The Grok chat over the Agent Client Protocol (ACP, #25225) would have added a third, and every copy is a place to get a step wrong. In the third copy, for example, the ACP adapter never read the field that says "the agent's leftover processes could not be confirmed gone", so that warning was lost.
What changes for you. Nothing you can see. Claude and Codex chats start, stop and recover exactly as before, with the same timings and messages. This is groundwork, so the Grok chat (and any later agent) starts from one stop, rather than a third copy of the steps. The Grok branch still has to read the descendant answer described below (#25225).
How. One shared "managed provider process" now starts the agent, sees it exit, stops it, and retries a stop it couldn't confirm. Every stop reports two separate answers about what Orca actually observed, using Orca's usual three words (
live,unverifiable,exited), or "no observation" when the stop looked at nothing.What Changed
{ root, tree }.treeisnullwhen that stop made no check of the descendants.treeis what the force-kill observed:exitedonly when the processes it captured were checked and found gone;live/unverifiableas that check found them;nullwhen it signalled without observing anything. That covers the Windows tree kill (whose result can't be read), a macOS/Linux process table that couldn't be read, and a process-group kill.teardownAccepted) is gone. The answer is a required part of the result, though a caller still has to read it.DescendantTreeVerdictinstead of a copy.unverifiableorlive", which fires in exactly the cases the old flag did. A new Codex test pins each case.rootExitObserved, which is never true for a failed spawn.claudeRootExitObservedagrees, whatever order its callers check things in.live.For the ACP branch (#25225)
These are in-memory API changes only. No stored data or messages between devices change.
close().treeinstead ofteardownAccepted. Log it when it isunverifiableorlive, as Codex does.nullmeans the stop observed nothing about the descendants, not that they are gone.managed.stderrTail()instead of its own stderr buffer.rootExitObservedwhere "the agent's process exited" is meant, so a failed spawn doesn't read as an exit.Why
Making the descendant answer a required part of the one result, rather than an optional field beside it, makes it harder for a new agent to forget, and the answer only claims what was observed. The common pattern also returns a descendant answer from every stop. Moving stderr and the default policy into the shared process removes three copies of each, and gives each agent fewer things to get wrong. Modelling a failed spawn separately keeps callers correct by construction rather than by the order of their checks.
Relationship to other PRs
main.main. It belongs with fix(native-chat): the agent's exit ends its record, and an unconfirmed stop is joined instead of held #24862 and stays here only to keep files under the size limit.Differences from the common pattern
claude/). Codex and Grok get the simpler force-kill fallback. It reports what it observed. On Windows, and when the macOS/Linux process table can't be read, it reportstree: null("no observation"). For an unreadable table, Claude's checker reportsunverifiableinstead.unverifiablewould newly turn those Codex stops into errors, which is a behavior change for a lane that has already shipped.nullfrom a graceful stop where nobody needed to look.unverifiable, taking Codex's behavior change deliberately.Linked Issue
Foundation follow-up from the #24989 review, stacked on #24862 and #24989. Internal work; no new issue opened.
Visual Proof
N/A: no UI or interaction change.
Testing
What I verified / didn't
Verified on macOS at
52becf72cb2:managed-provider-process-root-only.test.ts, which covers:tree: null;exited,live,unverifiableornull) intotree, and a repeat stop doesn't re-run the teardown;managed-provider-process-fallback-tree.test.ts, which runs the real fallback teardown with only operating-system calls faked. It covers:tree: null;exitedappears only after captured descendants were verified gone.live,unverifiable, an empty process group (no observation) and a child that never spawned.codex-app-server-connection-tree-unproven.test.ts: Codex's diagnostic for each teardown answer.tsconfig.node,tsconfig.tc.cli): the only errors are the known missing-module errors forstream-json/stream-chain, in files this PR doesn't touch, caused by a stale local install.Not verified:
b629fcf3a07failed one check: the changed-code quality gate flagged a test cast without aSAFETY:rationale. That is fixed ineb94d9b1615, where static analysis and typecheck passed. CI on52becf72cb2was still running when this was written, and CI is the authority for the full typecheck and test shards.Review
Each item from the previous review round is either fixed above or labelled Temporary in the differences list. The shared provider-process code has no Electron dependency.
Agent skill upstream boundary
Notes
No new UI, stored data, runtime capability or remote message. Folder workspaces and SSH are unaffected: process work stays on the machine that runs the agent.
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (CI will cover; changed-file checks, scoped typechecks and explicit tests passed locally)