Repository navigation
fix(service-automation): a failing parallel keeps every branch's steps in the run log - #23970
Conversation
…nch's steps Red at base: a parallel whose branch fails returns no childSteps, so the run log drops the delivered sibling's step and the failing branch's own step, and the run summary reports acted 0. Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP Co-authored-by: Claude <noreply@anthropic.com>
A parallel whose branch fails now returns its failure with childSteps: the steps of the branches that completed and the failing branch's own steps up to and including the one that failed, each tagged with the block, regionKind 'parallel-branch' and its branch index. Each branch runs with its own runRegion partialSteps sink, and the block reports its failure once every branch has settled, so a sibling that finishes after the failure keeps its step too. The failure is still RETURNED, so a fault edge on the block routes exactly as before, with the same step code, error text and $error; the engine already folds a returned failure's childSteps behind the block's own step. A region-suspension refusal is still re-thrown unchanged, now carrying the branch steps through attachPartialSteps, as loop does. Adds a real-engine pin: the real notify node on an inline messaging service and the real http node failing with fetch failed, showing the run log keeps both branch steps and the summary counts the delivery. Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP Co-authored-by: Claude <noreply@anthropic.com>
…ing its branch steps Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP Co-authored-by: Claude <noreply@anthropic.com>
…rallel-partial-steps
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6cacbb2209b2999eb91f6b26a2f3c739e415b1c0 && git checkout 6cacbb2209b2999eb91f6b26a2f3c739e415b1c0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6d1660dc3b163c30c3a02d34de5cfc219940d958 4c26145a936ebdddca382e625844318929dc2b67 && git checkout -B drift-repro 6d1660dc3b163c30c3a02d34de5cfc219940d958 && git merge --no-ff 4c26145a936ebdddca382e625844318929dc2b67
node scripts/docs-audit/affected-docs.mjs --json 6d1660dc3b163c30c3a02d34de5cfc219940d958 |
席位核对 · ACCEPT ·
|
Fixes #23928
Clause-②: no
What an operator now sees on a failed
parallelrunThe run's step log lists every branch's steps under the block's own
failurestep. Each is taggedparentNodeId,regionKind: 'parallel-branch'and itsbranchindex: a branch that completed, with itssuccessstep, and the failing branch's steps up to and including the one that failed, with its error. The run summary counts what those steps did. On the card's fan-out (branch 0 an inboxnotify, branch 1 anhttpPOST failing withfetch failed) the run now readsselected: 1, acted: 1, failed: 2. Before, it readacted: 0beside a notification that had already been delivered, andacted: 0on a failed run reads as "nothing happened, safe to re-run".The change:
builtin/parallel-node.ts,executeengine.runRegion(...)with its ownpartialStepssink. A failing branch's tagged region steps land in its sink beforerunRegionrethrows, and a completed branch returns its steps instead. So each step is collected exactly once, in branch declaration order, the order the success path already used.Promise.allrejected with before, so the error text is unchanged.{ success: false, error }, now withchildSteps. A region-suspension refusal is still re-thrown unchanged, now carrying the branch steps throughattachPartialSteps.No
engine.tschange. The engine already folds a returned failure'schildStepsright behind the container's own step (engine.ts:11812, the armtry_catchuses), and a thrown error's partial steps the same way (engine.ts:11689).Why return for a branch failure, and throw only for the refusal
The deciding constraint is
partial-steps.ts:40-47: a step-record fix must not move accept/reject behaviour, and the returned and thrown failure paths route differently.loopused to throw, so it kept throwing and carried its steps on the error (loop-node.ts:151-159).parallelalready returned its branch failure (parallel-node.ts:106at base). The same rule points the other way here: keep returning, and putchildStepson the result. A switch to a throw would move the block's step code fromNODE_FAILUREtoEXECUTION_ERROR, change$error's shape and rewrite the run's error text, the same costspartial-steps.tsnames. The region-suspension refusal was already thrown (parallel-node.ts:104at base) so that afaultedge cannot route it. It keeps the throw and getsattachPartialSteps, which leaves the error's identity, message and refusal marking untouched. That isloop's mechanism, on the one path whereparallelalready threw.Tests
parallel-node.test.ts(8 new pins) and the newparallel-failed-branch-steps.integration.test.ts(2 pins), run on commit 836fb42 (pins only, source at base 7277c07):Tests 10 failed | 7 passed (17). Readings:expected [] to deeply equal [ 'notify_owner@par/0:success', ... ](no branch steps),expected 0 to be greater than or equal to 1(acted),expected [ 'par', 'recover' ] to deeply equal [ 'par', 'notify_owner', 'x', ... ](the fault edge routes at base but the branch steps are missing), andexpected [] to deeply equal [ 'slow' ](at base the failed run returned before the slow sibling had even delivered).Tests 17 passed (17). The full package suite (pnpm --filter @objectstack/service-automation test) givesTest Files 188 passed (188),Tests 2519 passed (2519).pnpm --filter @objectstack/service-automation typecheckexits 0:tsc --noEmit, thencheck:test-typecheck: OK ... 0 file(s) / 0 error(s).parallel-failed-branch-steps.integration.test.tsruns the realnotifynode on a real inlineMessagingServicewith a recordinginboxchannel, and the realhttpnode with the globalfetchrejectingTypeError('fetch failed'), the card's failure. It asserts the step log carriesnotify_ownersuccesson branch 0 andslack_postfailurewith its error on branch 1, and that the live result and the persisted run row both readselected: 1, acted: 1,unmeasured: 1(the failed POST) andfailed: 2. The other pins cover: the failing branch's steps up to and including its failure, a sibling that settles after the failure, the reverse control (no effect,acted: 0), afaultedge routing as before with the branch steps between the block and its handler, the refused pause (failedstays 1), andloop { parallel }folding each step exactly once.scripts/ablation-replace.mjs(WRAP mode: anchor hit x1 to x0, blob changed, restore proven blob == HEAD47a3dfb296d0andgit diff HEADempty, each leg):childStepsfrom the returned failure (blob to861c8de3a97f):Tests 9 failed | 8 passed (17). Every returned-failure pin goes red, including both integration pins; the pause pin stays green because it rides the other limb.throw err;(blob tod99181112ccd), run withregion-durable-suspension-refusal.test.tstoo:Tests 1 failed | 24 passed (25). Only the pause pin goes red (expected [] to include 'notify_owner@par/0:success'); the existing region-refusal suite stays green both ways.expected [] to deeply equal [ 'slow' ]).Gates
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat 4c26145 (this head, after mergingorigin/main161aee1, which touched onlypackages/clianddriver-turso): 63 commands. 62 exited 0, exit captured before any pipe.pnpm check:dual-build-cjs-loadsexited 3,PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. That means it measured nothing here (it needs the whole-workspace build), so it is NOT MEASURED locally and left to CI'sLint & Repo Gates.--ranreconciliation:63 derived, 62 run, 0 NOT-MEASURED, 1 UNRUN(that one), plus the package test and typecheck recorded outside the derivation.Clause-②: no: the diff changes no exported declaration.registerParallelNode's signature is untouched and everything else is insideexecute. The built declaration closure was not diffed against base.Acceptance notes
faulthandler starts) once every branch has settled, not at the first rejection. A slow sibling therefore delays the handler, bounded by its nodes' own timeouts. This is the documented implicit join. Before, a sibling could finish its effect after the run had already recorded the failure.engine.ts, which this PR does not touch (another PR holds it): the returned-failure fold saystry_catch"is still the only" producer ofchildStepson a failing result, andrunRegion's doc says callers that pass no sink are "loop,parallel". Both now pass one. Carrier: none.Promise.allchose before. Only the first failure in settle order is acted on, so routing does not move. Read from source and not measured through a public door, so not filed. Carrier: none.parallel. Whether it now renders them is not measured here.Generated by Claude Code