diff --git a/.changeset/23928-parallel-failed-branch-steps.md b/.changeset/23928-parallel-failed-branch-steps.md new file mode 100644 index 00000000000..c3a3a781a51 --- /dev/null +++ b/.changeset/23928-parallel-failed-branch-steps.md @@ -0,0 +1,9 @@ +--- +'@objectstack/service-automation': patch +--- + +Clause-②: no + +A failed `parallel` run now shows what its branches did. Under the block's own `failure` step, the run's step log lists every branch's steps, each tagged with `parentNodeId`, `regionKind: 'parallel-branch'` and its `branch` index: a branch that completed, with its `success` step, 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. A fan-out whose notify branch delivered and whose http branch failed now reports `acted: 1` and `failed: 2` (the http step and the block), the way a failed `loop` already does. Before, the log held only the block's failure step and the summary read `acted: 0`. That reads as "nothing happened, safe to re-run" over a notification that had already been sent. + +The block now reports its failure once every branch has settled, not at the first branch to fail, so a branch that finishes later keeps its step and its effect is counted. Routing is unchanged. The failure is still the block's returned failure, so a `fault` edge on the `parallel` routes it with the same step code, error text and `$error`. A durable pause refused inside a branch still fails the run. diff --git a/packages/services/service-automation/src/builtin/parallel-failed-branch-steps.integration.test.ts b/packages/services/service-automation/src/builtin/parallel-failed-branch-steps.integration.test.ts new file mode 100644 index 00000000000..d856e57e0af --- /dev/null +++ b/packages/services/service-automation/src/builtin/parallel-failed-branch-steps.integration.test.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { MessagingService } from '@objectstack/service-messaging'; +import type { MessagingChannel } from '@objectstack/service-messaging'; +import { AutomationEngine } from '../engine.js'; +import { registerParallelNode } from './parallel-node.js'; +import { registerNotifyNode } from './notify-node.js'; +import { registerHttpNodes } from './http-nodes.js'; + +/** + * A failed `parallel` run on the REAL engine and the REAL built-in nodes: the + * run log and the run summary an operator reads after a fan-out whose one + * branch delivered a notification and whose other branch's HTTP call failed. + * + * The graph is the showcase's `showcase_fan_out_notify` fan-out + * (`examples/app-showcase/src/automation/flows/index.ts`): a `parallel` named + * `fan_out` whose branch 0 is an inbox `notify` (`notify_owner`) and whose + * branch 1 is an `http` POST (`slack_post`). On a runner with no Slack egress + * the POST fails with undici's `fetch failed`, which is what the global + * `fetch` double below answers — the only stand-in here. The notify node runs + * against a real inline `MessagingService` with a recording `inbox` channel, + * so "the notification was delivered" is read off the channel, not assumed. + * + * Before the fix this run's log was `[start:success, fan_out:failure]` and its + * summary `{ selected: 0, acted: 0, failed: 1 }`, while the inbox held the + * notification: an operator reading `acted: 0` re-runs the flow and the owner + * is notified twice. + */ + +function silentLogger(): any { + const l: any = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; + l.child = () => l; + return l; +} + +/** A channel that records what it was handed, so a real send is observable. */ +function recordingChannel(id: string): { channel: MessagingChannel; sent: unknown[] } { + const sent: unknown[] = []; + return { + sent, + channel: { + id, + async send(_ctx, delivery) { + sent.push(delivery); + return { ok: true }; + }, + }, + }; +} + +function boot() { + const inbox = recordingChannel('inbox'); + const messaging = new MessagingService({ logger: silentLogger() }); + messaging.registerChannel(inbox.channel); + const pluginCtx = { + logger: silentLogger(), + getService: (name: string) => (name === 'messaging' ? messaging : undefined), + } as any; + const engine = new AutomationEngine(silentLogger()); + registerParallelNode(engine, pluginCtx); + registerNotifyNode(engine, pluginCtx); + registerHttpNodes(engine, pluginCtx); + return { engine, inbox }; +} + +const FAN_OUT_FLOW = { + name: 'fan_out_notify', + label: 'Fan-out Notify (Parallel)', + type: 'autolaunched' as const, + nodes: [ + { id: 'start', type: 'start' as const, label: 'Start' }, + { + id: 'fan_out', + type: 'parallel' as const, + label: 'Notify in parallel', + config: { + branches: [ + { + name: 'Notify the owner', + nodes: [ + { + id: 'notify_owner', + type: 'notify', + label: 'Notify Owner', + config: { recipients: ['user_1'], title: 'Done: task', channels: ['inbox'] }, + }, + ], + edges: [], + }, + { + name: 'Post to Slack', + nodes: [ + { + id: 'slack_post', + type: 'http', + label: 'Slack Notify', + config: { + url: 'https://hooks.slack.com/services/T000/B000/XXXX', + method: 'POST', + }, + }, + ], + edges: [], + }, + ], + }, + }, + { id: 'end', type: 'end' as const, label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'fan_out' }, + { id: 'e2', source: 'fan_out', target: 'end' }, + ], +}; + +describe('a failed parallel run keeps its branch steps on the real engine', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const runFanOut = async () => { + vi.stubGlobal('fetch', vi.fn(async () => { + throw new TypeError('fetch failed'); + })); + const { engine, inbox } = boot(); + engine.registerFlow('fan_out_notify', FAN_OUT_FLOW); + const result = await engine.execute('fan_out_notify'); + const record = (await engine.listRuns('fan_out_notify'))[0]; + return { result, record, inbox }; + }; + + it('the step log carries the delivered notify and the failed http, each tagged with its branch', async () => { + const { result, record, inbox } = await runFanOut(); + + // The premise: the run failed, and branch 0 really delivered. + expect(result.success).toBe(false); + expect(record?.status).toBe('failed'); + expect(inbox.sent).toHaveLength(1); + + const steps = record?.steps ?? []; + const branchSteps = steps.filter(s => s.parentNodeId === 'fan_out'); + expect(branchSteps.map(s => ({ + nodeId: s.nodeId, + status: s.status, + regionKind: s.regionKind, + branch: s.branch, + }))).toEqual([ + { nodeId: 'notify_owner', status: 'success', regionKind: 'parallel-branch', branch: 0 }, + { nodeId: 'slack_post', status: 'failure', regionKind: 'parallel-branch', branch: 1 }, + ]); + // The failed branch step carries its own error. + const post = branchSteps.find(s => s.nodeId === 'slack_post'); + expect(post?.error?.code).toBe('NODE_FAILURE'); + expect(post?.error?.message).toContain('fetch failed'); + }); + + it('the run summary counts the delivered notification instead of reporting acted 0', async () => { + const { result, record, inbox } = await runFanOut(); + + expect(inbox.sent).toHaveLength(1); + // Never lower than the deliveries that happened. + expect(result.summary?.acted).toBeGreaterThanOrEqual(inbox.sent.length); + // The persisted run row reads the same as the live result. + expect(record?.summary).toMatchObject({ selected: 1, acted: 1 }); + expect(result.summary).toMatchObject({ selected: 1, acted: 1 }); + // The failed POST is an effect the run cannot count, and says so. + expect(result.summary?.unmeasured).toBe(1); + // Two failures, each on its own node: the branch's POST and the block. + const byId = new Map((result.summary?.nodes ?? []).map(n => [n.nodeId, n])); + expect(byId.get('notify_owner')).toMatchObject({ runs: 1, failures: 0, acted: 1 }); + expect(byId.get('slack_post')).toMatchObject({ runs: 1, failures: 1 }); + expect(byId.get('fan_out')).toMatchObject({ runs: 1, failures: 1, status: 'failure' }); + expect(result.summary?.failed).toBe(2); + }); +}); diff --git a/packages/services/service-automation/src/builtin/parallel-node.test.ts b/packages/services/service-automation/src/builtin/parallel-node.test.ts index c17fc359311..04707a2d456 100644 --- a/packages/services/service-automation/src/builtin/parallel-node.test.ts +++ b/packages/services/service-automation/src/builtin/parallel-node.test.ts @@ -2,8 +2,9 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { AutomationEngine } from '../engine.js'; -import type { NodeExecutor } from '../engine.js'; +import type { NodeExecutor, StepLogEntry } from '../engine.js'; import { registerParallelNode } from './parallel-node.js'; +import { registerLoopNode } from './loop-node.js'; function silentLogger() { return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any; @@ -172,4 +173,294 @@ describe('parallel block executor (ADR-0031)', () => { ])), ).toThrow(/parallel 'par' branch 1/); }); + + /** + * A failing `parallel` used to drop every branch's steps from the run log. + * + * The block returned `{ success: false }` with no `childSteps`, so the run + * log kept the container's own `failure` step and nothing beneath it: not + * the failing branch's step, and not the steps of a SIBLING branch that had + * already completed — and had side effects. The run summary folded over that + * log then reported `acted: 0` for a run that had delivered a notification, + * and `acted: 0` on a failed run reads as "nothing happened, safe to re-run". + * The same harm the dying-`loop` fix closed (`loop-dying-body-steps.test.ts`). + * + * The pins are on the run log and the summary an operator reads, and on the + * DIRECTION of `acted`: never lower than the effects that really happened. + */ + describe('a failing parallel keeps every branch\'s steps in the run log', () => { + /** Stands in for the store: every push is an effect that really happened. */ + let delivered: string[]; + /** What `$error` named when the `fault` handler ran. */ + let handledError: unknown; + + /** `nodeId@parentNodeId/branch:status` — one line per branch step, in log order. */ + const branchLog = (steps: readonly StepLogEntry[]) => + steps + .filter(s => s.regionKind === 'parallel-branch') + .map(s => `${s.nodeId}@${s.parentNodeId}/${s.branch}:${s.status}`); + + const runOnce = async (name = 'par_flow') => { + const result = await engine.execute(name); + const record = (await engine.listRuns(name))[0]; + return { result, steps: record?.steps ?? [] }; + }; + + beforeEach(() => { + delivered = []; + handledError = undefined; + + // Stands in for `notify`: a non-idempotent effect that reports its own + // count (#4354). `delayMs` makes it settle on a LATER macrotask than a + // sibling that fails at once, so "completed after the failure" is real. + engine.registerNodeExecutor({ + type: 'deliver', + async execute(node) { + const cfg = (node.config ?? {}) as Record; + const delayMs = Number(cfg.delayMs ?? 0); + if (delayMs > 0) await new Promise(r => setTimeout(r, delayMs)); + delivered.push(node.id); + order.push(node.id); + return { success: true, metrics: { selected: 1, acted: 1 } }; + }, + } as NodeExecutor); + + // A `fault` handler that records what `$error` named when it ran. + engine.registerNodeExecutor({ + type: 'handler', + async execute(_node, variables) { + order.push('handler'); + handledError = variables.get('$error'); + return { success: true }; + }, + } as NodeExecutor); + + // A branch node that asks to pause. It publishes no descriptor, so the + // engine's `supportsPause` gate has nothing to enforce and the REGION + // boundary is what refuses the pause — the named region refusal the + // `parallel` re-throws rather than returns. + engine.registerNodeExecutor({ + type: 'pause', + async execute() { return { success: true, suspend: true }; }, + } as NodeExecutor); + }); + + it('keeps the delivered sibling AND the failing branch\'s own step, each tagged with its branch', async () => { + engine.registerFlow('par_flow', parallelFlow([ + { name: 'notify', nodes: [{ id: 'notify_owner', type: 'deliver', label: 'Notify owner' }], edges: [] }, + { name: 'post', nodes: [{ id: 'x', type: 'boom', label: 'Post' }], edges: [] }, + ])); + + const { result, steps } = await runOnce(); + + // The premise: the block really failed, and the sibling really delivered. + expect(result.success).toBe(false); + expect(delivered).toEqual(['notify_owner']); + + expect(branchLog(steps)).toEqual([ + 'notify_owner@par/0:success', + 'x@par/1:failure', + ]); + // The failing step carries its own error — "what failed" is answerable. + const failing = steps.find(s => s.nodeId === 'x'); + expect(failing?.error?.code).toBe('NODE_FAILURE'); + expect(failing?.error?.message).toContain('branch kaboom'); + }); + + it('the run summary counts the delivered branch — `acted` never lower than the effects that happened', async () => { + engine.registerFlow('par_flow', parallelFlow([ + { name: 'notify', nodes: [{ id: 'notify_owner', type: 'deliver', label: 'Notify owner' }], edges: [] }, + { name: 'post', nodes: [{ id: 'x', type: 'boom', label: 'Post' }], edges: [] }, + ])); + + const { result } = await runOnce(); + + expect(result.success).toBe(false); + expect(result.summary?.acted).toBeGreaterThanOrEqual(delivered.length); + // Exactly reconciled: nothing invented, nothing folded twice. + expect(result.summary).toMatchObject({ selected: 1, acted: 1 }); + const byId = new Map((result.summary?.nodes ?? []).map(n => [n.nodeId, n])); + expect(byId.get('notify_owner')).toMatchObject({ runs: 1, failures: 0, acted: 1 }); + expect(byId.get('x')).toMatchObject({ runs: 1, failures: 1 }); + // The container's own record is unchanged: one execution, one failure. + expect(byId.get('par')).toMatchObject({ status: 'failure', runs: 1, failures: 1 }); + }); + + it('keeps the failing branch\'s steps up to AND including its failing step, and nothing after it', async () => { + engine.registerFlow('par_flow', parallelFlow([ + { name: 'solo', nodes: [{ id: 'd', type: 'deliver', label: 'D' }], edges: [] }, + { + name: 'chain', + nodes: [ + { id: 'c1', type: 'deliver', label: 'C1' }, + { id: 'x', type: 'boom', label: 'X' }, + { id: 'c3', type: 'deliver', label: 'C3' }, + ], + edges: [ + { id: 'ce1', source: 'c1', target: 'x' }, + { id: 'ce2', source: 'x', target: 'c3' }, + ], + }, + ])); + + const { result, steps } = await runOnce(); + + expect(delivered.sort()).toEqual(['c1', 'd']); + // Branch order is the declaration order, as on the success path. + expect(branchLog(steps)).toEqual([ + 'd@par/0:success', + 'c1@par/1:success', + 'x@par/1:failure', + ]); + expect(result.summary?.acted).toBe(delivered.length); + }); + + it('keeps a sibling that completes AFTER the failing branch has already failed', async () => { + engine.registerFlow('par_flow', parallelFlow([ + { name: 'slow', nodes: [{ id: 'slow', type: 'deliver', label: 'Slow', config: { delayMs: 15 } }], edges: [] }, + { name: 'bad', nodes: [{ id: 'x', type: 'boom', label: 'X' }], edges: [] }, + ])); + + const { result, steps } = await runOnce(); + + expect(result.success).toBe(false); + expect(delivered).toEqual(['slow']); + expect(branchLog(steps)).toEqual([ + 'slow@par/0:success', + 'x@par/1:failure', + ]); + expect(result.summary?.acted).toBe(1); + }); + + it('a `fault` edge on the block routes exactly as before; the branch steps sit between the block and its handler', async () => { + engine.registerFlow('par_flow', { + name: 'par_flow', + label: 'Parallel Flow', + type: 'autolaunched' as const, + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'par', type: 'parallel', label: 'Fan out', + config: { + branches: [ + { name: 'notify', nodes: [{ id: 'notify_owner', type: 'deliver', label: 'Notify owner' }], edges: [] }, + { name: 'post', nodes: [{ id: 'x', type: 'boom', label: 'Post' }], edges: [] }, + ], + }, + }, + { id: 'join', type: 'after', label: 'After' }, + { id: 'recover', type: 'handler', label: 'Recover' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'par' }, + { id: 'e2', source: 'par', target: 'join' }, + { id: 'e3', source: 'join', target: 'end' }, + { id: 'ef', source: 'par', target: 'recover', type: 'fault' }, + ], + }); + + const { result, steps } = await runOnce(); + + // Routing is unchanged: the returned failure is routable, the handler + // runs, the join does not, and the handled run completes. + expect(result.success).toBe(true); + expect(order).toContain('handler'); + expect(order).not.toContain('after'); + // `$error` names the block, as a returned failure always has. + expect((handledError as { nodeId?: string } | undefined)?.nodeId).toBe('par'); + + // The block's own step keeps the returned-failure shape. + const parStep = steps.find(s => s.nodeId === 'par' && s.parentNodeId === undefined); + expect(parStep?.status).toBe('failure'); + expect(parStep?.error?.code).toBe('NODE_FAILURE'); + + // Position: right behind the block's own step, ahead of the handler's. + const ids = steps.map(s => s.nodeId); + expect(ids.slice(ids.indexOf('par'), ids.indexOf('recover') + 1)).toEqual([ + 'par', 'notify_owner', 'x', 'recover', + ]); + }); + + it('reverse control: branches that fail before any effect still report acted: 0', async () => { + engine.registerFlow('par_flow', parallelFlow([ + { name: 'bad_a', nodes: [{ id: 'xa', type: 'boom', label: 'XA' }], edges: [] }, + { name: 'bad_b', nodes: [{ id: 'xb', type: 'boom', label: 'XB' }], edges: [] }, + ])); + + const { result, steps } = await runOnce(); + + expect(result.success).toBe(false); + expect(delivered).toEqual([]); + // Both failures are on the record; the count stays the honest zero. + expect(branchLog(steps)).toEqual(['xa@par/0:failure', 'xb@par/1:failure']); + expect(result.summary?.acted).toBe(0); + }); + + it('a pause refused at a branch boundary still fails the run, and keeps the sibling\'s steps', async () => { + engine.registerFlow('par_flow', parallelFlow([ + { name: 'notify', nodes: [{ id: 'notify_owner', type: 'deliver', label: 'Notify owner' }], edges: [] }, + { name: 'wait', nodes: [{ id: 'hold', type: 'pause', label: 'Hold' }], edges: [] }, + ])); + + const { result, steps } = await runOnce(); + + // The region refusal reaches the run as before: failed, not paused, and + // the join never ran. + expect(result.success).toBe(false); + expect(result.status).toBe('failed'); + expect(order).not.toContain('after'); + expect(branchLog(steps)).toContain('notify_owner@par/0:success'); + expect(result.summary?.acted).toBe(delivered.length); + // Still ONE failure: the refusal is counted on the block, once. + expect(result.summary?.failed).toBe(1); + }); + + it('nested in a loop, every branch step reaches the log exactly once', async () => { + registerLoopNode(engine, ctx()); + engine.registerFlow('nest_flow', { + name: 'nest_flow', + label: 'Nested', + type: 'autolaunched' as const, + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'each', type: 'loop', label: 'Each', + config: { + collection: [1, 2], + iteratorVariable: 'row', + body: { + nodes: [{ + id: 'par', type: 'parallel', label: 'Fan out', + config: { + branches: [ + { name: 'notify', nodes: [{ id: 'notify_owner', type: 'deliver', label: 'Notify owner' }], edges: [] }, + { name: 'post', nodes: [{ id: 'x', type: 'boom', label: 'Post' }], edges: [] }, + ], + }, + }], + edges: [], + }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'each' }, + { id: 'e2', source: 'each', target: 'end' }, + ], + }); + + const { result, steps } = await runOnce('nest_flow'); + + // The first row's block fails, so the loop dies on iteration 0. + expect(result.success).toBe(false); + expect(delivered).toEqual(['notify_owner']); + expect(branchLog(steps)).toEqual(['notify_owner@par/0:success', 'x@par/1:failure']); + expect(steps.filter(s => s.nodeId === 'par')).toHaveLength(1); + expect(new Set(steps).size).toBe(steps.length); + // A step folded twice would push `acted` above the real count. + expect(result.summary?.acted).toBe(delivered.length); + }); + }); }); diff --git a/packages/services/service-automation/src/builtin/parallel-node.ts b/packages/services/service-automation/src/builtin/parallel-node.ts index 831b21c192b..47a3dfb296d 100644 --- a/packages/services/service-automation/src/builtin/parallel-node.ts +++ b/packages/services/service-automation/src/builtin/parallel-node.ts @@ -7,6 +7,7 @@ import type { AutomationContext } from '@objectstack/spec/contracts'; import type { AutomationEngine, StepLogEntry } from '../engine.js'; import { parseNodeConfig } from './parse-config.js'; import { isRegionSuspensionRefusal } from '../region-suspension-refusal.js'; +import { attachPartialSteps } from '../partial-steps.js'; /** * `parallel` built-in node — a **structured parallel block** with an @@ -27,8 +28,10 @@ import { isRegionSuspensionRefusal } from '../region-suspension-refusal.js'; * * If any branch fails (a node returns `success: false` or throws), the block * fails — surfaced as a node failure so the flow's fault edge / error handling - * applies. Durable pause inside a branch is unsupported (a clear error), mirror- - * ing the loop container. + * applies. The failure is reported once every branch has settled, and it + * carries every branch's steps: the siblings that completed, and the failing + * branch's own steps up to and including the one that failed. Durable pause + * inside a branch is unsupported (a clear error), mirroring the loop container. */ export function registerParallelNode(engine: AutomationEngine, ctx: PluginContext): void { engine.registerNodeExecutor({ @@ -69,44 +72,79 @@ export function registerParallelNode(engine: AutomationEngine, ctx: PluginContex if (!parsed.ok) return parsed.refusal; const branches = parsed.config.branches; - let branchSteps: StepLogEntry[][]; - try { - // Implicit join: continue once when ALL branches have completed. - // #1479: each branch returns its body steps, tagged with the branch index. - // - // #15230: the branch index goes on `branch`, NOT on `iteration`. It - // used to share `iteration` with the loop's row index — one field, two - // meanings, told apart only by reading `regionKind` first — and because - // the tagger let the innermost region win outright, a branch step - // inside a loop body recorded the branch and lost the row entirely. - // `iteration` is now single-valued (the enclosing loop's, carried - // through nesting by `runRegion`'s tagger), so nothing here writes it: - // a branch step inside a loop body gets the row from the loop's own - // tagging pass, and a bare `parallel` leaves it absent because there is - // no enclosing loop to report. Maintainer ruling 2026-09-03. - branchSteps = await Promise.all( - branches.map((branchRegion, i) => - engine.runRegion(branchRegion, variables, context ?? ({} as AutomationContext), { - parentNodeId: node.id, - branch: i, - regionKind: 'parallel-branch', - }), - ), - ); - } catch (err) { + // A failing block keeps the record of what its branches did. A branch + // that completed may already have delivered a notification or called + // out, and a run log without its step makes the #4354 summary report + // `acted: 0` for that run — which reads as "nothing happened, safe to + // re-run", the double-execution misread a dying `loop` was fixed for + // (`partial-steps.ts`). + // + // One #7546 `partialSteps` sink PER BRANCH: `runRegion` pushes a failing + // branch's tagged steps (its failing step included) into that branch's + // sink before it rethrows, and a completed branch returns them instead, + // so each step is collected exactly once and the log keeps declaration + // order, as on the success path. + // + // The block waits for EVERY branch to settle before it reports a + // failure: a sibling still running when another branch fails would + // otherwise finish its effect after the block had already failed, with + // no step on the record. `rejections` keeps the settle order, so the + // failure the block reports is the one `Promise.all` rejected with. + const sinks: StepLogEntry[][] = branches.map(() => []); + const rejections: { err: unknown }[] = []; + // Implicit join: continue once when ALL branches have completed. + // #1479: each branch returns its body steps, tagged with the branch index. + // + // #15230: the branch index goes on `branch`, NOT on `iteration`. It + // used to share `iteration` with the loop's row index — one field, two + // meanings, told apart only by reading `regionKind` first — and because + // the tagger let the innermost region win outright, a branch step + // inside a loop body recorded the branch and lost the row entirely. + // `iteration` is now single-valued (the enclosing loop's, carried + // through nesting by `runRegion`'s tagger), so nothing here writes it: + // a branch step inside a loop body gets the row from the loop's own + // tagging pass, and a bare `parallel` leaves it absent because there is + // no enclosing loop to report. Maintainer ruling 2026-09-03. + const branchSteps = await Promise.all( + branches.map(async (branchRegion, i) => { + try { + return await engine.runRegion( + branchRegion, + variables, + context ?? ({} as AutomationContext), + { parentNodeId: node.id, branch: i, regionKind: 'parallel-branch' }, + sinks[i], + ); + } catch (err) { + rejections.push({ err }); + return sinks[i]; + } + }), + ); + const childSteps = branchSteps.flat(); + + const first = rejections[0]; + if (first) { + const { err } = first; // [#18881] A durable pause raised inside a BRANCH is refused at the // region boundary and must reach the run, ⛔ not be folded into this // node's returned failure. A returned failure is routable by a `fault` // edge on the `parallel` node, and routing this one would re-open the // silence the card closes: the run would continue past a region that // parked nothing and report success. Re-thrown, so the engine's own - // catch path records it once and fails the run. - if (isRegionSuspensionRefusal(err)) throw err; + // catch path records it once and fails the run — carrying the branch + // steps on the error the way a dying `loop` does, which leaves the + // error itself (identity, message, refusal marking) untouched. + if (isRegionSuspensionRefusal(err)) throw attachPartialSteps(err, childSteps); const message = err instanceof Error ? err.message : String(err); - return { success: false, error: `parallel '${node.id}': branch failed — ${message}` }; + // Returned, not thrown, exactly as before: the block's failure stays + // routable by a `fault` edge on the `parallel`, with the same step + // code, error text and `$error`. The engine folds a returned failure's + // `childSteps` into the run log right behind the block's own step. + return { success: false, error: `parallel '${node.id}': branch failed — ${message}`, childSteps }; } - return { success: true, output: { branches: branches.length }, childSteps: branchSteps.flat() }; + return { success: true, output: { branches: branches.length }, childSteps }; }, });