fix(orchestrator): Fail the run when a provider-turn.start effect dead-letters - #5150
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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 |
| ? yield* outbox | ||
| .fail({ effectId: effect.id, workerId, error }) | ||
| .pipe(Effect.onError((cause) => terminalizeClaim(effect, cause))) | ||
| ? yield* executor.compensateDeadLetter(effect, error).pipe( |
There was a problem hiding this comment.
🟠 High orchestration-v2/EffectWorker.ts:560
compensateDeadLetter runs before outbox.fail without first verifying that this worker still owns the effect lease. If execution exceeds leaseDurationMs, another worker can reclaim the row and successfully start the replacement turn, but this stale worker still calls failFromDeadLetter, which terminalizes a run that now belongs to the new worker's in-flight turn. The subsequent outbox.fail returns false too late to undo the corruption. Consider verifying lease ownership before calling compensateDeadLetter, or making compensation and outbox settlement atomic and lease-guarded.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/EffectWorker.ts around line 560:
`compensateDeadLetter` runs before `outbox.fail` without first verifying that this worker still owns the effect lease. If execution exceeds `leaseDurationMs`, another worker can reclaim the row and successfully start the replacement turn, but this stale worker still calls `failFromDeadLetter`, which terminalizes a run that now belongs to the new worker's in-flight turn. The subsequent `outbox.fail` returns `false` too late to undo the corruption. Consider verifying lease ownership before calling `compensateDeadLetter`, or making compensation and outbox settlement atomic and lease-guarded.
There was a problem hiding this comment.
The reclaim step in this scenario does not exist in this outbox. claimNext only claims status = 'pending' rows and additionally skips any thread that already has a running effect (claimableCandidatePredicate), and lease_expires_at is write-only across the repo (it never appears in a claim predicate), so a running row cannot be reclaimed while its worker is alive, regardless of lease expiry. FoundationPersistence.test.ts pins this ("does not reclaim a running effect after its process-local lease expires"). After a process crash, reconcileAfterProcessLoss cancels process-bound effect types, including provider-turn.start/provider-turn.restart, rather than requeueing them, and the crashed worker no longer exists.
The one operation that frees the per-thread claim gate under a live worker is cancelUnsettled, and both of its callers (the pre-start interrupt path and recovery reconcile) terminalize the owning run in the same commit, so the writeIfRunCurrent CAS in failFromDeadLetter observes a non-starting run and the compensation is a no-op. A lease re-check before compensation would also reintroduce the failure this PR removes, by skipping compensation when a row is cancelled without the run being terminalized. This invariant is now documented in the compensateDeadLetter doc comment.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
7c7d299 to
dd6895c
Compare
| while (childThreadQueue.length > 0) { | ||
| const childThreadId = childThreadQueue.shift(); | ||
| if (childThreadId === undefined) break; | ||
| const childResult = yield* Effect.result( |
There was a problem hiding this comment.
🟡 Medium orchestration-v2/ProviderTurnStartService.ts:675
A ProjectionStoreV2Error while reading a linked child thread projection is silently swallowed, so the dead-letter compensation can still successfully settle the parent run while leaving the child's open nodes, turn items, streaming messages, and subagents stranded permanently — there is no retry of this sweep. The code catches all read failures and treats them as a deleted child (// A deleted or unreadable child thread has nothing to settle.), but ProjectionStoreV2Error also covers transient or unreadable store failures, not just missing children. Only a confirmed missing/deleted child should be skipped; other read failures must propagate so the overall compensation fails and can be retried.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/ProviderTurnStartService.ts around line 675:
A `ProjectionStoreV2Error` while reading a linked child thread projection is silently swallowed, so the dead-letter compensation can still successfully settle the parent run while leaving the child's open nodes, turn items, streaming messages, and subagents stranded permanently — there is no retry of this sweep. The code catches all read failures and treats them as a deleted child (`// A deleted or unreadable child thread has nothing to settle.`), but `ProjectionStoreV2Error` also covers transient or unreadable store failures, not just missing children. Only a confirmed missing/deleted child should be skipped; other read failures must propagate so the overall compensation fails and can be retried.
Summary
provider-turn.startorprovider-turn.restartoutbox effectexhausts its retry budget, transition the owning run to
failed(attempt,root node, and a visible
Provider errorturn item included) instead ofleaving the run in
startingwith apendingattempt forever.compensateDeadLetterhook to the orchestration effect executor. Theworker invokes it on the dead-letter branch before the terminal
outbox.failsettles; the executor routes the two turn-start effect typesto a new
ProviderTurnStartServiceV2.failFromDeadLetter.Problem and Fix
EffectWorker.runOncemarked the outbox rowfailedand stopped. Nothing owned the run transition, so the run stayedstartingwith apendingattempt indefinitely, the thread kept showing work in progress, and the only trace was the outbox row and a log warning. Observed live when a providersession.forkreturned 400 on every attempt: the thread hung with no error. The gap is generic to any turn-start failure mode that survives the retry budget (adapter bug, provider binary drift, spawn failure).EffectWorkernow runs the executor'scompensateDeadLetterbefore committing the terminaloutbox.fail. Forprovider-turn.startandprovider-turn.restart(which chains into the same turn start and resets its run tostartingfirst),ProviderTurnStartServiceV2.failFromDeadLetteremits the compensating events: attemptfailed, root nodefailed, aProvider errorturn item carrying the redacted, bounded cause, and runfailed. The write is guarded byEventSink.writeIfRunCurrent(expectedStatus: "starting", sameactiveAttemptId), so a run that advanced or terminalized through any other path, or a replayed compensation, is a no-op. The event shape follows the existingprepared-run.failpath andmakeProviderFailureTurnItem.failedeffect with a still-hung run, because failed outbox rows are never claimed again.runId: nulland are never touched by startup reconcile (it only iterates per-run rows). Failing only the run would strand those entities open, and the child-thread ones permanently.failFromDeadLettersweeps the parent projection the wayProviderRuntimeRecoveryService.reconcileProjectiondoes (open subagents, non-root nodes, and turn items gofailed, superseded provider turns gocancelled, streaming messages stop streaming, anactiveprovider thread returns toidle) and then follows lifetime subagent linkage, terminal links included, recursing through nested subagents, to fail each linked child thread's open rows in the same guarded commit. A child row that names a nonterminal run in its own thread belongs to an independently live app-owned delegation and is left alone.Other effect types were reviewed case by case and deliberately left on the
existing warning-only dead-letter behavior: interrupt/steer target an
already-running turn whose normal ingestion owns settlement,
detach/respond/rollback/cleanup hold no run in a pre-provider state, and a
dead-lettered
checkpoint.captureis self-healed by startup reconcile.Validation
vp check: passvp run typecheck: pass (only pre-existing suggestions)EffectWorker.test.ts): the worker compensates before the terminalfail settles (ordering asserted), still settles cleanly when the lease was
lost mid-dead-letter, routes start/restart (and only those) to the run
failure path, and swallows a failing compensation.
FoundationPersistence.test.ts): the realProviderTurnStartServiceV2and effect worker run against seeded projectionstate in SQLite; a dead-lettered turn start yields a
failedrun, attempt,and node, a
Provider erroritem, cascaded open subagent/node/turn-itemrows, a stopped streaming message, and no non-terminal runs, and a replayed
compensation is a guarded no-op. Linked-child coverage: a provider-native
child and a nested grandchild whose rows carry
runId: nullterminalize,while an app-owned child with its own live run survives untouched.
binaryPathat a shimthat breaks only
agent stdio, so ACP initialize failed inside session openon every attempt. The outbox row ended
status=failed, attempt_count=5withthe
ProviderTurnStartErrorcause chain (the exact incident shape), and therun terminalized as
failedwith a visible error item within seconds; thethread settled. A healthy thread on the same server completed normally as
the control.
Note
Fail the run when a provider-turn.start effect dead-letters in the orchestrator
compensateDeadLetter(effect, error)toOrchestrationEffectExecutorV2Shapein EffectWorker.ts; forprovider-turn.startandprovider-turn.restarteffects, it callsProviderTurnStartServiceV2.failFromDeadLetterbefore the outbox marks the effect failed.failFromDeadLetterin ProviderTurnStartService.ts, which transitions astartingrun tofailedand cascades terminalization to the active attempt, root node, subagents, non-root nodes, provider turns, messages, and turn items.writeIfRunCurrentwithexpectedStatus: 'starting', making repeated compensation calls idempotent.📊 Macroscope summarized 7c7d299. 2 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted
🗂️ Filtered Issues
No issues evaluated.