Repository navigation
fix: stop Codex and OpenCode helper servers when Orca quits or crashes (STA-9254, 2 of 2) - #25753
Conversation
…d relay a provider's last output A one-shot CLI (claude -p, codex exec -) reads its request until stdin ends, so the supervisor's session rule (stdin end means the owner is closing) would stop it about 1 s into its answer. The one-shot lifetime passes stdin end through and leaves only the owner-death watch and explicit signals to stop it. The supervisor also exited as soon as its provider did, dropping output still in the pipes when the owner reads slowly. It now waits, bounded, for the provider's output to be relayed before exiting.
…or on POSIX The Claude model-list probe, model discovery for every agent, and commit message, pull request and branch name generation all spawned the agent CLI as a plain child of Orca. If Orca quit, crashed or was killed while one was running, nothing stopped it, and a CLI that hung kept running after Orca was gone. They now run under the provider supervisor that native chat already uses, in its one-shot lifetime, so Orca's exit stops the agent's whole process group however Orca exits. A timeout or cancel asks the supervisor to stop (SIGTERM) and only tears the tree down once it has had its full stop time; killing the supervisor first would orphan the agent's group. A missing binary now reaches Orca as the supervisor's exit 127, which is mapped back to the existing not-found message. Windows and WSL keep spawning the agent directly.
…p every spawn error back, and share one stop ladder - The supervisor read the provider's command and arguments from one base64 JSON env string. Linux caps a single env string at 128 KiB, so an argv prompt of about 90-120 KiB, which passes the 120 KiB per-argument guard, failed execve with E2BIG. The provider argv now follows the supervisor script's '--' as real arguments; the env keeps only small fields. - A supervisor that cannot start its provider reports Node's spawn error line and exits 127. That line now becomes the same error a direct spawn emits, so ENOENT still reads as 'not found on PATH' and EACCES or any other spawn error reads as 'failed to start'. - stopSupervisedProvider is the one ask, wait and force ladder: it gives a supervisor its full stop time before forcing. Agent one-shots, the Codex app-server close and the Claude child exit proof now share it, with the same requests, bounds and forced steps as before.
…derr line Node throws most spawn failures (ENOEXEC, ENOTDIR, ELOOP, EPERM, ...) instead of emitting them, and the supervisor had no catch, so it died with exit 1 and a stack trace that the user saw as the agent's failure. A thrown or emitted spawn failure now exits 127 with one marked line carrying whether it was thrown, its code and its message. Orca reads only the last stderr line, so a runtime warning printed earlier cannot hide it, and maps it to the message a direct spawn gave: thrown is 'could not be started', ENOENT is 'not found on PATH', and any other emitted error is 'failed to start'.
…r and hand them to the provider The supervisor runs Electron in Node mode, which honours NODE_OPTIONS and NODE_REPL_EXTERNAL_MODULE. A user value such as a --require of a missing file stopped the supervisor from starting, breaking even native CLIs like Codex that never load it. The launch now takes both out of the supervisor's environment, carries them in its spec, and restores them for the provider only, so a Node-based CLI still gets them.
… own breadcrumb site The forced tree teardown recorded every self-initiated kill as the Codex app-server's, so a forced commit-message or model-discovery stop read as a Codex teardown in crash breadcrumbs. The teardown now takes the caller's site; source-control stops pass the site their Windows tree kill already uses.
…tput limit; name the direct-child suites The commit-message suites that drive fake children spawn them directly, the unsupervised shape Windows and WSL use, so they now say so. The supervised POSIX stop gets its own compositions: a timed-out Codex generation settles at once but holds the Codex home until its supervisor has stopped (faithful fake, fake timers), and an agent that floods past the output limit is stopped through its real supervisor with no process left behind.
…pervisor on POSIX The Codex model-list probe, the hook trust grant and the session index heal each start a short-lived `codex app-server` as a plain child of Orca, and counted on it exiting when its input closes. A wedged Codex (a cold model/list waiting on the network, say) kept running after Orca quit, crashed or was killed. These sessions now run under the provider supervisor that native chat's Codex connection already uses, in its session lifetime, so Orca's exit stops the server's whole process group. The session's end and its deadline share the one stop ladder: end its input (and SIGTERM a session past its deadline), give the supervisor its full stop time, and only then tear the tree down. A missing binary reaches Orca as the supervisor's exit 127 and is mapped back to the spawn error, so the trust-grant telemetry still reads it as a missing binary. Windows and WSL keep spawning the server directly, with the same timings as before. The supervisor now takes only the command line it starts, so the CLI build, which also runs these sessions, no longer pulls in the native chat connection's types.
Agent one-shots stop their supervisor with SIGTERM through the shared stop ladder, watching the child's own exit. That adapter moves into one helper so the Codex backfill recovery can use it with its own stop request, instead of a copy.
…l alive While Codex rebuilds its session index, Orca keeps a read-only `codex app-server` running for up to an hour so Codex can finish. It was a plain child of Orca with its input held open, so a quit, crash or kill left it running. On POSIX it now runs under the provider supervisor in its session lifetime, so Orca's exit stops its whole process group. Stopping it (done, aborted, or given up) ends its input, as a Codex connection close does; the supervisor then SIGTERMs the group and SIGKILLs it after the grace, and the tree is torn down only if the supervisor outlives its full stop time. Windows and WSL keep the direct spawn and the drain-first probe termination. The spawn and stop of that process move into their own module.
Before an OpenCode launch, Orca starts `opencode serve` to read the configured agent and models, then stops it. The server was detached into its own process group and never exits when its input ends, so if Orca quit, crashed or was killed during that preflight (up to 10 s), nothing ever stopped it. On POSIX the server now runs under the provider supervisor in its one-shot lifetime: the preflight's closed input does not stop it, and Orca's exit does. The preflight's teardown asks the supervisor to stop (SIGTERM) and forces the tree only after the supervisor's full stop time; signalling or SIGKILLing the supervisor's own group would orphan the server's. A descendant that ignores SIGTERM is now killed with the group rather than left running once the pipes close. Windows keeps the direct spawn and its existing teardown.
A session past its deadline is SIGTERMed through its supervisor rather than waiting out the stdin-end grace, and its tree is torn down only after the supervisor's full stop time; Windows keeps its deadline kill and 1.5 s close wait. A supervised backfill app-server is stopped by ending its input, with the same full stop time before any teardown.
…h and cover supervised discovery The commit-message suites that drive fake children mocked the supervisor away on POSIX, so they asserted a direct root SIGKILL that production no longer takes there. They now pin the platform to Windows (with an empty PATH, so host installs cannot answer a bare agent name) and assert the Windows kill, taskkill included. The three tests that check the host's own discovery spawn shape run on the host and read the agent argv past the supervisor's '--'. Model discovery gets its supervised composition: a timed-out Codex discovery settles at once but holds the Codex home until its supervisor has stopped.
…e spawn error it was Native chat's exit errors carry the provider's stderr tail into Details. Under the supervisor a missing CLI left the supervisor's internal spawn-failure report there instead of Node's own 'spawn <cmd> ENOENT'. The report, its parser and a display formatter now live in one module; the Codex app-server and Claude stream-json exit errors pass the tail through the formatter, which turns a report back into the spawn error and leaves any other stderr unchanged.
…crashing on its missing pipes When the provider spawn fails outright (EMFILE, ENFILE), Node emits 'error' later and leaves the child with no pid and no stdio. Piping stdin into the missing pipe threw first, so the supervisor died with exit 1 and a stack trace and never wrote its spawn-failure report. The pipes are now wired only for a provider that started.
Agent one-shots stop their supervisor with SIGTERM through the shared stop ladder, watching the child's own exit. That adapter moves into one helper beside the ladder, so other supervised children can use it with their own stop request instead of a copy. The caller's breadcrumb site still reaches the forced teardown. Same request, wait and force as before.
Takes PR1's stopSupervisedChildProcess and source-control stop as they are. Short-lived Codex sessions now read a supervised spawn failure through PR1's marker classifier, show their stderr through its display formatter so the marker never reaches an error message, and file a forced teardown under the 'codex-app-server-session' breadcrumb site. The marker module joins the CLI typecheck list, since the CLI also runs these sessions.
…er their own breadcrumb sites A forced teardown of the Codex backfill app-server is filed under 'codex-state-db-backfill-recovery', and one of the OpenCode launch model preflight under 'opencode-launch-model-preflight', instead of the generic 'codex-app-server-teardown'.
A loaded host let the test read the pid file between its creation and its write (Unexpected end of JSON input); the stand-in now renames it into place.
… its owner dies The owner-death watch went straight to the group SIGTERM and cancelled any stdin-end grace, so when Orca quit or crashed a session provider such as the Codex app-server never saw the EOF that lets it finish writing its state (auth.json, the state database). A session whose owner is gone now closes as an owner's stdin end does: the provider's stdin is ended, it gets the stdin-end grace, and only then the SIGTERM and SIGKILL ladder. A one-shot already had its EOF at the end of its request, so its owner's death still stops it at once.
…d stop, and trust only a proven stop The preflight's own stop wrapper waited on the supervisor's pipes and counted a forced teardown as proof, though the teardown reports success even when it found no descendants to check, and a supervisor that failed to reap its group exits 1 with its pipes closed. It now uses stopSupervisedChildProcess with its breadcrumb site, and counts the server stopped only when the supervisor ended on its stop signal or relayed the server's own exit. A forced stop, or an exit of 1, returns no context, as an unverified stop did before.
…rant deadline comments
… SIGKILL the mocked teardown never could
…r exits on a stop A requested stop waited out the whole SIGTERM grace for the provider's group even after the provider itself had exited, so a SIGTERM-ignoring helper it left behind held every stop for up to 3 s. Under a stop, the rest of the group is now SIGKILLed as soon as the provider has exited, the same rule its own exit already follows.
…tput limit Model discovery's supervised stop was covered only under timeout. A Codex discovery that floods past the output limit now runs through a real supervisor: it settles with the too-much-data error, its agent is stopped through the supervisor, and the next discovery on the same Codex home starts only after that agent is gone. The direct-child suites' headers now list exactly the supervised cases that are covered.
…er closes it Owner death gave every session provider the stdin-end grace, so after an Orca crash a Claude session, whose close is a stdin end plus SIGTERM, could keep working on its turn for a second with nobody watching. The spawn spec now names the provider's close request: 'stdin-end' (the Codex app-server drains and exits on EOF, then gets its grace) or 'stdin-end-and-sigterm' (Claude; the default). A gone owner gets that same request. One constant per provider feeds both its spawn spec and its owner-side close, through one requestProviderClose, so the two cannot drift. One-shots still stop at once.
Keeps PR2's ProviderLaunch type beside PR1's close request in the supervisor.
…e way a Codex connection closes Codex finishes its writes and exits on its stdin end, so the Codex connection's supervisor closes it by ending stdin, and an owner that is gone now gets that same close. The short-lived sessions and the backfill app-server now name the same close request, through one shared constant, in their spawn spec and in their own close: Orca quitting or crashing gives them the stdin end and its grace before SIGTERM, instead of an immediate SIGTERM. A session past its deadline still adds a SIGTERM, since it is wedged.
…nd the preflight stop no longer waits out the grace The stand-in now records when its stdin ended and when SIGTERM arrived. A SIGKILLed owner leaves a Codex session or backfill app-server its stdin-end grace before SIGTERM, and the OpenCode preflight ends within 1 s of its server's SIGTERM even with a descendant that ignores SIGTERM.
…d start on a loaded host At 500 ms a loaded full run hit the deadline before the supervisor had started the stub, which never wrote the pid the test reads.
…ts provider exits Killing the rest of the group the moment the provider exited under a stop also reached native chat's closes, so an MCP server, a tool's child or a dev server still in Claude's or Codex's group was SIGKILLed mid-cleanup instead of getting the rest of the SIGTERM grace. The early group kill now applies only to one-shots, where the saved wait was the point; a session's stop is back to waiting out the grace for its group.
…itly The spawn-spec assertion matched the default close request, so dropping Claude's explicit request still passed. The test now checks that the spec is built with the exit-proof ladder's own constant.
Other Codex app-server spawns will name the same close request as the connection does. Holding it in its own small module lets them import it without the connection itself.
Main's #24989 moved the provider supervisor and its teardown into src/main/provider-process/. PR1's changes move with them: the one-shot lifetime, the provider argv on the supervisor's argv, the spawn-failure report and its formatter, the no-pid guard, close-request mirroring, the one-shot-only leader-exit reap and the shared stops. Main's mechanisms replace PR1's where they overlap: the teardown takes the caller's required site label (PR1's optional site is dropped), and the provider's env is resolved once by resolveProviderChildEnv before the supervisor keeps the Node options for the provider alone.
…in the CLI project The command-line build runs the short-lived Codex app-server session, which will name the same close request as the Codex connection. Listing the close request, the provider supervisor it takes its type from, and the spawn-failure report the supervisor uses lets the CLI project typecheck that import without pulling the connection in.
e65bbc9) Ports PR2 to the provider-process layout: the supervisor, teardown, stop helper and spawn-failure report now come from src/main/provider-process/, every forced teardown passes its required site, the backfill's stop passes { site, request } to stopSupervisedChildProcess, and PR2's own ProviderLaunch type gives way to main's launch types. tsconfig.cli.json keeps PR2's teardown chain, de-duplicated.
At a load average near 50 both owner-death tests timed out waiting for the bundled owner's stand-in at 10 s; they pass alone.
…rt, and cover a server that ignores SIGTERM The session and trust-grant deadline tests ran a 2-4 s deadline from spawn, so a loaded host could stop the stand-in before it wrote its pid. The session test's deadline now outlasts its pid-read budget, and the trust-grant deadlines are 8 s. The trust-grant comment said a wedged server may ignore everything but SIGKILL, but that stub dies on its stdin end; a new POSIX case pins that a server ignoring both its stdin end and SIGTERM is SIGKILLed after the SIGTERM grace.
…e reports one On Linux, glibc's execvp hands an executable that is not a program to /bin/sh, so both a direct and a supervised spawn run it and it exits 127; only macOS throws ENOEXEC. The not-a-program case now runs on macOS only; the path-through-a-file case (ENOTDIR) still covers a thrown start failure everywhere.
…odule The WSL invocation boundary lists files that spawn wsl.exe directly. The backfill recovery's spawn moved into codex-state-db-backfill-recovery-process.ts, so the entry moves with it; the count is unchanged.
… stops reach the process-table reader The backfill recovery and Codex sessions now stop through the shared supervised teardown, whose process-table reader binds execFile when it loads. Five rate-limit fetcher tests mocked node:child_process with only spawn and failed at load: they now mock the backfill recovery, as their sibling fetcher tests already do. The account add-login tests' child_process mocks gain an execFile stub.
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.
29e6696) Main's shared provider lifecycle replaces PR1's stop and close-request helpers, so PR2's callers move onto it: - Codex sessions spawn through spawnManagedProviderProcess with the root-only close policy, as the Codex connection does, and close through managed.close(): stdin end, the supervisor's full stop time, then the tree. A session past its deadline still gets stdin end and SIGTERM first; Windows keeps its deadline kill, and its forced close is now the shared Windows teardown. - The backfill names one close request ('stdin-end') for its spawn spec and its stopSupervisedChildProcess stop. - The OpenCode preflight reads the shared close result: a stop counts only when the supervisor reaped the group itself or a forced teardown proved the tree gone.
…gone A forced teardown walks and group-kills the supervisor's tree, but the server leads its own detached group, so a teardown verdict of 'exited' does not cover members left in the server's group. Only a supervisor that reaped the group itself, by its own stop or relaying the server's exit, now counts as a proven stop. The Windows session-stop test now says why it sees no direct kill.
Status: reviewed, live-QA'd, CI greenThe problem. Orca briefly runs some agent servers in the background: a Codex app-server for the model list and other short sessions, a Codex server for recovering old chats, and an What changes for you. On macOS and Linux these servers and their children now stop when Orca quits, crashes or is force-killed. Codex gets the same close as Orca's native-chat Codex connection: its input ends, SIGTERM follows 1 s later, and SIGKILL 3 s after that. Nothing on screen changes. Windows and WSL behave as before. This is step 2 of 2. Step 1 (#25715) is merged, and this PR now targets main. Review rounds
Each fix was ablated: undoing it makes its test fail (23 ablations). Live QA (macOS, isolated rig, stand-in servers that ignore end of input and start a SIGTERM-ignoring helper)The PR side was re-run on
The real Since review: retarget and merges with main
CIHead
Not verified
|
…de-probes codex-hook-trust-grant.ts takes main's side: #25743/#25744 removed the background trust-grant timeout whose comment PR 2 had updated, so that comment change is dropped. tsconfig.cli.json keeps main's edits plus PR 2's teardown-chain entries, without the two real-home grant files main deleted.
…de-probes Brings in #25977, main's fix for its own typecheck error, so CI's test shards run.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (21)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review. 📝 WalkthroughWalkthroughCodex app-server sessions and state-DB backfill recovery now use managed or supervised process lifecycle helpers. OpenCode launch-context probes create one-shot provider spawn specifications and use supervised cleanup when applicable. Tests cover platform-specific spawning, timeout behavior, process-tree shutdown, and process-owner termination. The CLI TypeScript project includes additional process-management modules, and several rate-limit and account tests update their child-process mocks. Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to No actionable issue remains from this review; the PR is mergeable after normal checks. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 26.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 30 functions across 20 files. (1 skipped: 1 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 |
…ss now imports Main's #25753 added managed-provider-process.ts to the CLI project, which imports this PR's provider-executable-missing.ts.
…d, without disabling Send (#25666) * fix(native-chat): disable Send for unavailable agents * test: update sign-in copy expectations * fix: show automatic chat availability only on Send * fix: keep signed-out evidence when a live session lists models * refactor: split Send availability presentation and tests * refactor(native-chat): keep the sign-in verdict on the catalog's failure record The probe's "signed out" / "CLI missing" verdict now lives on the account's existing model-catalog failure record instead of two extra maps, with the failure TTL as its only lifetime: - Only the probe sets or clears the verdict; a chat's own model listing (which works while signed out) never clears or renews it. An aged-out verdict makes the next read re-probe, however fresh the catalog. - A healthy fresh catalog is no longer re-probed every 30 s; it keeps the normal 10 min fresh window. A Claude start refused as signed out ages the catalog so the next read re-probes. - A waiting picker again answers from whichever catalog lands first and never waits on a probe. A read that still owes the probe's verdict says so, and the composer waits for it once with the new optional waitForAvailability param. - The open-time start-failure line and its transcript row are hidden only while Send is disabled for the same reason; otherwise they show as before. - A host verdict lifetime longer than the client's hold is clamped, not dropped. The catalog read watches only the account settings and the paired host's contact instead of every store write. * fix(native-chat): one send-gate rule, and a missing CLI as its own failure - The sign-in/CLI verdict is gated once where the session hands it to the composer: while a turn runs it neither shows nor refuses, so a follow-up queues as before; goal mode is held by the same gate. The start-failure line and row hide only while that same value is up. - A start whose CLI was never found (a direct spawn's ENOENT, or the one a provider supervisor reports) is now a `cliMissing` failure with the disabled Send's sentence, instead of a generic "couldn't start" beside it. An older client shows the host's sentence for the new kind. - Signed-out words for a slash command keep "Run /x again". - The Claude catalog reads its models only from the reply to its own list_models request, never from the initialize reply. - The Codex probe lists models while its account check runs; a signed-out verdict still wins. - The missing-executable check moves to provider-process, out of native chat. * test(native-chat): signed-out command failures name the command to run again * fix(native-chat): probe the Codex home a launch would read - Before the catalog probe's read-only app-server reads Orca's shared Codex home, it runs the same sync a launch and the usage poll run, so a `codex login` made in ~/.codex is seen at once instead of leaving "Run codex login" up until something else re-syncs (forever on a headless host). Per-account homes are left alone. - A "no account" answer counts as signed out only when the home's auth.json is absent or holds no credential; one caught mid-write by a token refresh, or holding a credential, is unknown. * test(codex-accounts): type the node:os mock with a type import * fix(native-chat): keep the sign-in verdict until a newer answer replaces it - No gap while signed out: the host serves an aged verdict while the probe that re-derives it runs, and the composer never clears its last answer before a read; only an answer without a verdict, an error, lost contact or an account change does. Send stays disabled with its reason through every re-check. - After sign-in the stale "isn't signed in" start-failure line stays hidden: a probe that found the account fine now says so (`accountVerified`), and the line shows only when the host has no answer at all. - A Claude start refused as signed out, or any start whose CLI was never found, is recorded as that home's verdict, and the chat reads it once when that start failure arrives. - The re-probe mark is read only where the catalog read starts its probe, so a Codex chat's own picker no longer re-lists for it on every read. - Choosing an account or signing in again in Accounts settings re-derives the verdict at the next read; the account key now compares settings by value. - A re-read that brings an identical catalog keeps the option state, so focus re-reads re-render nothing. * fix(native-chat): Codex's signed-out words name only the step that unblocks this chat A chat is pinned to the Codex home it started in, so choosing another account in Codex Accounts settings never unblocks it. The system-account sentence now says only to run `codex login`, in every locale. * refactor(native-chat): give an account's sign-in status its own owner The sign-in verdict lived inside the model catalog's failure records, so the store needed guards for who wrote a failure, which listing may clear it, and whether a verdict was served while re-probing. Each guard fixed one way a chat's own listing or an older probe overwrote the account's answer. The answer now lives in AgentAccountStatuses: one in-memory, capped status per account home (ready, not signed in, or CLI missing). Only the session-less probe, a refused start and an account change write it. A blocked answer older than 30 s, or one an account change marked, makes the next read start the probe whatever else is listing, and it is served until that probe answers. Older evidence never replaces newer. The catalog's failure code is main's again, and agent-model-catalog-failures.ts is gone. The wire carries one optional `availability` field in place of `unavailable` and `accountVerified`. The renderer holds its answer per host, agent and chat, re-reads only on that agent's account inputs, and re-reads on a timer only while blocked. After an account change, Send stays disabled with the previous words until the re-probe answers, then changes once. Account switches and re-sign-ins mark the status before the settings change a chat's read follows. * fix(native-chat): re-derive the sign-in answer after an account change mid-probe, and back off while blocked Round-4 review findings on the account status owner: - An account switch or re-sign-in made while a probe was running left the old answer up for up to 30 s: the next read joined that probe, whose answer was then dropped as older than the change. A read now chains exactly one fresh probe after a probe that began before the change. - Removing the selected Claude account swapped another sign-in into the shared config dir without marking the answer; it now marks it first. - Evidence was ordered by wall-clock time, so a clock that stepped back froze a blocked answer. It is now ordered by a sequence and aged on a monotonic clock; a negative age reads as due. - A chat left open while signed out started a CLI probe every 30 s. Probes that keep finding the same block now space out (30 s, 60 s, 2 min, 4 min, then every 5 min), carried to the client as recheckInMs. An account change or a different refusal starts the hold over. - The renderer dropped no settled wait from its leave set; it now does. - A failed Claude probe logged its whole stdout, which now includes the initialize reply naming the account. Control replies are dropped from the log and the failure message. - The account services take the status re-check as an injected onSignInChanged hook instead of importing the native-chat store. - Imports moved below file headers; comments no longer say "ready" means re-checked after the failure. * test(claude-accounts): say why the runtime-auth fakes are cast where the hook was added * fix(native-chat): type the held-answer handle as the account status * fix(native-chat): re-check a block when someone returns, and catch an account change during a home's first probe Round-5 review findings: - After a chat sat blocked for a few minutes, the backoff made every read wait out a hold of up to 5 minutes, so someone who ran `/login` or `codex login` and came back still saw Send disabled for minutes. The backoff now applies only to the client's own timer read, which it marks with an optional `scheduledRecheck` param. A read from someone looking (window focus, the pane becoming visible, a new chat, a start failure) re-probes once the block is older than 30 s. The param is sent only on that timer, which only a blocked `availability` answer arms, so a host that predates the param never receives it. - An account change made during a home's first probe marked nothing, because only existing statuses were marked, so the old account's answer was written as current. Each agent's last account change is now one sequence number that every status and running probe is compared against; statuses are no longer mutated in place. - Removing an account that nothing had selected no longer re-checks. * refactor(native-chat): keep the sign-in and missing-CLI words in their own module Main's retry wording pushed the failure-words table past its line budget. * refactor(native-chat): build the structured chat's view session in the view-state module Main's send-reveal work brought NativeChatStructuredSession.tsx and the session controller to their line budgets. * fix(native-chat): keep a stated start line hidden through a running turn, and re-probe an account changed mid first probe - A chat whose open-time start failed signed out had that line hidden while the host said the same. When a new signed-out answer landed during a running turn, the line came back for the whole turn: the hide read Send's gate, which a running turn suspends. The hide now reads the host's answer itself (blocked for the same reason, or ready); Send's gate still steps aside while a turn runs. Both are views of the one answer the session holds. - An account change during a home's first probe dropped that probe's answer but left nothing to re-check the new account, and the picker's reply said no probe was pending, so the chat stayed unknown until the catalog aged out. A read now chains one fresh probe whenever the running probe began before the change, and its reply says that probe is still owed so the client asks once more for its verdict. - The client's timer read no longer carries `scheduledRecheck`. The timer already fires only when the host's backed-off `recheckInMs` says, which is past the 30 s after which any read re-probes, and every answer re-arms it. One fewer strict-schema param. * build(cli): typecheck the missing-executable check the provider process now imports Main's #25753 added managed-provider-process.ts to the CLI project, which imports this PR's provider-executable-missing.ts. * fix(native-chat): disable Send with the fix when Claude or Codex is signed out or missing What you see: in a native Claude or Codex chat whose CLI is signed out, or not installed on the computer running the chat, Send is disabled and its tooltip (hover or keyboard focus) says how to fix it, in words that match the account ("Codex isn't signed in. Run `codex login`.", "This Claude account isn't signed in. Sign in again in Claude Accounts settings.", "Claude isn't installed on the computer running this chat."). A message refused after sending gets the same words. On desktop the open-time "Chat could not be started" line, its Retry and its transcript row no longer repeat these two reasons, unless a message of yours is still waiting on that start. An unknown answer never disables Send, and while a turn runs Send stays Stop and Enter queues. How: the host's existing session-less model-catalog probe also says why no chat can start. Claude's probe child sends initialize before list_models and applies the rule chat start already uses (token source none with no API key); Codex's probe asks account/read beside model/list; a spawn that finds no executable is "CLI missing". The probe's failure record in the catalog store carries that reason, and every catalog reply carries it as an optional `unavailable` field. Only a probe writes or clears it: a live chat's own listing (which a signed-out Codex still answers) never does. It lasts the store's 30 s failure lifetime; after that the next read starts or joins the probe, serving the held reason meanwhile, and only a read that already asks to wait gets the probe's answer. A change to the managed-account settings expires it. Before probing the shared Codex mirror home, the probe runs the same sync a launch does. The renderer keeps the latest answer until the next one replaces it, and only while blocked re-reads on window focus and when the chat gains focus. No status store, timers or new request params. * fix(native-chat): no picker loading while Send re-checks a sign-in, and plain keyboard focus on the disabled Send A chat whose account is signed out and has no model list no longer shows the model picker as loading each time it re-checks the sign-in after 30 s: the re-check is for Send, so a reply that already carries the verdict ends the picker's wait. The disabled Send's wrapper takes keyboard focus (so its tooltip opens) without becoming a second button; the inner button keeps its label. Trims tests that repeated rules other tests already prove. * fix(native-chat): lift the Send gate whenever Stop shows, including while a queued card is about to send The composer shows Stop while the host's queue is about to send its next card, but the sign-in gate stayed on in that window, so Enter was refused with no message. * fix(native-chat): never disable Send for a sign-in or missing-CLI verdict; show a dismissible notice instead The host's pre-send check can be wrong while the chat would work (saved Arguments it never sees, project settings it can't read, gateways that accept a signed-out Codex), and a disabled Send left no way past it. - Delete the Send gate: the composer, Enter and goal mode no longer read the verdict, and the Send tooltip reason and focusable wrapper go back to main. - Show Codex not-signed-in and Claude/Codex not-installed as one entry in the chat's existing notice card. Dismissed per verdict; shows again when the verdict changes or clears and returns; steps aside when the failed start (launch line or latest failed-start row) says the same. - Claude: drop the probe's signed-out verdict (initialize-on-probe stdin, request-id filtering, control-reply log scrubbing). The chat start's refusal, with its account kind, is the only Claude sign-in signal. Missing-CLI from the probe stays. - Restore the failed-start line and transcript row for these causes (no more hiding on desktop) and drop the chat-root focus re-check; a window-focus re-read stays while a notice is showing. * fix(native-chat): re-check the notice's reason after a chat starts, keep a signed-out Codex's model list Review round 1 of the notice rework. - A chat that proves its start (at acquire, or on its later "started" event) marks the catalog's held reason for that account due, so the next read re-probes instead of trusting it until the TTL; the chat re-reads when a turn starts or ends while a notice shows. - The Codex probe keeps a model list it got beside a signed-out reason, so a gateway user with a wrong verdict keeps the picker list. - Failure records move to their own module; an expired failure with no reason is dropped, and expiry drops reason-less failures. - Notice: any verdict change resets the dismissal; signed-out is Codex-only; the say-once rule ignores a failed start a turn has run past. - Missing-CLI copy: "{{agent}} wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents." (6 locales). * fix(native-chat): only an acquiring start re-checks the catalog reason; drop the unreachable Claude filter Review round 2 of the notice rework. - A re-attach to a live child proved nothing new, so only an attach that acquired a child which proved its start marks the reason due. - Reword: a start proves the program exists; for a Codex sign-in it only prompts an earlier re-check. - Drop the notice's Claude signed-out filter: no host sends that verdict. - Tests: a re-attach does not notify; a turn ending re-reads once more. * fix(native-chat): never newly show a catalog reason the host is still re-checking After signing in to Codex, a new chat flashed the old "isn't signed in" notice for about half a second: past its 30 s lifetime the host answers a plain read with the held reason while its probe re-checks. A chat now adopts such a reason only when it already shows it; otherwise the joined read's answer decides. * test(native-chat): follow main's harness and the clearer Claude sign-in words after the merge * test(native-chat): run the start-proof catalog test in the Node runtime project with the other SQLite host tests




ELI5
Before an OpenCode launch, Orca starts an OpenCode server for a few seconds to read which agent and models are configured, then stops it. That server never exits on its own. If Orca quit, crashed or was killed during those seconds, the server kept running forever, even with the real OpenCode CLI.
Codex has the same problem in a milder form. Orca briefly starts a Codex server to list Codex's models, to grant hook trust and to repair Codex's session index. While Codex rebuilds that index, Orca keeps a Codex server running for up to an hour. Each was a plain child of Orca, so a Codex server that was stuck kept running after Orca was gone.
Now each of these runs under the same supervisor native chat already uses. It is a small watcher process that stops the server, and anything it started, when Orca goes away.
What Changed
Before:
opencode serve, closes its input, reads/api/model,/api/agent,/api/model/defaultand/api/config, and then stops it, within a 10 s timeout. The server was started in its own process group and never exits when its input ends. A quit, crash or force-kill in that window left it running with nothing ever reaping it.codex app-serverfor the model list, hook trust grants and session-index repair). Orca counted on Codex exiting when its input closed. At the deadline it SIGKILLed only the top process and its direct children. A Codex server waiting on the network outlived Orca.codex app-serveralive for up to an hour while Codex rebuilds its index, with its input held open. A quit or crash left it running.After (macOS and Linux):
The plan, and which step this is:
Mechanism:
runCodexAppServerSession,codex/codex-app-server-session.ts). The callers are the Codex model-list probe, the hook trust grant and both session-index repairs. TheorcaCLI's hook commands use them too.spawnManagedProviderProcess, the same managed process and close policy as native chat's Codex connection, so a session closes exactly the way that connection does.managed.close(): end input, give the supervisor its full stop time, then tear the tree down. A session past its deadline is wedged, so it also gets SIGTERM straight away.codex/codex-state-db-backfill-recovery-process.ts, split out of the recovery module). Spawned in the supervisor's session lifetime with an end-of-input close request. Its stop goes through the sharedstopSupervisedChildProcess.opencode/opencode-launch-model-context.ts).opencode servein the one-shot lifetime. The preflight closes the server's input right away, and in a session that would stop it a second later.stopSupervisedChildProcess(SIGTERM to the supervisor).codex-app-server-session,codex-state-db-backfill-recoveryoropencode-launch-model-preflight.orcaCLI, soconfig/tsconfig.cli.jsonlists the shared teardown and the modules it imports. Without them the CLI typecheck fails with 16 missing-file errors.Why
The failure is the same one step 1 fixes: these servers have no owner that outlives Orca's own timers. The supervisor already gives native chat's Codex connection that guarantee in production, so these reuse it, with the same close request, rather than adding another mechanism.
The OpenCode preflight could not just rely on end of input. In OpenCode's source,
opencode serveends in a wait that never returns and does not watch its input. So the preflight needs the one-shot lifetime, where only Orca's death or an explicit stop ends it.Alternatives considered:
Differences from the common pattern:
opencode server listening on …, but the preflight looks for a line startingserver listening on. Whether OpenCode 2.0.16, the only version that runs the preflight, prints the new line is not verified here. A release with the new line would make every preflight time out: STA-9584.Cost (macOS, stand-ins):
Linked Issue
Part of STA-9254 (Linear): step 2 of 2. Step 1, #25715, is merged.
Visual Proof
Nothing on screen changes; the change is which processes are still running after Orca exits. These are the live-QA logs, rendered as images. Process ids come from the rig, and liveness is read from the kernel.
Before (main a47e0f5), Orca killed during an OpenCode preflight: 8 s later, the stand-in server and its helper are still running.
After (this PR, fba19a9), same case: the server gets SIGTERM at 0.08 s; it and its helper are gone at 0.11 s, the supervisor at 0.22 s.
Before (main), Orca killed during a Codex model-list session: the stand-in
codex app-serversees its input end but is never signalled. 8 s later, it and its helper are still running.After (this PR, fba19a9), same case: input ends, SIGTERM follows 1.0 s later (the drain), the server is gone at 1.07 s, and the SIGTERM-ignoring helper is SIGKILLed after the 3 s grace. Everything is gone by 4.16 s.
Testing
Live QA (macOS; this PR on fba19a9, the current head, vs main a47e0f5). A rig with an isolated profile and stand-in
opencode(reporting 2.0.16) andcodexservers. Each stand-in ignores end of input, never answers, and starts a helper that ignores SIGTERM. The production triggers were used: an OpenCode launch with a model preference, and the Codex model-list probe. Each quit or kill landed mid-flight. Process ids were recorded and liveness was read from the kernel every 0.1 s. The PR side was first run on 2e23019 and re-run on fba19a9 after the port onto main's managed provider lifecycle; the times agree within 0.06 s.Automated (macOS). Every real-process test uses stand-in servers: Node scripts that ignore end of input, start a helper that ignores SIGTERM, and record when their input ended and when SIGTERM came. The owner tests bundle the real module into a plain-Node "Orca" with esbuild and SIGKILL it.
Not verified:
The real Codex and OpenCode CLIs were not run. That
opencode serveignores end of input is from reading its source.The Linux runtime and Windows CI.
I manually tested these changes locally
Automated tests added/updated, or explained why not below
AI Disclosure
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)