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/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 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..3e3196b2e0d --- /dev/null +++ b/packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts @@ -0,0 +1,318 @@ +// 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 () => { + // ⚠️ 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' } }); + + 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. + // + // ⚠️ 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' } }); + + 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(); + }); + }); +});