fix(workflow): stop charging approval resumes to the crash recovery budget - #3940
Conversation
…udget A composite runs its children against a synthetic run record whose status is always "running". The DAG executor reads that status to tell two situations apart: a run parked on a human decision, where a composite recorded "running" is re-entered so its child resumes, and a run whose worker died mid-node, where a node recorded "running" is re-run under a bounded recovery budget. One level down, an approval resume was indistinguishable from a dead worker, so re-entering a nested composite spent recovery budget. Executing the node overwrites the bumped attempt, which hid this until a re-entered sibling queued behind the concurrency limit and never got its turn -- it keeps the bump. Two ordinary approvals of a parallel > parallel > wait then fail the run with "Node X was interrupted ...; retry budget exhausted", naming a node nobody interrupted. Thread the reason execution stopped instead of inferring it from a record that cannot carry it. The new ExecutionScope collects what a child graph must agree with the root run about -- root run id, execution run id, ownership, and now whether this is a wait resume -- and replaces the four positional arguments that were already being passed down verbatim at every level. Re-entry itself was correct and is unchanged; only the accounting moves. Closes veryfront/veryfront-issue-inbox#696
|
Warning Review limit reached
Next review available in: 50 minutes Limit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?Wait for the limit to reset, then comment An organization admin can change what happens after included review limits in Billing. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
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 |
📦 Client bundle boundary
A server module in a client graph aborts hydration in the browser. New leaks fail CI; known leaks are tracked in |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 89dd30c9b4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Duplicate of this from my side: I opened #3943 against the same issue twelve minutes after you, same diagnosis. I am closing mine. Yours is the better shape: bundling I checked yours properly before closing mine, and it holds up:
One test of mine is worth taking, and I can show why rather than assert it. Your crash companion proves a dead worker still recovers a nested node. It does not prove that recovery is still bounded down there. Mutating your branch to drop the child-graph bound: - if (attempts > maxAttempts) {
+ if (isDurableRun && attempts > maxAttempts) {leaves both of your new tests green: A regression that removed the bound entirely inside child graphs would ship green. This test catches it (it goes red under that mutation, and passes on your branch as-is): it("still bounds recovery for a node a dead worker left running inside a composite", async () => {
const executed: string[] = [];
const exec = new DAGExecutor({
stepExecutor: new MockStepExecutor(new Map(), (node) => {
executed.push(node.id);
return { success: true, output: node.id, executionTime: 1 };
}),
});
const nodes: WorkflowNode[] = [
{
id: "outer",
dependsOn: [],
config: {
type: "parallel",
nodes: [{ id: "leaf", dependsOn: [], config: { type: "step" } }],
} as any,
},
];
// A running run, so a node recorded running is an interrupted attempt.
// The leaf already spent its one recovery, so it must not run again.
const run = createTestRun({
status: "running",
nodeStates: {
outer: { nodeId: "outer", status: "running", attempt: 1, startedAt: new Date() },
leaf: { nodeId: "leaf", status: "running", attempt: 2, startedAt: new Date() },
},
});
const result = await exec.execute(nodes, run);
assertEquals(executed, []);
assertEquals(result.completed, false);
assertEquals(result.nodeStates["leaf"]!.status, "failed");
});It needs |
The rule "a waiting run has nothing to recover" is wrong: parked and interrupted are not exclusive. A worker can die with a step in flight while a sibling wait is parked, and the run then reaches "waiting" with that step still recorded running. Every node recorded running was being read as a parked composite, so a non-composite was skipped -- and with nothing left ready the graph reported completion, finishing the workflow having silently dropped a side effect. Reproduced on main before this branch: a two-node graph at maxConcurrency 1, recovered from a crash and then approved, returns completed=true with the step never executed and still marked running. Only the composite case is a resume. Everything else recorded running falls through to recovery, where the existing wait guard and retry budget already apply. When the budget is gone the run now fails loudly rather than reporting success it did not earn. Found by review on #3940, which was right that propagating the root status into child graphs widens this -- it just predates the propagation.
Re: P1 "Preserve recovery for interrupted nested siblings" — confirmed, and it predates this branchProbed it before accepting it. The finding is real, and the fix is pushed in 78a68a7. It reproduces on
Root cause is the rule, not the propagation. "A waiting run has nothing to recover" assumes parked and interrupted are exclusive. They are not: a worker can die with a step in flight while a sibling wait is parked, and the run then reaches After the fix, same probe:
Two regression tests added in
|
Closes veryfront/veryfront-issue-inbox#696.
The defect
A composite runs its children against a synthetic
WorkflowRunwhose status is always"running".DAGExecutorreads run status to tell two situations apart:runningis re-entered so its child resumes;runningis re-run under a bounded recovery budget.At the top level the real run is
waitingand this works. One level down the child graph always looks like the second case, so an ordinary approval resume re-enters the enclosing composite off the crash budget.Why it stayed invisible, and when it stops being invisible
Re-entering the node overwrites the bumped attempt with a fresh
attempt: 1, so in the common case the mis-accounting leaves no trace. It survives only when a node pushed toreadyby the recovery path is queued behindmaxConcurrencyand an earlier node in the batch parks on a wait — that node never executes, so nothing overwrites its bump.Reproduced end to end in
src/workflow/executor/dag/index.test.tswithparallel > [parallel > wait, parallel > wait]atmaxConcurrency: 1. Onmain, two ordinary approvals produce:Nobody interrupted
inner-a.Second defect, found in review: a run could report success with a step never executed
Review flagged that propagating the root status into child graphs would let a genuinely
interrupted nested step be skipped. Probing it found something worse: the same hole is on
maintoday, at the top level, and this branch did not create it.Two nodes at
maxConcurrency: 1— a wait and a step — where a worker died with the step inflight. Recover, then approve. On
origin/main:completed: true, the step never executed, still recordedrunning. No child graph isinvolved:
resumingWaitis already true at the top level, and the same lines already skip anon-composite there.
The rule was wrong, not the propagation. "A waiting run has nothing to recover" assumes parked
and interrupted are exclusive, and they are not — a worker can die with a step in flight while
a sibling wait is parked, and the run then reaches
waitingwith that step stillrunning.Only the composite case is a resume; everything else recorded
runningnow falls through tothe recovery path, where the existing wait guard and retry budget already apply. When the
budget is gone the run fails loudly instead of reporting a success it did not earn.
If you are bisecting a report of a workflow that finished with a step never executed, this is
it, and it predates this branch. Fixed here rather than split out because this branch touches
the same eight lines and would otherwise widen its reach.
The fix
Thread the reason execution stopped rather than inferring it from a record that cannot carry it. A new
ExecutionScopecollects the values a child graph must agree with the root run about —rootRunId,executionRunId,ownership, and nowresumingWait— and replaces the four positional arguments that were already being threaded verbatim through every level (executeUnwrapped,executeNode,dispatchNode, and each composite strategy).resumingWaitis read once, inexecute(), off the only run record that knows.Re-entry behaviour is unchanged; only the accounting moves. Which composite types are re-entered on a wait resume (
RESUMABLE_COMPOSITE_TYPES) is now the same rule at every depth, where before nested graphs used the recovery rule.Tests
wait resume inside a nested composite— the repro above (fails onmain), plus a companion asserting a genuinely dead worker (status: "running") still recovers a nested node.workflow-tracing.test.tscalls the privateexecuteUnwrappeddirectly and was updated to the scope argument. Its assertions — child spanworkflow.run_idvs the step hook's run id — are unchanged.Verification
src/workflow/blob/veryfront-cloud-storage.test.tsfails in this sandbox, but it fails identically on a cleanorigin/mainworktree — it is an ambientglobalThis.fetchstub reaching the real network, which is the hazard tracked in veryfront/veryfront-issue-inbox#692. Unrelated to this change.