From 3d7201f2a942d78700b9ebf9a4ee03e5330c14b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 00:46:31 +0000 Subject: [PATCH 1/3] wip: #18714 resumed-leg refusal rollup --- .../services/service-automation/src/engine.ts | 184 +++++++++++++++++- 1 file changed, 180 insertions(+), 4 deletions(-) diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 53fee28ca7d..90ccd6c29ed 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -1207,6 +1207,39 @@ function isRefusalSignal(err: unknown): err is FlowRefusalSignal { return typeof err === 'object' && err !== null && (err as FlowRefusalSignal).__flowRefused === true; } +/** + * [#18714] "The child run this frame is parked on finished `refused`" — the + * RESUMED-leg counterpart of the synchronous `subflow` / `map` executors' + * `refuse` result (#18110 / #18555). + * + * Those two executors read `child.status === 'refused'` off the value + * `engine.execute` returned to them. A run that PAUSES first never returns + * through that call at all: the child's outcome reaches its parent on one of + * the two resumed legs instead — the delegated resume + * ({@link AutomationEngine.resumeInternal}'s `subflow:` block, which drives the + * child itself) and the up-bubble ({@link AutomationEngine.bubbleToParent}, + * where the child's own frame drives the parent). Neither leg had an arm for + * `refused`, so the two failed differently and both fail-open in their own way: + * the delegated leg read the refusal as an ordinary success and walked the + * parent's out-edges, and the up-bubble leg never resumed the parent at all, + * leaving it `paused` in `listSuspendedRuns()` forever. + * + * ⛔ Not an error and ⛔ not an error CODE. A refusal is a successful + * evaluation that says no, so the only thing this carries is the rendered + * reason — exactly what {@link FlowRefusalSignal} already carries, and the + * reason both legs converge on that one signal rather than growing a second + * terminal exit apiece. + */ +interface ChildRunRefusal { + /** + * The child run's own `refusalMessage`, passed through verbatim. + * `undefined` only when the child carried none — recorded honestly rather + * than filled in with invented text, exactly as `FlowRefusalSignal.message` + * is. + */ + readonly message?: string; +} + /** * The definition-level input-schema guard's own throw type (#10025). * @@ -6154,6 +6187,13 @@ export class AutomationEngine implements IAutomationService { * child's own up-bubble must stay off so the parent isn't resumed twice. * @param childSummary - #4354: totals of the child run whose completion * triggered this resume (the up-bubble path), credited to the awaiting step. + * @param childRefusal - [#18714] Set ONLY by {@link bubbleToParent}'s + * refusal arm: the child this run is parked on finished `refused`, so + * this run must refuse too instead of continuing past its `subflow` / + * `map` node. Carried as a parameter rather than on the resume signal + * because it is engine-internal control flow, not data the parent's + * variable map should ever see — the reserved-name guard + * (`applyResumeSignal`) polices that map, and a refusal is not a variable. */ private async resumeInternal( runId: string, @@ -6165,6 +6205,7 @@ export class AutomationEngine implements IAutomationService { signal: ResumeSignal, skipBubble: boolean, childSummary?: FlowRunSummary, + childRefusal?: ChildRunRefusal, ): Promise { // Idempotency guard (set synchronously, before any await): reject a // concurrent duplicate resume of the same run so side effects can't run @@ -6262,6 +6303,16 @@ export class AutomationEngine implements IAutomationService { // it, before traversal appends anything further. this.creditChildRun(run.steps, run.nodeId, childSummary); + // [#18714] The refusal this run must end on instead of continuing + // past its `subflow` / `map` node, if any. Seeded from the UP-BUBBLE + // leg (the child's own frame drove this resume and already knows), + // and set below by the DELEGATED leg (this frame drives the child + // and reads its answer). Both legs hand it to the ONE throw site + // inside the traversal `try` further down, so a refusal leaves this + // method through the same `finishRefusedRun` chokepoint every other + // producer uses — see `ChildRunRefusal`. + let pendingChildRefusal: ChildRunRefusal | undefined = childRefusal; + // ── Subflow delegation (nested pause): this run is paused at a // `subflow` node whose child run itself suspended. The caller's // signal is meant for the node the CHILD paused on (its screen / @@ -6352,6 +6403,46 @@ export class AutomationEngine implements IAutomationService { ); return { success: false, error, durationMs: Date.now() - run.startTime }; } + // [#18714] DELEGATED-LEG REFUSAL. The child ran to a + // refusing terminal — an `end` declaring + // `outcome: 'refused'`, or a node of its own whose child + // refused — and answered `{ success: true, status: + // 'refused' }`, because *a refusal is a successful + // evaluation that says no*. The branch set above tests only + // `paused` and `!success`, so until this arm existed a + // refused child was neither and fell straight through the + // ordinary success exit below: measured, the parent + // returned `{ success: true, successMessage: … }`, its run + // row recorded `completed`, and the node downstream of the + // `subflow` RAN. That is the identical fail-open shape + // #18110 / #18555 closed on the synchronous leg, still open + // on the leg 「the one a screen flow actually takes」. + // + // ⛔ NOT folded into the `!childRes.success` arm above. That + // arm calls `failSuspendedRun` and records the parent + // `failed`; a refusal is not a failure — it must not be + // routable by a `fault` edge, must not consume retry budget + // and must not be counted in `nodes[].failures`, all of + // which the failure arm would confer. The parent's terminal + // row must read `refused`, the same word the child's does. + // + // Recorded, ⛔ not thrown here. The pause is not consumed + // yet — `claimAdvance` / `forgetSuspendedRun` are still + // below — and a refusal that unwound from this position + // would leave the parent's own suspension live while its + // run row said terminal. The throw site inside the + // traversal `try` is past the consumption, which is why + // both legs converge there. + // + // The mapping below still runs, deliberately: the child's + // declared outputs really were produced and the nodes + // before its refusal really ran, so the parent's answer + // must not depend on HOW the child ended — the same call + // the synchronous `subflow` arm makes when it returns the + // success envelope PLUS `refuse`. + if (childRes.status === 'refused') { + pendingChildRefusal = { message: childRes.refusalMessage }; + } // Child completed — continue below with its output as the // resume signal (replaces the caller's signal, which the // child already consumed). @@ -6577,6 +6668,32 @@ export class AutomationEngine implements IAutomationService { const context = run.context; try { + // [#18714] The child this run is parked on REFUSED — on either + // resumed leg (see `ChildRunRefusal`). Thrown HERE, and the + // position is the point: + // + // - Past the consumption. `claimAdvance` and + // `forgetSuspendedRun` have run, so the parent's own pause is + // gone exactly as it is for every other way this resume can + // end. A refusal raised before them would record a terminal + // run while leaving its suspension live in + // `listSuspendedRuns()` — the very leak the up-bubble leg is + // filed for, moved one frame up. + // - Before the traversal. Nothing downstream of the awaiting + // node runs, which is the whole content of "a refusal stops + // the run". + // - As `FlowRefusalSignal`, so the `catch` below converts it + // through `finishRefusedRun` — ONE terminal shape, whichever + // producer raised it. ⛔ Deliberately not a second terminal + // exit of its own: this file's own history is a list of + // outcomes that became a function of WHICH ROUTE a run took. + // + // `run.nodeId` is the node that was awaiting the child — the + // `subflow` / `map` this frame is parked on, the same node the + // synchronous leg names on its `FlowRefusalSignal`. + if (pendingChildRefusal) { + throw new FlowRefusalSignal(run.nodeId, pendingChildRefusal.message); + } // ── Map re-entry (sequential multi-instance, ADR-0037 A2). // A run paused at a `map` node (correlation `map:`) // does NOT continue past the node on resume — it RE-RUNS the @@ -6707,7 +6824,7 @@ export class AutomationEngine implements IAutomationService { // here, never through `execute()`'s exit. Tested first, beside // the re-suspend, for the same reason it is tested first there. if (isRefusalSignal(err)) { - return this.finishRefusedRun({ + const refused = this.finishRefusedRun({ runId, flowName: run.flowName, flowVersion: run.flowVersion, @@ -6716,6 +6833,47 @@ export class AutomationEngine implements IAutomationService { steps, flow, variables, refusalMessage: err.message, context, }); + // [#18714] UP-BUBBLE LEG. This run is terminal and the + // caller holds ITS id — but if it was a subflow CHILD, some + // ancestor is still parked at the `subflow` / `map` node + // that started it, and nothing else in the engine will ever + // move that ancestor: `bubbleToParent` was called on the + // completion path alone, so a child resumed to a refusal + // returned from here having resolved exactly one of the two + // runs it is responsible for. Measured: the child row read + // `refused` while its parent stayed `paused` and stayed in + // `listSuspendedRuns()` indefinitely — a leaked run, not a + // wrong answer, which is why it fails DIFFERENTLY from the + // delegated leg above and needs its own arm. + // + // The same call the completion path makes, with the refusal + // attached: the parent is genuinely resumed — it consumes + // its pause, records its own terminal row and bubbles to + // ITS parent in turn, so a chain of any depth resolves by + // the same induction completions already rely on. ⛔ Not a + // direct walk like `failAncestors`: that verb exists for a + // cascade in which no ancestor can be resumed at all, and + // it records them `failed` — the wrong word here. + // + // `output` and `summary` are the refused run's own, for the + // reason `finishRefusedRun` collects them: the nodes before + // the refusal really ran, and a parent that refuses must + // still be able to report what its child did. + // + // `skipBubble` is honoured exactly as on the completion + // path — under the DELEGATED leg the parent's frame is the + // caller, and it raises its own refusal at the throw site + // above. Bubbling here as well would resume the parent + // twice. + if (!skipBubble) { + await this.bubbleToParent( + run, + (refused.output ?? {}) as Record, + refused.summary, + { message: err.message }, + ); + } + return refused; } // Re-suspended at a downstream node: persist a fresh continuation. if (isSuspendSignal(err)) { @@ -7047,12 +7205,19 @@ export class AutomationEngine implements IAutomationService { } /** - * Up-bubble for the subflow chain: when a completed run carries + * Up-bubble for the subflow chain: when a TERMINAL run carries * `$parentRunId`, resume that parent with this run's output. Recursion via - * the parent's own completion bubbles multi-level chains. Best-effort — + * the parent's own terminal exit bubbles multi-level chains. Best-effort — * a failed parent continuation is logged, never thrown back at the * caller who resumed the child. * + * [#18714] "Terminal" is two outcomes, not one. The completion path was the + * only caller until this card, so a child that resumed to a REFUSAL left + * its parent parked at the awaiting node forever — visible in + * `listSuspendedRuns()`, resumable by nobody, because the child it waits on + * no longer exists. The refusal arm calls this with `refusal` set; every + * other thing this method does is identical on both outcomes. + * * [#15556] Best-effort at the ENGINE layer only, since this call never * throws either way: on the `'stranded'` exit — the one #15556's ruling * names — it also records a {@link SubflowParentStrand} under the CHILD's @@ -7069,6 +7234,17 @@ export class AutomationEngine implements IAutomationService { output: Record, /** #4354 — this child's totals, credited to the parent's awaiting step. */ summary?: FlowRunSummary, + /** + * [#18714] Set when this child finished `refused` rather than + * `completed`. Everything about the bubble is unchanged — the same + * mapped signal, the same best-effort contract, the same per-outcome + * #4632 grading below — except that the parent ends on the child's + * refusal instead of continuing past its awaiting node. Carried to + * {@link resumeInternal} as its own argument, ⛔ never folded into + * `sig`: the signal is the parent's variable map, and a refusal is + * control flow, not a variable. + */ + refusal?: ChildRunRefusal, ): Promise { const ctx = run.context as Record | undefined; const parentRunId = ctx?.$parentRunId; @@ -7083,7 +7259,7 @@ export class AutomationEngine implements IAutomationService { // the one writer allowed to set them (#3853 follow-up). ? engineBuilt({ variables: { [`${mapNode}.$mapItemOutput`]: output ?? null, [`${mapNode}.$mapItemDone`]: true } }) : this.buildSubflowResumeSignal(run.context, output); - const parentRes = await this.resumeInternal(parentRunId, sig, false, summary); + const parentRes = await this.resumeInternal(parentRunId, sig, false, summary, refusal); if (!parentRes.success) { // #6499 — `parentRes.error` is the envelope field that carries // a failing node's / driver's text VERBATIM (#5912 left it From 150f0d2c61f2b5db02abdf1c0c3243431d866c97 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 00:54:16 +0000 Subject: [PATCH 2/3] test(service-automation): two pins for the resumed-leg refusal rollup --- .../src/resumed-leg-refusal-rollup.test.ts | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts diff --git a/packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts b/packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts new file mode 100644 index 00000000000..b39706a975b --- /dev/null +++ b/packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #18714 — a child that PAUSES and then refuses must roll its refusal up on + * BOTH resumed legs. + * + * #18110 / #18555 gave the `subflow` and `map` executors an arm for + * `child.status === 'refused'`. That arm reads the value `engine.execute` + * RETURNED to them, so it covers exactly one shape: a child that ran straight + * through without pausing. A child that parks on a screen never returns through + * that call at all — its outcome reaches its parent on one of two resumed legs, + * and neither had an arm. `engine.ts` describes the resumed leg, in its own + * words, as 「the one a screen flow actually takes」, so the uncovered legs were + * the common ones for the feature that made `refused` reachable at all + * (#15788). + * + * ⚠️ TWO PINS, because the two legs fail DIFFERENTLY and a single "a refusal is + * handled" assertion would be one measurement restated: + * + * - **Delegated resume** (`engine.resume(parentRunId)`) — measured on the + * unfixed engine: the parent answered `{ success: true, successMessage: … }`, + * its run row recorded `completed`, and the node downstream of the `subflow` + * RAN. The refusal is LOST, fail-open — a refusing gate that lets the run + * through, which nobody notices because the flow finishes green. + * - **Up-bubble** (`engine.resume(childRunId)`) — measured on the unfixed + * engine: the child row recorded `refused` correctly, and the parent stayed + * `paused`, in `listSuspendedRuns()`, indefinitely. Nothing is wrong with + * the answer; a RUN IS LEAKED. `bubbleToParent` was called on the completion + * path only. + * + * ⚠️ `refused` here is the run OUTCOME — *a refusal is a successful evaluation + * that says no* — ⛔ NOT this package's other `refused` (a GUARD refusal, + * `guard-refusal.ts`, which is a kind of FAILURE). The two senses are + * distinguished at `engine.ts`'s `FlowRefusalSignal` docblock, and + * `builtin/subflow-child-refusal.test.ts` is about a THIRD thing again + * (#14379's retryable resume-bag codes). + * + * ⚠️ Direction, predicted before running: every `it` under the two `the defect` + * blocks FAILS against the unfixed engine. The CONTROLS are green on both sides + * on purpose — an engine that had started refusing every resumed child would + * satisfy the defect assertions and fail these. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { AutomationEngine } from './engine.js'; +import type { NodeExecutor } from './engine.js'; +import { installBuiltinNodes } from './builtin/index.js'; + +function silentLogger() { + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any; +} +function pluginCtx() { + return { logger: silentLogger(), getService() { return undefined; } } as any; +} + +/** The parent's own completion toast — it must never ride a child's refusal. */ +const PARENT_TOAST = 'Parent completed!'; +/** The authored refusal template. `{kind}` is what makes the text per-record. */ +const REFUSAL_TEMPLATE = 'Refused: {kind} is not eligible'; +/** …rendered in the CHILD against the value the screen collected. */ +const RENDERED_REFUSAL = 'Refused: vip is not eligible'; +/** + * The child's post-pause, pre-refusal work, reported as #4354 metrics. A child + * that refuses really can have written rows before it said no, and a parent + * summary that forgot them reads "nothing happened, safe to re-run". + */ +const CHILD_METRICS = { selected: 3, acted: 2 } as const; + +/** The child's screen declares exactly one unconditional required field. */ +const REQUIRED_KIND = [{ name: 'kind', label: 'Kind', type: 'text', required: true }]; + +/** + * A child that PARKS on a real `screen` node, does work when resumed, and then + * reaches an `end`. `endConfig` absent = a plain completion (the control). + */ +const pausingChild = (name: string, endConfig?: Record) => ({ + name, + label: name, + type: 'screen', + status: 'active', + version: 1, + variables: [{ name: 'kind', type: 'text', isOutput: true }], + nodes: [ + { id: 'c_start', type: 'start', label: 'Start' }, + { id: 'ask', type: 'screen', label: 'Ask', config: { fields: REQUIRED_KIND } }, + { id: 'c_work', type: 'childwork', label: 'Work' }, + { id: 'c_end', type: 'end', label: 'End', ...(endConfig ? { config: endConfig } : {}) }, + ], + edges: [ + { id: 'ce0', source: 'c_start', target: 'ask', type: 'default' }, + { id: 'ce1', source: 'ask', target: 'c_work', type: 'default' }, + { id: 'ce2', source: 'c_work', target: 'c_end', type: 'default' }, + ], +}); + +/** The parent: start → subflow(child) → downstream → end, with its own toast. */ +const parentFlow = (childName: string) => ({ + name: 'parent_flow', + label: 'Parent Flow', + type: 'autolaunched', + status: 'active', + version: 1, + successMessage: PARENT_TOAST, + nodes: [ + { id: 'ps', type: 'start', label: 'Start' }, + { id: 'call', type: 'subflow', label: 'Call Child', config: { flowName: childName, outputVariable: 'childOut' } }, + { id: 'after', type: 'downstream', label: 'After' }, + { id: 'pe', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'p1', source: 'ps', target: 'call', type: 'default' }, + { id: 'p2', source: 'call', target: 'after', type: 'default' }, + { id: 'p3', source: 'after', target: 'pe', type: 'default' }, + ], +}); + +describe('#18714 — a resumed child run that REFUSES rolls up on both legs', () => { + let engine: AutomationEngine; + let ran: string[]; + + beforeEach(() => { + engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, pluginCtx()); + ran = []; + + engine.registerNodeExecutor({ + type: 'childwork', + async execute() { + ran.push('child-work'); + return { success: true, metrics: { ...CHILD_METRICS } }; + }, + } as NodeExecutor); + // The node AFTER the parent's `subflow`. Its presence in `ran` IS the + // "the parent walked on" assertion — ⛔ not a proxy for it. + engine.registerNodeExecutor({ + type: 'downstream', + async execute() { + ran.push('downstream'); + return { success: true }; + }, + } as NodeExecutor); + + engine.registerFlow('gate_refuses', pausingChild('gate_refuses', { outcome: 'refused', message: REFUSAL_TEMPLATE }) as never); + engine.registerFlow('gate_allows', pausingChild('gate_allows') as never); + }); + + /** Start the parent and return `[parentRunId, childRunId]` — both parked. */ + async function startPair(childName: string): Promise<[string, string]> { + engine.registerFlow('parent_flow', parentFlow(childName) as never); + const started = await engine.execute('parent_flow', {} as never); + expect(started.status).toBe('paused'); + const parentRunId = started.runId!; + const child = engine.listSuspendedRuns().find((r) => r.runId !== parentRunId)!; + expect(child).toBeDefined(); + return [parentRunId, child.runId]; + } + + // ══ LEG 1 ══════════════════════════════════════════════════════════════ + describe('the defect, leg 1 — DELEGATED resume: the refusal was LOST fail-open', () => { + it('the parent run REFUSES — it does not answer success and record `completed`', async () => { + const [parentRunId] = await startPair('gate_refuses'); + + const res = await engine.resume(parentRunId, { variables: { kind: 'vip' } }); + + expect(res.success).toBe(true); // a refusal is a successful evaluation + expect(res.status).toBe('refused'); + expect((await engine.getRun(parentRunId))?.status).toBe('refused'); + }); + + it("the child's rendered reason reaches the parent's caller, and the parent's toast stays silent", async () => { + const [parentRunId] = await startPair('gate_refuses'); + + const res = await engine.resume(parentRunId, { variables: { kind: 'vip' } }); + + // Interpolated in the CHILD against the value its screen collected + // and passed through — ⛔ not re-rendered, ⛔ not invented here. + expect(res.refusalMessage).toBe(RENDERED_REFUSAL); + expect((await engine.getRun(parentRunId))?.refusalMessage).toBe(RENDERED_REFUSAL); + // Stamping the completion toast here would toast "Parent completed!" + // over a refusal to complete. + expect(res.successMessage).toBeUndefined(); + }); + + it('downstream nodes do NOT run — the subflow node\'s out-edges are not walked', async () => { + const [parentRunId] = await startPair('gate_refuses'); + + await engine.resume(parentRunId, { variables: { kind: 'vip' } }); + + expect(ran).toEqual(['child-work']); + expect(ran).not.toContain('downstream'); + }); + + it("preserves the child's #4354 rollup on the refusal path", async () => { + // The refusal is raised past the consumption and past + // `creditChildRun`, so the child's counts are already in the + // parent's step log when the run terminates. + // + // ⛔ The refusal assertion belongs IN this test: without it the + // totals below are equally true of the unfixed engine, which rolled + // the same metrics up and then carried on. + const [parentRunId] = await startPair('gate_refuses'); + + const res = await engine.resume(parentRunId, { variables: { kind: 'vip' } }); + + expect(res.status).toBe('refused'); + expect(res.summary?.nodes.find((n) => n.nodeId === 'call')).toMatchObject({ + selected: 3, acted: 2, + }); + }); + }); + + describe('the control, leg 1 — an allowing child still completes the parent', () => { + // ⛔ Mandatory, not decoration: every assertion above is also satisfied + // by an engine that had started refusing EVERY resumed child. + it('completes, fires the toast, walks on, and leaks nothing', async () => { + const [parentRunId, childRunId] = await startPair('gate_allows'); + + const res = await engine.resume(parentRunId, { variables: { kind: 'vip' } }); + + expect(res.success).toBe(true); + expect(res.status).toBeUndefined(); // the terminal-success exit stamps none + expect(res.refusalMessage).toBeUndefined(); + expect(res.successMessage).toBe(PARENT_TOAST); + expect(ran).toEqual(['child-work', 'downstream']); + expect((await engine.getRun(parentRunId))?.status).toBe('completed'); + expect(await engine.hasSuspendedRun(parentRunId)).toBe(false); + expect(await engine.hasSuspendedRun(childRunId)).toBe(false); + }); + }); + + // ══ LEG 2 ══════════════════════════════════════════════════════════════ + describe('the defect, leg 2 — UP-BUBBLE: the parent was LEAKED `paused` forever', () => { + it('the parent is no longer suspended — it leaves `listSuspendedRuns()`', async () => { + // ⭐ THE leg-2 signature, and it is not the leg-1 one: leg 1 answered + // the wrong thing about a run it did resolve; this leg answers + // correctly about the CHILD and never resolves the parent at all. + const [parentRunId, childRunId] = await startPair('gate_refuses'); + + await engine.resume(childRunId, { variables: { kind: 'vip' } }); + + expect(await engine.hasSuspendedRun(parentRunId)).toBe(false); + expect(engine.listSuspendedRuns().map((r) => r.runId)).not.toContain(parentRunId); + expect(engine.listSuspendedRuns()).toEqual([]); + }); + + it('the parent records its own terminal `refused` row, carrying the same reason', async () => { + const [parentRunId, childRunId] = await startPair('gate_refuses'); + + await engine.resume(childRunId, { variables: { kind: 'vip' } }); + + const parentRow = await engine.getRun(parentRunId); + expect(parentRow?.status).toBe('refused'); + expect(parentRow?.refusalMessage).toBe(RENDERED_REFUSAL); + // …and the child's own row is unchanged by the bubble. + expect((await engine.getRun(childRunId))?.status).toBe('refused'); + }); + + it('downstream nodes do NOT run — the parent refuses instead of continuing', async () => { + const [, childRunId] = await startPair('gate_refuses'); + + await engine.resume(childRunId, { variables: { kind: 'vip' } }); + + expect(ran).toEqual(['child-work']); + expect(ran).not.toContain('downstream'); + }); + + it("the child's own caller is told the truth about the CHILD, unchanged", async () => { + // The bubble is best-effort at the engine layer and never rewrites + // what the child's resumer is told: this caller resumed the child, + // and the child refused. + const [, childRunId] = await startPair('gate_refuses'); + + const res = await engine.resume(childRunId, { variables: { kind: 'vip' } }); + + expect(res.success).toBe(true); + expect(res.status).toBe('refused'); + expect(res.refusalMessage).toBe(RENDERED_REFUSAL); + }); + + it("credits the child's #4354 rollup to the parent's awaiting step", async () => { + const [parentRunId, childRunId] = await startPair('gate_refuses'); + + await engine.resume(childRunId, { variables: { kind: 'vip' } }); + + const parentRow = await engine.getRun(parentRunId); + expect(parentRow?.status).toBe('refused'); + expect(parentRow?.summary?.nodes.find((n) => n.nodeId === 'call')).toMatchObject({ + selected: 3, acted: 2, + }); + }); + }); + + describe('the control, leg 2 — an allowing child still bubbles a COMPLETION', () => { + it('the parent completes through the up-bubble, walks on, and fires its toast', async () => { + const [parentRunId, childRunId] = await startPair('gate_allows'); + + const res = await engine.resume(childRunId, { variables: { kind: 'vip' } }); + + expect(res.success).toBe(true); + expect(res.status).toBeUndefined(); + expect(ran).toEqual(['child-work', 'downstream']); + expect(await engine.hasSuspendedRun(parentRunId)).toBe(false); + expect((await engine.getRun(parentRunId))?.status).toBe('completed'); + expect((await engine.getRun(parentRunId))?.refusalMessage).toBeUndefined(); + }); + }); +}); From b2203ba3b726f624eb4b84bd8aec3332a0a0b11c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 00:59:43 +0000 Subject: [PATCH 3/3] fix(service-automation): roll a resumed child's refusal up on both legs The delegated-resume block tested only `paused` / `!success`, so a child that paused and then refused fell through the ordinary success exit and the parent recorded `completed` with its downstream node run. `bubbleToParent` was called on the completion path alone, so a child resumed to a refusal left its parent parked in `listSuspendedRuns()` forever. Both legs now record the refusal and hand it to one throw site inside the resume traversal, past the consumption and before the traversal, so the run terminates through the existing `finishRefusedRun` chokepoint. Claude-Session: https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E Co-authored-by: Claude --- .../18714-resumed-leg-refusal-rollup.md | 27 +++++++++++++++++++ .../src/resumed-leg-refusal-rollup.test.ts | 11 ++++++++ 2 files changed, 38 insertions(+) create mode 100644 .changeset/18714-resumed-leg-refusal-rollup.md diff --git a/.changeset/18714-resumed-leg-refusal-rollup.md b/.changeset/18714-resumed-leg-refusal-rollup.md new file mode 100644 index 00000000000..a55f1a803d5 --- /dev/null +++ b/.changeset/18714-resumed-leg-refusal-rollup.md @@ -0,0 +1,27 @@ +--- +"@objectstack/service-automation": patch +--- + +fix(service-automation): a child that PAUSES and then refuses now rolls its refusal up on both resumed legs — the delegated resume and the up-bubble (#18714) + +**Clause-②: no** — nothing published moves. The two arms are added inside `AutomationEngine`'s private `resumeInternal` / `bubbleToParent`, and the one new type (`ChildRunRefusal`) is module-private, not barrel-exported. No schema key, no closed-set member, no export and no registry entry changes; `refused` has been a published terminal status since #15788 and no new status, code or `ERROR_CODE_LEDGER` entry is minted here. + +#18110 / #18555 gave the `subflow` and `map` executors an arm for `child.status === 'refused'`, and that arm reads the value `engine.execute` **returned** to them — so it covers exactly one shape: a child that runs straight through without pausing. A child that durably PAUSES first (a nested `approval` / `screen` / `wait`) never returns through that call at all. Its outcome reaches its parent on one of two **resumed** legs instead, and neither had an arm. Both pre-date #18110/#18555 and neither is a regression of it; that delivery named the two executors and matched its ruling exactly, and its own changeset filed this card for the remaining half. + +The two legs failed **differently**, so each gets its own arm and its own pin: + +- **Delegated resume** — `engine.resume(parentRunId)`, the path a screen-flow runner takes when it holds one stable run id and posts every wizard step to it. The delegation block tested only `paused` and `!success`; a refused child is neither, so it fell through the ordinary success exit. Measured: the parent answered `{ success: true, successMessage: … }`, its run row recorded **`completed`**, and the node downstream of the `subflow` **ran**. The refusal was lost **fail-open** — the identical shape #18110 closed on the synchronous leg. +- **Up-bubble** — `engine.resume(childRunId)`. `bubbleToParent` was called on the completion path only, so a child resumed to a refusal resolved exactly one of the two runs it is responsible for. Measured: the child row recorded `refused` correctly and the parent stayed **`paused`**, in `listSuspendedRuns()`, indefinitely. Nothing looks wrong; a run is **leaked**. + +What changed: + +- **One terminal shape, both legs.** Each leg records the child's refusal and hands it to a single throw site inside the resume's traversal `try`, which raises the engine's existing internal refusal signal — so the refusal leaves through the same `finishRefusedRun` chokepoint every other producer already uses. ⛔ Deliberately not a second terminal exit per leg: this file's history is a list of outcomes that became a function of which route a run took. +- **The throw site sits past the consumption and before the traversal.** The parent's own suspension is consumed exactly as it is on every other way a resume can end, so the terminal row and the pause can never disagree; and nothing downstream of the awaiting node runs. +- **The parent's terminal row reads `refused`**, carrying the child's already-rendered `refusalMessage` verbatim, and the parent's own `successMessage` stays silent. ⛔ Not `failed`: a refusal is not a failure — it must not consume retry budget, must not be routable by a `fault` edge and must not be counted in `nodes[].failures`. +- **The child's #4354 rollup (`selected` / `acted` / `unmeasuredEffect`) survives on both legs**, for the same reason it survives on the synchronous one: the refusal is raised after the awaiting step has been credited. A child that refused really can have written rows before it said no. +- **Chains of any depth resolve**, because the up-bubble arm resumes the parent for real — the parent consumes its pause, records its own terminal row and bubbles to *its* parent in turn, by the same induction completions already rely on. ⛔ Not a direct ancestor walk like the failure cascade's: that verb records ancestors `failed`, which is the wrong word here. +- **The child's own resumer is told exactly what it was told before** — the bubble is still best-effort at the engine layer and never rewrites the child's envelope. + +Unchanged: the synchronous leg (#18110/#18555), the region-containment refusal (#18881 — a different error type on a different path, which neither resume leg raises or consumes), the retryable delegated resume-bag codes (#14379), the terminal child-failure cascade, and the `RESUME_IN_PROGRESS` / `STORE_UNAVAILABLE` / stranded gradings on the bubble. + +⚠️ **Behavioural direction**: a run that previously finished green over a refusing paused child now terminates `refused`, and a parent that previously sat in `listSuspendedRuns()` forever is now resolved. Both are the authored outcome arriving where it never did; a composition that depended on the fail-open was depending on the defect. diff --git a/packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts b/packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts index b39706a975b..3e3196b2e0d 100644 --- a/packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts +++ b/packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts @@ -256,6 +256,12 @@ describe('#18714 — a resumed child run that REFUSES rolls up on both legs', () }); it('downstream nodes do NOT run — the parent refuses instead of continuing', async () => { + // ⚠️ Measured: this one does NOT redden when the up-bubble arm is + // ablated away — a parent that is never resumed also never walks + // on, so both the defect and the fix satisfy it. It is kept as the + // pin on the WRONG FIX: bubbling this refusal as if it were a + // completion resolves the parent and runs `downstream`. The three + // reddening leg-2 assertions are the ones next door. const [, childRunId] = await startPair('gate_refuses'); await engine.resume(childRunId, { variables: { kind: 'vip' } }); @@ -268,6 +274,11 @@ describe('#18714 — a resumed child run that REFUSES rolls up on both legs', () // The bubble is best-effort at the engine layer and never rewrites // what the child's resumer is told: this caller resumed the child, // and the child refused. + // + // ⚠️ An INVARIANCE pin, and measured green on both sides of the + // ablation on purpose — the defect answered the child's caller + // correctly too. What it holds is that adding the bubble did not + // move that answer, which is the half a fix here could break. const [, childRunId] = await startPair('gate_refuses'); const res = await engine.resume(childRunId, { variables: { kind: 'vip' } });