From 836fb42e338ac498a3e71daa4b2b52c695185c68 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:13:10 +0000 Subject: [PATCH 1/3] test(service-automation): pin that a failing parallel keeps every branch'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 --- .../src/builtin/parallel-node.test.ts | 293 +++++++++++++++++- 1 file changed, 292 insertions(+), 1 deletion(-) 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); + }); + }); }); From a7d5ac2151d5eb568ba8d0bde87bd93809197d7a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:16:49 +0000 Subject: [PATCH 2/3] fix(service-automation): a failing parallel keeps every branch's steps 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 --- ...el-failed-branch-steps.integration.test.ts | 176 ++++++++++++++++++ .../src/builtin/parallel-node.ts | 100 +++++++--- 2 files changed, 245 insertions(+), 31 deletions(-) create mode 100644 packages/services/service-automation/src/builtin/parallel-failed-branch-steps.integration.test.ts 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.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 }; }, }); From caf2767ea06e7be53a0e56a899c6aaca0a9a5296 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:21:34 +0000 Subject: [PATCH 3/3] chore(changeset): service-automation patch for a failed parallel keeping its branch steps Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP Co-authored-by: Claude --- .changeset/23928-parallel-failed-branch-steps.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/23928-parallel-failed-branch-steps.md 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.