From 7edf37b441c6b7e5d3c05dc62ae3616f1140f11f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:15:36 +0000 Subject: [PATCH 1/4] fix(service-automation)!: refuse a parent-id resume of a map whose item run is still suspended A run parked on a `map` node, resumed by its own id, re-ran the map and advanced past the in-flight item: success, the parent completed, and the item child was left suspended. The resume gate now walks the linked-run chain once and, after the authority check, refuses a resume that reaches a `map:` link whose item child is still suspended, naming that child run id. Nothing is consumed. The engine's own up-bubble goes through resumeInternal and is unchanged, as is subflow delegation. The refusal code lives in one constant, MAP_ITEM_PENDING_REFUSAL_CODE. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxEzAw9N6UiWHC6Zp2 --- .changeset/27877-map-parent-resume-refusal.md | 19 ++ ...raded-suspended-run-load-log-cause.test.ts | 4 +- .../services/service-automation/src/engine.ts | 65 ++++-- .../src/map-parent-resume-refusal.test.ts | 187 ++++++++++++++++++ .../src/resume-authority-gate.test.ts | 18 +- .../service-automation/src/run-resume.ts | 69 ++++++- 6 files changed, 327 insertions(+), 35 deletions(-) create mode 100644 .changeset/27877-map-parent-resume-refusal.md create mode 100644 packages/services/service-automation/src/map-parent-resume-refusal.test.ts diff --git a/.changeset/27877-map-parent-resume-refusal.md b/.changeset/27877-map-parent-resume-refusal.md new file mode 100644 index 00000000000..7266f9e7a0c --- /dev/null +++ b/.changeset/27877-map-parent-resume-refusal.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-automation': patch +--- + +fix(service-automation)!: a resume of a run parked on a `map` node, addressed by the parent's run id while the in-flight item's child run is still suspended, is refused with a code naming the child run id, instead of completing the parent past an item that never ran (#27877) + +Clause-②: no (narrowing) — `resume(runId)` on a run whose resume chain reaches a `map` link with a still-suspended item child, which answered success before, is refused. Nothing is widened. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at `AutomationEngine.resume` (`@objectstack/service-automation`), served by the generic resume route (`POST /automation/:name/runs/:runId/resume`) and the MCP `resume_run` tool. + +- **Before.** A run parked on a `map` node, resumed by its own id while the item's child run was parked on a pause open to the generic route (a `screen`, a `wait`, any node declaring `resumeAuthority: 'any'`), re-ran the map node. The map had already counted the in-flight item as started, so it moved past it: the resume answered success, the parent's run row was `completed`, and the item child stayed in `listSuspendedRuns()` with nothing left to continue into. +- **Now.** That resume is refused with `RESUME_IN_PROGRESS` (409 on both doors), and the message names the item run id to resume instead. Nothing moves: the parent stays parked on the item, the map's progress is unchanged, and the item child stays resumable. The same holds when a `subflow` link leads down to such a map: the refusal names the item run of the deepest map link. +- **Unchanged.** Resuming the item run continues the map exactly as before, through the engine's own up-bubble. A parent parked on a `subflow` node still delegates the signal to its child. An item parked on a node only its owning service may resume still answers `PERMISSION_DENIED` (403) at the parent's id. A `map` whose item run is gone (cancelled, or never persisted) is not refused. +- **Who sees it.** A caller that resumes a batch flow by the parent run id it launched with, while an item is parked on a screen or a wait. + +**The remedy.** Resume the item run the refusal names (also listed by `listSuspendedRuns()`, the run whose context carries the parent's id); the parent continues when the map's last item completes. diff --git a/packages/services/service-automation/src/degraded-suspended-run-load-log-cause.test.ts b/packages/services/service-automation/src/degraded-suspended-run-load-log-cause.test.ts index 3656c135788..9f28ce6bfa3 100644 --- a/packages/services/service-automation/src/degraded-suspended-run-load-log-cause.test.ts +++ b/packages/services/service-automation/src/degraded-suspended-run-load-log-cause.test.ts @@ -33,7 +33,7 @@ // And it is live during boot, not only in theory: `plugin.ts` `start()` calls // `rearmSuspendedWaitTimers`, which calls `engine.resume(run.runId)` for every // overdue run, and the public `resume()` runs its authorization gate -// (`refuseGatedResume` → `resolveEffectiveSuspension`) through THIS degrading +// (`refuseGatedResume` → `resolveSuspensionChain`) through THIS degrading // reader before `resumeInternal` ever takes the strict one. // // ## The fix, and the one thing deliberately NOT changed @@ -356,7 +356,7 @@ describe('#6230 — one "store unreachable while resuming" incident, both record * unrelated finding. `resume()` walks BOTH catches around * `loadSuspendedRunStrict`: * - * gate → `refuseGatedResume` → `resolveEffectiveSuspension` → + * gate → `refuseGatedResume` → `resolveSuspensionChain` → * `loadSuspendedRun` → this seam, `warn` → **stdout** * then → `resumeInternal` → strict read → #5912, `error` → stderr * diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 421cbefbbec..b67c9300837 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -6373,6 +6373,13 @@ export class AutomationEngine implements IAutomationService { * (subflow delegation / up-bubble, `map` re-entry, wait-timer wake) go * through {@link resumeInternal} and are not re-gated — they continue work * some already-authorized call started. + * + * **A map whose item is still parked is refused, not advanced (#27877).** + * Resuming a run parked on a `map` node by ITS OWN id re-runs the map, + * which advances past the in-flight item — so a run whose item child is + * still suspended answers a coded refusal naming that child's run id, and + * nothing moves. Resuming the child is the door; the map continues when it + * completes. See {@link refuseGatedResume}. */ resume(runId: string, signal?: ResumeSignal): Promise { // The body lives in ./run-resume.ts (#25222). @@ -6398,8 +6405,18 @@ export class AutomationEngine implements IAutomationService { * chain and judges the node the signal lands on (subflow) or would advance * past (map) — see {@link LINKED_RUN_PREFIXES}. A run it cannot resolve at * all (unknown run id, nothing suspended, no resolvable node type) is left to - * `resumeInternal`, which reports the machine-state error — the gate only - * ever speaks to authorization. + * `resumeInternal`, which reports the machine-state error — the gate speaks + * to authorization, and to the one machine-state fact only its chain walk + * can see. + * + * **That fact is a skip (#27877).** When the chain passes a `map` link + * whose item child is still suspended, continuing the id the caller holds + * would re-run the map and advance it PAST that item without running it — + * the parent completes and the item's suspension is left behind, orphaned. + * Authority is judged first, so a service-owned item keeps its 403; an item + * open to this route is refused with a coded 409 naming the child run id to + * resume instead, before any state is touched. A `subflow` link is not a + * skip — the signal is delegated to the child — and is unchanged. * * **An UNDECLARED node type is refused, not deferred** (#5561 step two). It * used to be let through on the schema default's inherited `'any'`; a pause @@ -6416,7 +6433,7 @@ export class AutomationEngine implements IAutomationService { private refuseGatedResume(runId: string, signal?: ResumeSignal): Promise { // The body lives in ./run-resume.ts (#25222). const host: ThisParameterType = { - resolveEffectiveSuspension: (...a) => this.resolveEffectiveSuspension(...a), + resolveSuspensionChain: (...a) => this.resolveSuspensionChain(...a), resolveSuspendedNodeType: (...a) => this.resolveSuspendedNodeType(...a), resolveResumeAuthority: (...a) => this.resolveResumeAuthority(...a), resolveDeclaredResumeAuthority: (...a) => this.resolveDeclaredResumeAuthority(...a), @@ -6594,38 +6611,48 @@ export class AutomationEngine implements IAutomationService { * node is literally where it lands. * - `map:` — the signal is not delegated; the `map` node RE-RUNS, and since * `$mapState.started` was advanced past the in-flight item before the - * suspend, continuing here advances the map *past* the item whose child - * is still parked. Judging the parent's own `map` node (always + * suspend, continuing here would advance the map *past* the item whose + * child is still parked. Judging the parent's own `map` node (always * `resumeAuthority: 'any'`) let a raw resume skip a pending approval in a - * batch-approval flow — the gate has to read the item, not the loop. + * batch-approval flow — the gate has to read the item, not the loop. And + * since #27877 the gate does not let that advance happen at all: a + * resume that reaches a `map:` link whose child is still suspended is + * refused with a code naming the child run id, whatever the item's + * authority — so no parent completes past an item that never ran, and no + * item suspension is left orphaned. The engine's own up-bubble from the + * completed child goes through `resumeInternal` and is not gated. */ private static readonly LINKED_RUN_PREFIXES = ['subflow:', 'map:'] as const; /** * Follow the linked-run chain from `runId` to the suspension whose node - * actually governs a resume — see {@link LINKED_RUN_PREFIXES}. The deepest - * reachable suspension, not the id the caller holds, is what a resume - * really continues (or skips past). Returns `null` when nothing is - * suspended under that id; stops at the last resolvable link when a child - * row is gone (that run is where `resumeInternal` will continue). - */ - private async resolveEffectiveSuspension(runId: string): Promise { + * actually governs a resume — see {@link LINKED_RUN_PREFIXES}. Returns every + * suspension the walk loaded, the caller's own first: the LAST one, not the + * id the caller holds, is what a resume really continues (or skips past), + * and each `map:` link with a successor is an item still parked under it + * (#27877). Empty when nothing is suspended under that id; stops at the + * last resolvable link when a child row is gone (that run is where + * `resumeInternal` will continue). + */ + private async resolveSuspensionChain(runId: string): Promise { const seen = new Set(); + const chain: SuspendedRun[] = []; let run = await this.loadSuspendedRun(runId); for (let depth = 0; run && depth < AutomationEngine.MAX_SUSPENSION_CHAIN_DEPTH; depth++) { + chain.push(run); const correlation = run.correlation; const prefix = typeof correlation === 'string' ? AutomationEngine.LINKED_RUN_PREFIXES.find(p => correlation.startsWith(p)) : undefined; - if (!prefix) return run; + if (!prefix) return chain; const childRunId = correlation!.slice(prefix.length); - if (!childRunId || seen.has(childRunId)) return run; + if (!childRunId || seen.has(childRunId)) return chain; seen.add(childRunId); - const child = await this.loadSuspendedRun(childRunId); - if (!child) return run; - run = child; + run = await this.loadSuspendedRun(childRunId); } - return run; + // The depth bound stopped the walk on a run it loaded but never read. + if (run) chain.push(run); + return chain; } /** Read a suspended run from the hot cache, falling back to the durable diff --git a/packages/services/service-automation/src/map-parent-resume-refusal.test.ts b/packages/services/service-automation/src/map-parent-resume-refusal.test.ts new file mode 100644 index 00000000000..ff1ff8337e7 --- /dev/null +++ b/packages/services/service-automation/src/map-parent-resume-refusal.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A resume of a run parked on a `map` node, addressed by the PARENT's id while + * the in-flight item's child run is still suspended, is refused (#27877). + * + * The `map` node does not delegate a signal the way `subflow` does: on resume + * it RE-RUNS, and `$mapState.started` was advanced past the in-flight item + * before the suspend. So continuing the parent advanced the map past an item + * that never ran — measured on the in-process door: `success: true`, the + * parent's run row `completed`, and the item child left in + * `listSuspendedRuns()`, orphaned. The gate now refuses that resume with a code + * and a message naming the child run id to resume instead, and nothing moves. + * + * What stays as it was: resuming the item child (the door the refusal names) + * continues the map through the engine's own up-bubble, and a `subflow` + * parent-id resume — whose signal IS delegated to the child — still succeeds. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts'; +import { AutomationEngine } from './engine.js'; +import { registerMapNode } from './builtin/map-node.js'; +import { registerSubflowNode } from './builtin/subflow-node.js'; +import { MAP_ITEM_PENDING_REFUSAL_CODE } from './run-resume.js'; + +function silentLogger(): any { + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } }; +} +function pluginCtx(): any { + return { logger: silentLogger(), getService() { throw new Error('none'); } }; +} + +/** start → `open_pause` (raw-resumable, like `screen` / `wait`) → after → end. */ +function itemFlow(name: string) { + return { + name, + label: name, + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'pause', type: 'open_pause', label: 'Pause' }, + { id: 'after', type: 'after', label: 'After' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'pause' }, + { id: 'e2', source: 'pause', target: 'after' }, + { id: 'e3', source: 'after', target: 'end' }, + ], + }; +} + +/** start → `` → end, the `map` / `subflow` host. */ +function hostFlow(name: string, node: Record, variables?: unknown[]) { + return { + name, + label: name, + type: 'autolaunched', + ...(variables ? { variables } : {}), + nodes: [{ id: 'hs', type: 'start', label: 'Start' }, node, { id: 'he', type: 'end', label: 'End' }], + edges: [ + { id: 'h1', source: 'hs', target: node.id as string }, + { id: 'h2', source: node.id as string, target: 'he' }, + ], + }; +} + +const MAP_NODE = { + id: 'signoffs', type: 'map', label: 'Sign off each', + config: { collection: { dialect: 'cel', source: 'items' }, flowName: 'one_item', outputVariable: 'results' }, +}; + +describe('a parent-id resume of a map whose item is still parked (#27877)', () => { + let engine: AutomationEngine; + let downstream: string[]; + + beforeEach(() => { + downstream = []; + engine = new AutomationEngine(silentLogger()); + engine.registerNodeExecutor({ + type: 'open_pause', + descriptor: defineActionDescriptor({ + type: 'open_pause', version: '1.0.0', name: 'Open Pause', supportsPause: true, resumeAuthority: 'any', + }), + async execute() { return { success: true, suspend: true }; }, + }); + engine.registerNodeExecutor({ + type: 'after', + async execute(node) { downstream.push(node.id); return { success: true }; }, + }); + registerMapNode(engine, pluginCtx()); + registerSubflowNode(engine, pluginCtx()); + engine.registerFlow('one_item', itemFlow('one_item') as never); + engine.registerFlow('batch_flow', hostFlow('batch_flow', MAP_NODE, [{ name: 'items', type: 'list', isInput: true }]) as never); + }); + + const launch = () => engine.execute('batch_flow', { params: { items: [{ id: 't1' }, { id: 't2' }] } }); + const parked = (flowName: string) => engine.listSuspendedRuns().filter(r => r.flowName === flowName); + const mapState = (runId: string) => + (engine as any).suspendedRuns.get(runId)?.variables?.['signoffs.$mapState']; + + it('refuses with a code naming the item run, and nothing advances', async () => { + const paused = await launch(); + const [item1] = parked('one_item'); + expect(item1).toBeDefined(); + + const refused = await engine.resume(paused.runId!, {}); + + expect(refused.success).toBe(false); + expect(refused.code).toBe(MAP_ITEM_PENDING_REFUSAL_CODE); + expect(refused.error).toContain(`Resume run '${item1!.runId}' instead`); + // The parent is still parked on item 1, the map did not move, and the item + // is still resumable — nothing was consumed and nothing ran. + expect(parked('batch_flow').map(r => r.runId)).toEqual([paused.runId]); + expect(mapState(paused.runId!)).toEqual({ started: 1, results: [] }); + expect(parked('one_item').map(r => r.runId)).toEqual([item1!.runId]); + expect(downstream).toEqual([]); + expect((await engine.listRuns('batch_flow')).map(r => r.status)).not.toContain('completed'); + }); + + it('is refused whatever the signal carries — a skip is not a question of authority', async () => { + const paused = await launch(); + + const refused = await engine.resume(paused.runId!, { [RESUME_AUTHORITY_SERVICE]: true }); + + expect(refused.code).toBe(MAP_ITEM_PENDING_REFUSAL_CODE); + expect(mapState(paused.runId!)).toEqual({ started: 1, results: [] }); + expect(parked('one_item')).toHaveLength(1); + }); + + it('resuming the named item run still works, and the map then continues to completion', async () => { + const paused = await launch(); + const [item1] = parked('one_item'); + + const first = await engine.resume(item1!.runId, {}); + expect(first.success).toBe(true); + // Item 1 ran through; the map recorded it and parked on item 2. + expect(downstream).toEqual(['after']); + expect(mapState(paused.runId!)).toEqual({ started: 2, results: [expect.anything()] }); + const [item2] = parked('one_item'); + expect(item2!.runId).not.toBe(item1!.runId); + + const second = await engine.resume(item2!.runId, {}); + expect(second.success).toBe(true); + expect(downstream).toEqual(['after', 'after']); + expect(engine.listSuspendedRuns()).toEqual([]); + const [row] = await engine.listRuns('batch_flow'); + expect(row?.status).toBe('completed'); + }); + + it('follows a subflow link down to a map parked on its item, and names that item', async () => { + engine.registerFlow('outer_flow', hostFlow('outer_flow', { + id: 'call', type: 'subflow', label: 'Call batch', config: { flowName: 'batch_flow', input: { items: [{ id: 't1' }] } }, + }) as never); + const outer = await engine.execute('outer_flow'); + expect(outer.status).toBe('paused'); + const [batch] = parked('batch_flow'); + const [item1] = parked('one_item'); + expect(batch && item1).toBeTruthy(); + + const refused = await engine.resume(outer.runId!, {}); + + expect(refused.code).toBe(MAP_ITEM_PENDING_REFUSAL_CODE); + expect(refused.error).toContain(`waiting on run '${batch!.runId}'`); + expect(refused.error).toContain(`Resume run '${item1!.runId}' instead`); + expect(engine.listSuspendedRuns()).toHaveLength(3); + expect(downstream).toEqual([]); + }); + + it('leaves a subflow parent-id resume unchanged: the signal is delegated to the child', async () => { + engine.registerFlow('child_flow', itemFlow('child_flow') as never); + engine.registerFlow('parent_flow', hostFlow('parent_flow', { + id: 'call', type: 'subflow', label: 'Call child', config: { flowName: 'child_flow' }, + }) as never); + const paused = await engine.execute('parent_flow'); + expect(paused.status).toBe('paused'); + + const resumed = await engine.resume(paused.runId!, {}); + + expect(resumed.code).toBeUndefined(); + expect(resumed.success).toBe(true); + expect(downstream).toEqual(['after']); + expect(engine.listSuspendedRuns()).toEqual([]); + }); +}); diff --git a/packages/services/service-automation/src/resume-authority-gate.test.ts b/packages/services/service-automation/src/resume-authority-gate.test.ts index 176cd780619..85788cd077b 100644 --- a/packages/services/service-automation/src/resume-authority-gate.test.ts +++ b/packages/services/service-automation/src/resume-authority-gate.test.ts @@ -32,6 +32,7 @@ import { AutomationEngine } from './engine.js'; import { InMemorySuspendedRunStore } from './suspended-run-store.js'; import { registerSubflowNode } from './builtin/subflow-node.js'; import { registerMapNode } from './builtin/map-node.js'; +import { MAP_ITEM_PENDING_REFUSAL_CODE } from './run-resume.js'; function silentLogger(): any { return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } }; @@ -432,14 +433,17 @@ describe('resume authorization gate (#3801)', () => { expect(downstream).toEqual(['after']); // item 1 really ran through }); - it('leaves a map over ungated items resumable', async () => { + it('refuses the MAP PARENT over an ungated item too — the item is the door (#27877)', async () => { + // Ungated, the authority check lets it through, and continuing the map + // would advance past the parked item. The refusal and the item door are + // pinned in `map-parent-resume-refusal.test.ts`. registerBatch(engine, 'open_pause'); const paused = await launch(engine); - const resumed = await engine.resume(paused.runId!, {}); + const refused = await engine.resume(paused.runId!, {}); - expect(resumed.code).toBeUndefined(); - expect(resumed.success).toBe(true); + expect(refused.code).toBe(MAP_ITEM_PENDING_REFUSAL_CODE); + expect(mapState(engine, paused.runId!)).toEqual({ started: 1, results: [] }); }); // ── the signal may not write the engine's own variables ──────────── @@ -458,6 +462,12 @@ describe('resume authorization gate (#3801)', () => { ])('refuses a signal that forges the map item handoff via `%s`', async (_field, signal) => { registerBatch(engine, 'open_pause'); // ungated items — the gate lets the parent through const paused = await launch(engine); + // …once its item is no longer parked: with the item live the gate refuses + // the parent before the signal is read (#27877). A cancelled item leaves + // the parent's `map:` link with nothing behind it, which is the shape + // that still reaches the handoff guard. + const item1 = engine.listSuspendedRuns().find(r => r.flowName === 'one_item')!; + expect(await engine.cancelRun(item1.runId)).toBe(true); const refused = await engine.resume(paused.runId!, signal as any); diff --git a/packages/services/service-automation/src/run-resume.ts b/packages/services/service-automation/src/run-resume.ts index 51da355caca..067d1647d14 100644 --- a/packages/services/service-automation/src/run-resume.ts +++ b/packages/services/service-automation/src/run-resume.ts @@ -70,7 +70,7 @@ export interface RunResumeHost { readonly recordExpander: AutomationEngine['recordExpander']; readonly refuseGatedResume: AutomationEngine['refuseGatedResume']; readonly resumeInternal: AutomationEngine['resumeInternal']; - readonly resolveEffectiveSuspension: AutomationEngine['resolveEffectiveSuspension']; + readonly resolveSuspensionChain: AutomationEngine['resolveSuspensionChain']; readonly resolveSuspendedNodeType: AutomationEngine['resolveSuspendedNodeType']; readonly resolveResumeAuthority: AutomationEngine['resolveResumeAuthority']; readonly resolveDeclaredResumeAuthority: AutomationEngine['resolveDeclaredResumeAuthority']; @@ -391,17 +391,29 @@ export async function resume(this: Pick, runId: string, signal?: ResumeSignal): Promise { - const run = await this.resolveEffectiveSuspension(runId); +/** + * [#27877] The code a resume answers when it would advance a `map` past an + * item whose child run is still suspended. The ONE spelling of it — the + * refusal below and its pins read this constant, so the code is a one-line + * change wherever the vocabulary question lands. + */ +export const MAP_ITEM_PENDING_REFUSAL_CODE: NonNullable = 'RESUME_IN_PROGRESS'; + +/** The body of {@link AutomationEngine.refuseGatedResume}; that method's docblock is its contract. */ +export async function refuseGatedResume(this: Pick, runId: string, signal?: ResumeSignal): Promise { + const chain = await this.resolveSuspensionChain(runId); + const run = chain[chain.length - 1]; if (!run) return null; const nodeType = this.resolveSuspendedNodeType(run); - if (!nodeType) return null; - if (this.resolveResumeAuthority(nodeType) !== 'service') return null; - // The owning service stamped the signal — this resume IS the recorded - // decision's tail, not a way around it. - if (signal?.[RESUME_AUTHORITY_SERVICE]) return null; + // Authority first: an item only its owning service may continue keeps + // answering 403 at every id that reaches it, the child's own included — + // naming the child here would send the caller to a door that refuses too. + // The owning service's stamp IS the recorded decision's tail, not a way + // around it. + if (!nodeType || this.resolveResumeAuthority(nodeType) !== 'service' || signal?.[RESUME_AUTHORITY_SERVICE]) { + return refuseMapItemSkip.call(this, runId, chain); + } // Refusing. Which of the two reasons? A second registry walk, on the // refusal path only, so the message can tell a node that deliberately @@ -441,6 +453,43 @@ export async function refuseGatedResume(this: Pick, runId: string, chain: readonly SuspendedRun[]): AutomationResult | null { + for (let i = chain.length - 2; i >= 0; i--) { + const link = chain[i]!; + if (typeof link.correlation !== 'string' || !link.correlation.startsWith('map:')) continue; + const item = chain[i + 1]!; + const at = link.runId === runId ? `'${link.nodeId}'` : `'${link.nodeId}' (linked run '${link.runId}')`; + this.logger.warn( + `[automation] refused resume of run '${runId}': parked on map node ${at}, whose item run ` + + `'${item.runId}' is still suspended — resuming here would advance the map past that item`, + ); + return { + success: false, + code: MAP_ITEM_PENDING_REFUSAL_CODE, + error: + `Run '${runId}' is ${link.runId === runId ? '' : `waiting on run '${link.runId}', which is `}paused at ` + + `map node '${link.nodeId}' on item run '${item.runId}', which is still suspended — resuming here ` + + `would advance the map past that item without running it. Resume run '${item.runId}' instead; ` + + `the map continues when it completes`, + }; + } + return null; +} + /** The body of {@link AutomationEngine.refuseUndeclaredSuspension}, moved unedited; that method's docblock is its contract. */ export function refuseUndeclaredSuspension(this: Pick, nodeType: string): NodeExecutionResult | null { const descriptor = this.resolveCanonicalDescriptor(nodeType); @@ -483,7 +532,7 @@ export async function loadSuspendedRun(this: Pick Date: Fri, 30 Oct 2026 05:15:40 +0000 Subject: [PATCH 2/4] feat(spec,runtime,service-automation)!: RESUME_CHILD_PENDING is the code of the map parent-id resume refusal The refusal answered the RESUME_IN_PROGRESS placeholder, which tells a caller the work is already being done. It now answers its own code: a new member of the AutomationResult.code union, the TriggerFlowResponseSchema code enum and the ADR-0112 ledger row under service-automation, served 409 by the runtime resume door's RESUME_REFUSAL_ROWS on REST and MCP alike. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxEzAw9N6UiWHC6Zp2 --- .changeset/27877-map-parent-resume-refusal.md | 11 +++++++---- .../domains/automation-resume-refusal-codes.test.ts | 3 ++- packages/runtime/src/domains/automation.ts | 7 ++++++- packages/runtime/src/mcp-resume-run.test.ts | 1 + .../services/service-automation/src/run-resume.ts | 13 +++++++++---- packages/spec/src/api/automation-api.zod.ts | 1 + packages/spec/src/api/error-code-ledger.zod.ts | 1 + packages/spec/src/contracts/automation-service.ts | 11 ++++++++++- 8 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.changeset/27877-map-parent-resume-refusal.md b/.changeset/27877-map-parent-resume-refusal.md index 7266f9e7a0c..8b4207a87b1 100644 --- a/.changeset/27877-map-parent-resume-refusal.md +++ b/.changeset/27877-map-parent-resume-refusal.md @@ -1,19 +1,22 @@ --- '@objectstack/service-automation': patch +'@objectstack/spec': patch +'@objectstack/runtime': patch --- fix(service-automation)!: a resume of a run parked on a `map` node, addressed by the parent's run id while the in-flight item's child run is still suspended, is refused with a code naming the child run id, instead of completing the parent past an item that never ran (#27877) -Clause-②: no (narrowing) — `resume(runId)` on a run whose resume chain reaches a `map` link with a still-suspended item child, which answered success before, is refused. Nothing is widened. +Clause-②: yes (narrowing) — `resume(runId)` on a run whose resume chain reaches a `map` link with a still-suspended item child, which answered success before, is refused, with a new wire code `RESUME_CHILD_PENDING` (a new member of the `AutomationResult.code` union, the `TriggerFlowResponseSchema` `code` enum and the ADR-0112 ledger). Metadata-protocol: none - + -**BREAKING** accept-set narrowing at `AutomationEngine.resume` (`@objectstack/service-automation`), served by the generic resume route (`POST /automation/:name/runs/:runId/resume`) and the MCP `resume_run` tool. +**BREAKING** accept-set narrowing at `AutomationEngine.resume` (`@objectstack/service-automation`), served by the generic resume route (`POST /automation/:name/runs/:runId/resume`) and the MCP `resume_run` tool (`@objectstack/runtime`), with its code added to the contract (`@objectstack/spec`). - **Before.** A run parked on a `map` node, resumed by its own id while the item's child run was parked on a pause open to the generic route (a `screen`, a `wait`, any node declaring `resumeAuthority: 'any'`), re-ran the map node. The map had already counted the in-flight item as started, so it moved past it: the resume answered success, the parent's run row was `completed`, and the item child stayed in `listSuspendedRuns()` with nothing left to continue into. -- **Now.** That resume is refused with `RESUME_IN_PROGRESS` (409 on both doors), and the message names the item run id to resume instead. Nothing moves: the parent stays parked on the item, the map's progress is unchanged, and the item child stays resumable. The same holds when a `subflow` link leads down to such a map: the refusal names the item run of the deepest map link. +- **Now.** That resume is refused with the new code `RESUME_CHILD_PENDING` (409 on both doors), and the message names the item run id to resume instead. Nothing moves: the parent stays parked on the item, the map's progress is unchanged, and the item child stays resumable. The same holds when a `subflow` link leads down to such a map: the refusal names the item run of the deepest map link. - **Unchanged.** Resuming the item run continues the map exactly as before, through the engine's own up-bubble. A parent parked on a `subflow` node still delegates the signal to its child. An item parked on a node only its owning service may resume still answers `PERMISSION_DENIED` (403) at the parent's id. A `map` whose item run is gone (cancelled, or never persisted) is not refused. +- **New code.** `RESUME_CHILD_PENDING` joins `AutomationResult.code`, `TriggerFlowResponseSchema.data.code` and the error-code ledger under `@objectstack/service-automation`; the runtime resume door maps it to 409. It is not `RESUME_IN_PROGRESS`: that one is benign and needs nothing from the caller, this one is answered only by resuming a different run id. A client that switches exhaustively over the code union gains one member. - **Who sees it.** A caller that resumes a batch flow by the parent run id it launched with, while an item is parked on a screen or a wait. **The remedy.** Resume the item run the refusal names (also listed by `listSuspendedRuns()`, the run whose context carries the parent's id); the parent continues when the map's last item completes. diff --git a/packages/runtime/src/domains/automation-resume-refusal-codes.test.ts b/packages/runtime/src/domains/automation-resume-refusal-codes.test.ts index 428d807e7db..0858c572158 100644 --- a/packages/runtime/src/domains/automation-resume-refusal-codes.test.ts +++ b/packages/runtime/src/domains/automation-resume-refusal-codes.test.ts @@ -58,13 +58,14 @@ function makeDispatcher(resumeResult: AutomationResult) { const ROWS = [...RESUME_REFUSAL_ROWS].map(([code, row]) => [code, row.status] as const); describe('#21724 — the resume-refusal rows', () => { - it('the table is the six engine refusals the contract publishes', () => { + it('the table is the seven engine refusals the contract publishes', () => { // A floor, so an emptied or truncated table cannot pass the per-row // pins below vacuously. expect(ROWS.map(([code]) => code).sort()).toEqual([ 'INVALID_SCREEN_INPUT', 'INVALID_SIGNAL', 'PERMISSION_DENIED', + 'RESUME_CHILD_PENDING', 'RESUME_IN_PROGRESS', 'RUN_NOT_FOUND', 'STORE_UNAVAILABLE', diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index e6d914a540a..87963f89fea 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -2612,6 +2612,7 @@ export const RESUME_REFUSAL_ROWS: ReadonlyMap = 'RESUME_IN_PROGRESS'; +export const MAP_ITEM_PENDING_REFUSAL_CODE = 'RESUME_CHILD_PENDING' satisfies NonNullable; /** The body of {@link AutomationEngine.refuseGatedResume}; that method's docblock is its contract. */ export async function refuseGatedResume(this: Pick, runId: string, signal?: ResumeSignal): Promise { diff --git a/packages/spec/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts index 791e5fb0e64..6d1f4804fcc 100644 --- a/packages/spec/src/api/automation-api.zod.ts +++ b/packages/spec/src/api/automation-api.zod.ts @@ -408,6 +408,7 @@ export const TriggerFlowResponseSchema = lazySchema(() => BaseResponseSchema.ext 'RUN_NOT_FOUND', 'STORE_UNAVAILABLE', 'RESUME_IN_PROGRESS', + 'RESUME_CHILD_PENDING', 'INVALID_SCREEN_INPUT', 'FLOW_DISABLED', 'FLOW_NO_START_NODE', diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 076142dcff8..c1a6a563db7 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1323,6 +1323,7 @@ export const ERROR_CODE_LEDGER = { // line — no HTTP door; registered under `@objectstack/metadata-protocol` // too, so this row is provenance, not identity. 'NOT_OVERRIDABLE', + 'RESUME_CHILD_PENDING', // [#27877] parent-id resume of a map whose item child run is still suspended (409; resume the named child) 'RESUME_IN_PROGRESS', // duplicate resume refused while the first is running 'RUN_NOT_FOUND', // no suspension for this run id — unresumable for good 'STORE_UNAVAILABLE', // durable suspended-run store unreadable — existence unknown diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 9ce81313967..2d27992419e 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -522,6 +522,15 @@ export interface AutomationResult { * running; this duplicate was refused so side effects cannot run twice. * A transport maps it to **409**. The other resume is doing the work, * so callers should treat it as benign. + * - `'RESUME_CHILD_PENDING'` — the run is parked on a `map` node (directly, + * or through the run a `subflow` node waits on) whose in-flight item's + * child run is still suspended, and the resume addressed the PARENT. + * Continuing it would advance the map past an item that never ran, so + * nothing moves and `error` names the child run id to resume instead; the + * map continues when that child completes (#27877). A transport maps it + * to **409**. Distinct from `'RESUME_IN_PROGRESS'` on purpose: that one is + * benign and needs nothing from the caller, this one is answered only by + * resuming a different run id. * - `'INVALID_SCREEN_INPUT'` — the run is parked on a `screen` node and * the submitted bag violates that screen's declared field contract: a * `required` field the caller WAS asked for is missing, or a key the @@ -579,7 +588,7 @@ export interface AutomationResult { * measured need — and by the spec seat — rather than minted at a call * site. */ - code?: 'PERMISSION_DENIED' | 'INVALID_SIGNAL' | 'RUN_NOT_FOUND' | 'STORE_UNAVAILABLE' | 'RESUME_IN_PROGRESS' | 'INVALID_SCREEN_INPUT' | 'FLOW_DISABLED' | 'FLOW_NO_START_NODE' | 'FLOW_INPUT_SCHEMA_INVALID'; + code?: 'PERMISSION_DENIED' | 'INVALID_SIGNAL' | 'RUN_NOT_FOUND' | 'STORE_UNAVAILABLE' | 'RESUME_IN_PROGRESS' | 'RESUME_CHILD_PENDING' | 'INVALID_SCREEN_INPUT' | 'FLOW_DISABLED' | 'FLOW_NO_START_NODE' | 'FLOW_INPUT_SCHEMA_INVALID'; /** * Lifecycle status. `'paused'` means the run suspended at a node (e.g. * an Approval node awaiting a human decision, ADR-0019) and can be From 2bc14f4a450c05497e6254e85db5b61765925e56 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:22:53 +0000 Subject: [PATCH 3/4] chore(spec): regenerate the reference pages for RESUME_CHILD_PENDING Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxEzAw9N6UiWHC6Zp2 --- content/docs/references/api/automation-api.mdx | 3 ++- content/docs/references/api/contract.mdx | 3 ++- content/docs/references/api/error-code-ledger.mdx | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index b24a6e7d9fd..2dfd5c4947a 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -347,7 +347,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +352 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +353 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | | **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds | ### Allowed Values: `FlowInnerRefusal.code` @@ -631,6 +631,7 @@ const result = AutomationApiErrorCode.parse(data); * `REQUEST_NOT_FOUND` * `RESEED_NO_ROWS` * `RESEED_SKIPPED` +* `RESUME_CHILD_PENDING` * `RESUME_FAILED` * `RESUME_IN_PROGRESS` * `RESUME_TARGET_LOST` diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index ba7e11435dc..2362a57bb8d 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +352 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +353 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -319,6 +319,7 @@ const result = ApiErrorSchema.parse(data); * `REQUEST_NOT_FOUND` * `RESEED_NO_ROWS` * `RESEED_SKIPPED` +* `RESUME_CHILD_PENDING` * `RESUME_FAILED` * `RESUME_IN_PROGRESS` * `RESUME_TARGET_LOST` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index f12b6cf2292..4101142258b 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -486,6 +486,7 @@ const result = ErrorCode.parse(data); * `REQUEST_NOT_FOUND` * `RESEED_NO_ROWS` * `RESEED_SKIPPED` +* `RESUME_CHILD_PENDING` * `RESUME_FAILED` * `RESUME_IN_PROGRESS` * `RESUME_TARGET_LOST` From 082f766bf69f702411796717955551f9db58aeb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:27:26 +0000 Subject: [PATCH 4/4] fix(service-automation): spell the map refusal code constant in the form the dispatcher vocabulary guard resolves Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxEzAw9N6UiWHC6Zp2 --- packages/services/service-automation/src/run-resume.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-automation/src/run-resume.ts b/packages/services/service-automation/src/run-resume.ts index 090064717a3..38a9551832c 100644 --- a/packages/services/service-automation/src/run-resume.ts +++ b/packages/services/service-automation/src/run-resume.ts @@ -402,7 +402,7 @@ export async function resume(this: Pick; +export const MAP_ITEM_PENDING_REFUSAL_CODE: NonNullable = 'RESUME_CHILD_PENDING'; /** The body of {@link AutomationEngine.refuseGatedResume}; that method's docblock is its contract. */ export async function refuseGatedResume(this: Pick, runId: string, signal?: ResumeSignal): Promise {