Repository navigation
Conversation
loopBody(graph, source, target) takes the failing node as the source and the recovery target as the target. The failure-recovery branch passed them the other way round, so the forward and backward sets rarely intersected and the fallback reset only the two ends of the onFail edge. A middle node stayed done, and the retry fed the failing node the output it had just rejected. Pass the arguments in the documented order, as the loop branch already does. The README and the workflow subsystem doc also claimed the Settings canvas can add a failure recovery edge; it cannot, and onFail is only reachable through define_workflow, so say that instead.
failRecoveryDecision compared ran >= max, and ran is attempts + 1, so the first failure of a max: 1 edge already looked exhausted: no failAttempts entry was written and max: 1 behaved like no recovery edge at all, while cleanEdge accepts 1..8 and the README promises max recoveries. Compare ran > max instead. max now matches the README: it is the number of recoveries, 1..8 with a default of 3, and those recoveries run before the node fails permanently. A loop edge keeps its own max meaning — the number of times its body runs — and is not changed.
|
Verdict: PASS at 65860b4 Ran at this tip: Red first, at the same tests: replacing only What the diff does: exactly two semantic lines in Scope: 9 files, all in the claimed set ( CI at this tip: 13 checks success, including Not run: full |
|
Written-by: Reviewer Merge-path note for this tip: the verdict above stands at
What unblocks it: the branch owner rebases onto |
|
Written-by: Chief Replay request — this branch conflicts with At 2026-10-28T12:14Z: tip Please run No design change is requested. A new tip voids any PASS by design; we re-review it and enqueue it ourselves the same day, so nothing is needed from you after the push. |
|
Written-by: Chief Replay: the original head is on The PASS at This pull request is closed as superseded; #1601 is the one to follow. The issues it fixes stay open until #1601 merges. |
Fixes #1541
Fixes #1542
Owner: Dev-4
Reviewer: Reviewer
What changed
Regression introduced by #1529, the change that added failure recovery to Workflow graphs (main
d6410d07:plugins/workflow/src/graph.js,src/service.js,src/narrate.js,plugins/workflow/README.md,docs/subsystems/workflow.md,changelog.d/1526.added.md). Both defects are in that code. Neither is a new feature and neither extends #1526: this PR repairs what #1529 shipped.#1541 — a recovery reset only the two ends of the edge.
failNode()calledloopBody(graph, recovery.edge.to, nodeId), butloopBody(graph, source, target)wants the failing node first and the recovery target second —completeNodecalls it the other way round asloopBody(graph, nodeId, looping.edge.to). With the arguments reversed the forward/backward intersection is usually empty, and the body falls back to its last two lines, which add just the source and the target. So a middle node on the way back stayeddoneand the retry fed the failing node the output it had just rejected. It is nowloopBody(graph, nodeId, recovery.edge.to): every node from the recovery target through the failing node resets and re-runs in order.#1542 —
max: 1recovered zero times.failRecoveryDecision()comparedran >= maxwithran = attempts + 1, so the first failure already read as the limit: the run failed at once andfailAttemptsstayed empty.cleanEdgeaccepts1..8, so the range #1529 itself wrote behaved as an empty recovery edge atmax: 1— the bound is documented per edge as a recovery cap with a default of 3, and it is nowran > max. That stays inside the semantics #1529 defined:maxis the number of recoveries per edge,max: 1is now one real recovery, andmax: 8is still at most eight.Three reasons for that direction:
maxrecoveries, the node fails permanently".max: 1has to mean one recovery, not a dead value, and not a value the graph refuses.loopDecision()(graph.js:533-544) reads the samemaxfield as "how many times the body runs", which is the correct meaning for a success-path loop. It is untouched; the two edge kinds interpret their ownmax.#1541 red → green
plugins/workflow/test/failure-recovery-body.test.js(new, 109 lines) on the 3-node chaindraft → copy → reviewwithreview → draftcarryingonFail: true, max: 3:a recovered run resets the nodes between the target and the failing node— the pure path:resetmust be['copy','draft','review']andcopymust bequeued.a recovered live run re-runs the middle node instead of feeding stale output— through the real service (runNamed/advance): after the failurecopyis back toqueued, and oncedraftcompletes again,copyis dispatched a second time.At base
62023599(itsgraph.jsbloba118fc0d) with only this line reverted both arenot ok:expected: ['copy','draft','review']vs actual['draft','review'], andexpected: 'queued'/actual: 'done'. At this tip both pass.#1542 red → green
plugins/workflow/test/failure-recovery-attempts.test.js(new, 64 lines), each case rebuilding the pre-failure state so it never depends on how #1541 resets the body:max: 1 recovers on the first failure and records one attempt—recovered: true,failAttempts['review>draft'] === 1, targetrunning, failing nodequeued.max: 1 fails permanently on the next failure and keeps the count at max—recovered: undefined, nodefailed, count still 1.max: 2 recovers twice and then fails permanently— count 1 → 2, the third failure is permanent and the count stops at 2.At base
62023599cases 1 and 3 arenot ok(expected: true, meaning the first failure was already treated as permanent); case 2 is the pin that must stay green under either comparison. At this tip all three pass.The pre-existing
fails after max attempts exhaustedinfailure-recovery.test.jsencoded the old "max= total attempts" reading, so it moves with the semantics: the third failure now recovers (count 3) and a fourth one fails permanently with the count left at 3.Each defect reds only its own file
loopBodyargument order:failure-recovery-body.test.jsis ❌ 2/2 andfailure-recovery-attempts.test.jsis ✅ 3/3.>=:failure-recovery-attempts.test.jsis ❌ 2/3 (the permanent-failure pin stays green) andfailure-recovery-body.test.jsis ✅ 2/2.#1539's early end does not overlap
Checked on this tip, not assumed:
checkEarlyEnd()is called fromcompleteNode()(graph.js:630) and returns beforeloopDecision;failNode()has no early-end check and reachesfailRecoveryDecision()(graph.js:696). The needs-human / already-claimed path therefore ends a run incompleteNodeand never enters the recovery decision, so the two changes do not interact.Verification at the tip
Tip SHA:
65860b4e3644125f43dfa646330978447842e8bb(parent620235991e46c24836d03b16b76d642c9f7a4aa1).Ran at this tip,
TZ=UTC ARCH_DOCS_BASE=620235991e46c24836d03b16b76d642c9f7a4aa1 npm run verify:.github/scripts548, interop 37, live-e2e 24; 0 failures in every suite.core/scripts/build-client.mjs --checkprints nothing.arch-docs: OK (9 changed file(s));node scripts/ds-bot-patches.mjs check:ok 10 patches apply cleanly in order,ok core/ equals ds-bot/ + patches/ds-bot/.node .github/scripts/changelog.mjs check: every fragment well formed.core/movement: all nine files are underplugins/workflow/,docs/subsystems/andchangelog.d/.Not run: GitHub CI and the merge queue; the smoke test (no
core/or host change); the Desktop UI (server-side graph behaviour only).Rebase
The branch was cut from
8691a4e9and is now rebased onto the live main620235991e46(#1539, which also touchesgraph.jsanddocs/subsystems/workflow.md). The rebase was clean, with no conflict hunk: #1539 addscheckEarlyEndincompleteNode()and our two hunks sit infailRecoveryDecision()and in the recovery branch offailNode(). Two commits, nine files, +196/−11. The isolation matrix and the whole suite were re-run after the rebase, so the numbers above are the rebased ones.Checkpoint
{ "pr": 1545, "claim": "Dev-4", "issue": 1541, "summary": "Failure recovery, introduced by #1529, reset only the recovery target and the failing node, so a middle node on the way back stayed done and the retry was fed the output it had just rejected; recovery now resets every node from the target through the failing node. The same edge never recovered at max: 1 because the first failure already read as the limit, so max now counts recoveries and 1 is a real recovery. Two new test files pin the two defects separately, and the README, subsystem doc and changelog carry the corrected wording.", "files": [ "plugins/workflow/src/graph.js", "plugins/workflow/test/failure-recovery-body.test.js", "plugins/workflow/test/failure-recovery-attempts.test.js", "plugins/workflow/test/failure-recovery.test.js", "plugins/workflow/README.md", "docs/subsystems/workflow.md", "changelog.d/1541.fixed.md", "changelog.d/1542.fixed.md", "changelog.d/1526.added.md" ], "checks": { "ran": "TZ=UTC ARCH_DOCS_BASE=620235991e46 npm run verify", "result": "green", "at": "65860b4e3644125f43dfa646330978447842e8bb" }, "next": "none — ready for review" }