Repository navigation
fix(orchestration): stop retried failed worker starts from piling up live agents - #27795
Conversation
…live agents A failed worker-start keeps the terminal it created (a pane the user typed into is theirs), so a coordinator retrying a start that can never succeed left one more live agent behind on every retry, with nothing to stop it. - A --retry-of start now releases the failed attempt's still-owned terminal through the normal worker-release path (output archived, user takeover kept, recovery finishes it after a restart). - A failed start now counts toward the Task's existing three-failure circuit breaker, carried across retries; a retry of a circuit-broken Dispatch is refused with a plain error. Linear: STA-7113
…lain breaker refusal - Only a --retry-of start inherits its predecessor's failure count, so worker exits across separate starts keep the old behaviour. - The circuit-broken refusal carries failureCount and next steps that say to stop retrying, read the last attempt, and that its pane stays open. - A repeated failed report on a circuit-broken Dispatch is a duplicate. Linear: STA-7113
… still open Linear: STA-7113
…d terminal closed Linear: STA-7113
Review summaryThe problem (STA-7113). A supervised Codex worker stopped at an interactive startup prompt, so What changes for users. Retrying a failed worker now closes the stuck attempt it replaces, through the normal release path: output archived, and a pane the user typed into is kept. After three failed starts in a retry chain, Orca refuses the next retry and tells the coordinator plainly to stop and report the failure. Measured in a hidden production build over 10 retry attempts (n=3 each): 10 stuck agents still alive before, 1 after; 12 live terminals before, 3 after. Review. Two adversarial correctness rounds, one architecture review (verdict: keep the design), and the readiness checklist (no P0/P1). Fixed during review:
Deferred, each narrow and noted in the PR body:
Kept by decision: a retry also releases a worker that ran and reported its own failure. The retry replaces it the same way; output is archived, and a pane the user typed into is kept. Verified:
Not verified:
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Priority: ⬆️ High Merge Risk: ⚪ Minimal · up to
|
|
A worker blocked at startup could be retried repeatedly, leaving each replaced agent and pane running because failed starts did not reach the three-failure limit. Linked retries now release the prior owned pane through normal cleanup, stop after three failures, and leave the last pane available. A pane the user has taken over or explicitly selected for reuse stays open; cleanup errors are reported without blocking the next start. Separate starts begin with a fresh count. Review also found that closing the pane could lose the actual failure reason because its archive contained only blank or command-only streamed output. The shared capture now retains history and the current terminal screen before closing. Masking uses the host's actual wrap information so wrapped token patterns are covered while independent failure messages and draft lines remain readable. Recovery instructions and late failure reports were also corrected. Six review rounds converged on this design at Verified: 296 targeted tests across 23 files, Node typecheck, all 24 repository static checks, and exact-head CI: 31 successful checks, 14 skipped, no failures or pending jobs. Six review rounds and readiness were already complete; final hidden M4Air Playwright QA now passes on The matched comparison used the exact integrated main snapshot Main-process resident memory was also checked on M4Air in this single matched pair:
The PR ended 2.1 MiB lower in this pair. The original-host three pairs below still show the PR higher by 14.4, 144.9, and 21.7 MiB. These variable, short synthetic measurements do not establish a main-process memory improvement or identify the cause of the earlier increase. Re-ran the original ten-request measurement on the original host, three runs per build. Values below are median final values (range), and supersede the earlier PR measurements.
Every final-version run started three workers and refused the next seven requests without adding another pane. Main-process resident memory was higher after in every pair, with wide variation before; this does not establish an improvement or identify the cause of that difference. An interrupted partial run was excluded after Orca restarted; the completed first baseline run was preserved and the missing final-version run resumed. These are development-runtime built checkouts with a synthetic startup prompt, reconstructed baseline source, current toolchain and the integrated main changes, not packaged-release or real-agent measurements. Deferred limits: an old stopped retry ID can give misleading recovery instructions after a manual reopen (the latest failed attempt works); remote failed-start cleanup, configuration errors counting toward the limit, and the small crash window before cleanup is requested remain. Deliberately closing the third pane after two failures can still count as a failure. Older hosts without wrap information retain complete-line masking and disclose its coverage limit; separate hard-line fragments are outside that masking promise. Live Windows/Linux/WSL/SSH/folder and mixed-version failure scenarios, aggregate archive load and separate memory growth after restart remain unverified. Final-head visual proof (M4Air, hidden Playwright capture): Before retry: Main after four failed starts: PR after three failures and the fourth request refused: Takeover keeps the original pane beside the new attempt: Explicit terminal reuse keeps a single worker pane: |
Only src/cli/bundled-skill-guides.ts conflicted; it is generated, so it was regenerated rather than hand-resolved. Upstream's guidance changes land around this branch's edits without touching them: the circuit-breaker and retry-release paragraphs in references/recovery-and-cleanup.md (stablyai#27795), and the orca-cli launch-flag note in the kernel's role table (stablyai#27794, stablyai#27796). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nnNsG3p4Do9eiyfXGr2rB
…live agents (#27795) * fix(orchestration): stop retried failed worker starts from piling up live agents A failed worker-start keeps the terminal it created (a pane the user typed into is theirs), so a coordinator retrying a start that can never succeed left one more live agent behind on every retry, with nothing to stop it. - A --retry-of start now releases the failed attempt's still-owned terminal through the normal worker-release path (output archived, user takeover kept, recovery finishes it after a restart). - A failed start now counts toward the Task's existing three-failure circuit breaker, carried across retries; a retry of a circuit-broken Dispatch is refused with a plain error. Linear: STA-7113 * test(orchestration): type the failed-start retry fixtures without casts * fix(orchestration): carry failed-start count only along --retry-of; plain breaker refusal - Only a --retry-of start inherits its predecessor's failure count, so worker exits across separate starts keep the old behaviour. - The circuit-broken refusal carries failureCount and next steps that say to stop retrying, read the last attempt, and that its pane stays open. - A repeated failed report on a circuit-broken Dispatch is a duplicate. Linear: STA-7113 * fix(orchestration): breaker refusal only points at a terminal that is still open Linear: STA-7113 * fix(orchestration): log a retry's release that could not prove the old terminal closed Linear: STA-7113 * Preserve worker diagnostics before terminal release and mask wrapped tokens (cherry picked from commit b70f13f) Backport to 1.4.224: kept both sides of the sqlite vitest file list (1.4.224 ssh tests + the new archive tests); applied the recovery-and-cleanup.md text change to 1.4.224's generated ORCHESTRATION_FULL_MARKDOWN constant instead of taking main's regenerated copy.





ELI5
When a supervised worker agent got stuck at a startup prompt, its coordinator could retry the start again and again. Each retry launched a brand-new agent, and every stuck one before it stayed alive. Nothing ever stopped the loop, so live agents piled up until Orca's main process ran out of memory and macOS killed it. Now a retry cleans up the stuck attempt it replaces, and after three failed starts Orca refuses to retry and tells the coordinator to report the failure instead.
What you see, before and after
"You" is the person watching Orca. "The coordinator" is the agent that starts workers and retries them.
A worker starts normally. Nothing changes for either of you. One terminal opens with the worker, and the coordinator gets the same result as before.
A worker gets stuck at a startup prompt and the coordinator retries until one works.
Agent startup blocked: …for each failed attempt. It can still read each old attempt's output withworker-read.worker-read, because the output is saved before the terminal closes.A worker never gets past the prompt and the coordinator keeps retrying.
Task <id> failed 3 times, so Orca will not retry it again (last error: …). Report the failure to the user instead of retrying.The refusal also tells it not to start a replacement task, to read the last attempt's output withworker-read, and that the last terminal stays open for you untilworker-release.What Changed
What users saw (STA-7113): a Codex worker stopped at an interactive startup prompt, so its start failed with
Agent startup blocked: codex-interactive-prompt. The coordinator kept retrying it (44 times in 22 minutes in the report). Each retry opened another terminal with another agent stuck at the same prompt. Orca's main-process memory climbed until macOS killed the app, and every relaunch wedged again.Why it happened: two gaps met.
worker-release. But a retry (worker-start --retry-of) never called it, so every retry stranded one more live agent.worker-startnever added to the failure count, and every retry started counting again at 0. The breaker could never trip, so a retry loop had no end.After this change:
Retry releases the attempt it replaces. When
worker-start --retry-of <failed dispatch>is accepted, and that failed worker still owns its terminal, Orca releases it through the same pathworker-releaseuses. This also covers a worker that ran and reported its own failure: the retry replaces it the same way. That terminal is one the user hasn't typed into, that isn't a chat-style worker session, and that the retry isn't reusing with--terminal. Its output is archived first, soworker-readstill shows what the stuck agent printed. A pane the user typed into stays theirs. If Orca restarts mid-release, the existing startup recovery finishes it. The retry never waits on this cleanup and never fails because of it.Failed starts count toward the breaker. A failed start adds one to its dispatch's failure count, and a
--retry-ofstart inherits the count of the attempt it retries. At the third failure the dispatch is marked circuit-broken, and a fourth--retry-ofis refused with a plain error:Task <id> failed 3 times, so Orca will not retry it again (last error: …). Report the failure to the user instead of retrying.The refusal also gives the coordinator its next steps:worker-read;worker-release.An older client ignores the new
failureCountfield.The breaker's existing rules are unchanged.
failedresult does not count.--retry-of) still begins at 0, so a worker that exits and is started again behaves exactly as before.task-updatecan still reopen a Task by hand.The orchestration guide (
recovery-and-cleanup.md, plus its generated bundled copy) now says that failed starts count, and that a retry releases the failed attempt's terminal.Mechanism, by file:
retried-worker-terminal-release.ts(new): the release-on-retry step. It is called fromlocal-worker-start.tsand from the home side offederated-worker-start.ts, because a remote retry can replace a failed local attempt.dispatch-row-writer.tsandworker-dispatch-start.tsstamp the carried count on a retry's row and refuse a retry of a circuit-broken dispatch.worker-dispatch-outcome.ts(failWorkerStart) counts the failure.orchestration-dispatch-refusal-contract.tsandtask-dispatch-refusal.tshold the refusal's data and next steps.failed-start-dispatch-identity.tsalso matches a circuit-broken dispatch. Without that, the third failed start's terminal could not be released.worker-report-settlement.tsandlifecycle-transition.tskeep the [Bug]: agent_prompt_stalled for codex/kimi workers — fixed 5s confirmation window vs ~20s first hook-event latency (root-cause evidence) #16095 correction working: a worker whose start was recorded as failed because its first prompt looked stalled can still report its real outcome, even if that failure tripped the breaker. A repeated failed report on a circuit-broken dispatch is treated as a duplicate.Why
The terminal-ownership design from #19608 is kept on purpose, so a user who answers the stuck prompt by hand keeps their pane. This PR does not close terminals when a start fails. It closes the old one only when the coordinator explicitly replaces it with a retry, through the normal release path. That path already handles archiving, user takeover and restart recovery. Alternatives considered:
Linked Issue
Linear: STA-7113. This fixes the retry pile-up the ticket describes. The ticket also reports Orca growing to 17 GB after a restart with no retry loop running; this PR does not explain or fix that part.
Visual Proof
Method:
main(BEFORE,9d305f3ce4f) and of this branch (AFTER, built ate7f8df61583; the two later commits only change the refusal's next-step text and add a log line, not what is released or counted), each in an isolated profile on one macOS laptop. This is a count and memory measurement, so it ran without the timing machine.codexwas a stand-in that paints a Codex startup permission dialog and sits at it. Orca's own detector reported the ticket's failure:Agent startup blocked: agent-interactive-prompt. Terminal workers, as in the ticket.worker-start --agent codex, then retried with--retry-ofuntil 10 attempts. Every CLI call was proven to target the rig, not the real app.Resident memory on macOS swings by hundreds of MB between samples because of memory compression, so read the process and terminal counts as the result. A first series at the round-1 commit gave identical counts. Raw data and scripts: kept locally under
perf-lanes/easy/measure/failed-start-retry/(not committed).Testing
New
failed-start-circuit-breaker.test.ts, at the database level: the count carries across retries, the breaker trips on the third failed start and refuses a fourth with the message above, a worker's own failed report is not counted, and the stalled-prompt correction still works after the breaker tripped.New
failed-start-retry-release.test.ts, through the realworker-startandworker-releasehandlers:--terminalleaves it alone;Existing orchestration suites, run locally: worker start and release, federation, dispatch lifecycle and settlement (95 + 19 files) all pass.
tscnode and cli typechecks pass.Live measurement in a hidden production build (macOS): see Visual Proof.
Review: two adversarial review rounds plus an architecture review; findings and what was deferred are in the review comment.
I manually tested these changes locally
Automated tests added/updated, or explained why not below
Review
Agent skill upstream boundary
Notes
worker-releasecannot close it today and neither can this. That gap predates this PR and is left out of scope. The breaker still counts remote failed starts, because the home side records them. Nothing new crosses the wire: no new fields, methods or opcodes.--spec) for every attempt is not bounded by the breaker.task-updatecan reopen the Task.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)