@@ -7,6 +7,7 @@ import type { AutomationContext } from '@objectstack/spec/contracts';
77import type { AutomationEngine , StepLogEntry } from '../engine.js' ;
88import { parseNodeConfig } from './parse-config.js' ;
99import { isRegionSuspensionRefusal } from '../region-suspension-refusal.js' ;
10+ import { attachPartialSteps } from '../partial-steps.js' ;
1011
1112/**
1213 * `parallel` built-in node — a **structured parallel block** with an
@@ -27,8 +28,10 @@ import { isRegionSuspensionRefusal } from '../region-suspension-refusal.js';
2728 *
2829 * If any branch fails (a node returns `success: false` or throws), the block
2930 * fails — surfaced as a node failure so the flow's fault edge / error handling
30- * applies. Durable pause inside a branch is unsupported (a clear error), mirror-
31- * ing the loop container.
31+ * applies. The failure is reported once every branch has settled, and it
32+ * carries every branch's steps: the siblings that completed, and the failing
33+ * branch's own steps up to and including the one that failed. Durable pause
34+ * inside a branch is unsupported (a clear error), mirroring the loop container.
3235 */
3336export function registerParallelNode ( engine : AutomationEngine , ctx : PluginContext ) : void {
3437 engine . registerNodeExecutor ( {
@@ -69,44 +72,79 @@ export function registerParallelNode(engine: AutomationEngine, ctx: PluginContex
6972 if ( ! parsed . ok ) return parsed . refusal ;
7073 const branches = parsed . config . branches ;
7174
72- let branchSteps : StepLogEntry [ ] [ ] ;
73- try {
74- // Implicit join: continue once when ALL branches have completed.
75- // #1479: each branch returns its body steps, tagged with the branch index.
76- //
77- // #15230: the branch index goes on `branch`, NOT on `iteration`. It
78- // used to share `iteration` with the loop's row index — one field, two
79- // meanings, told apart only by reading `regionKind` first — and because
80- // the tagger let the innermost region win outright, a branch step
81- // inside a loop body recorded the branch and lost the row entirely.
82- // `iteration` is now single-valued (the enclosing loop's, carried
83- // through nesting by `runRegion`'s tagger), so nothing here writes it:
84- // a branch step inside a loop body gets the row from the loop's own
85- // tagging pass, and a bare `parallel` leaves it absent because there is
86- // no enclosing loop to report. Maintainer ruling 2026-09-03.
87- branchSteps = await Promise . all (
88- branches . map ( ( branchRegion , i ) =>
89- engine . runRegion ( branchRegion , variables , context ?? ( { } as AutomationContext ) , {
90- parentNodeId : node . id ,
91- branch : i ,
92- regionKind : 'parallel-branch' ,
93- } ) ,
94- ) ,
95- ) ;
96- } catch ( err ) {
75+ // A failing block keeps the record of what its branches did. A branch
76+ // that completed may already have delivered a notification or called
77+ // out, and a run log without its step makes the #4354 summary report
78+ // `acted: 0` for that run — which reads as "nothing happened, safe to
79+ // re-run", the double-execution misread a dying `loop` was fixed for
80+ // (`partial-steps.ts`).
81+ //
82+ // One #7546 `partialSteps` sink PER BRANCH: `runRegion` pushes a failing
83+ // branch's tagged steps (its failing step included) into that branch's
84+ // sink before it rethrows, and a completed branch returns them instead,
85+ // so each step is collected exactly once and the log keeps declaration
86+ // order, as on the success path.
87+ //
88+ // The block waits for EVERY branch to settle before it reports a
89+ // failure: a sibling still running when another branch fails would
90+ // otherwise finish its effect after the block had already failed, with
91+ // no step on the record. `rejections` keeps the settle order, so the
92+ // failure the block reports is the one `Promise.all` rejected with.
93+ const sinks : StepLogEntry [ ] [ ] = branches . map ( ( ) => [ ] ) ;
94+ const rejections : { err : unknown } [ ] = [ ] ;
95+ // Implicit join: continue once when ALL branches have completed.
96+ // #1479: each branch returns its body steps, tagged with the branch index.
97+ //
98+ // #15230: the branch index goes on `branch`, NOT on `iteration`. It
99+ // used to share `iteration` with the loop's row index — one field, two
100+ // meanings, told apart only by reading `regionKind` first — and because
101+ // the tagger let the innermost region win outright, a branch step
102+ // inside a loop body recorded the branch and lost the row entirely.
103+ // `iteration` is now single-valued (the enclosing loop's, carried
104+ // through nesting by `runRegion`'s tagger), so nothing here writes it:
105+ // a branch step inside a loop body gets the row from the loop's own
106+ // tagging pass, and a bare `parallel` leaves it absent because there is
107+ // no enclosing loop to report. Maintainer ruling 2026-09-03.
108+ const branchSteps = await Promise . all (
109+ branches . map ( async ( branchRegion , i ) => {
110+ try {
111+ return await engine . runRegion (
112+ branchRegion ,
113+ variables ,
114+ context ?? ( { } as AutomationContext ) ,
115+ { parentNodeId : node . id , branch : i , regionKind : 'parallel-branch' } ,
116+ sinks [ i ] ,
117+ ) ;
118+ } catch ( err ) {
119+ rejections . push ( { err } ) ;
120+ return sinks [ i ] ;
121+ }
122+ } ) ,
123+ ) ;
124+ const childSteps = branchSteps . flat ( ) ;
125+
126+ const first = rejections [ 0 ] ;
127+ if ( first ) {
128+ const { err } = first ;
97129 // [#18881] A durable pause raised inside a BRANCH is refused at the
98130 // region boundary and must reach the run, ⛔ not be folded into this
99131 // node's returned failure. A returned failure is routable by a `fault`
100132 // edge on the `parallel` node, and routing this one would re-open the
101133 // silence the card closes: the run would continue past a region that
102134 // parked nothing and report success. Re-thrown, so the engine's own
103- // catch path records it once and fails the run.
104- if ( isRegionSuspensionRefusal ( err ) ) throw err ;
135+ // catch path records it once and fails the run — carrying the branch
136+ // steps on the error the way a dying `loop` does, which leaves the
137+ // error itself (identity, message, refusal marking) untouched.
138+ if ( isRegionSuspensionRefusal ( err ) ) throw attachPartialSteps ( err , childSteps ) ;
105139 const message = err instanceof Error ? err . message : String ( err ) ;
106- return { success : false , error : `parallel '${ node . id } ': branch failed — ${ message } ` } ;
140+ // Returned, not thrown, exactly as before: the block's failure stays
141+ // routable by a `fault` edge on the `parallel`, with the same step
142+ // code, error text and `$error`. The engine folds a returned failure's
143+ // `childSteps` into the run log right behind the block's own step.
144+ return { success : false , error : `parallel '${ node . id } ': branch failed — ${ message } ` , childSteps } ;
107145 }
108146
109- return { success : true , output : { branches : branches . length } , childSteps : branchSteps . flat ( ) } ;
147+ return { success : true , output : { branches : branches . length } , childSteps } ;
110148 } ,
111149 } ) ;
112150
0 commit comments