Skip to content

Commit 4793aa4

Browse files
fix(service-automation)!: a parent-id resume of a map whose item run is still suspended is refused, naming the item run (#27951)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DF7fjxkB5Bbtyuwr1fLzN4Y)_ domain:server#4 · os-elon-musk · session_016WV4cxEzAw9N6UiWHC6Zp2 Fixes #27877 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no authorable metadata changes; a resume the engine accepted is refused ## 维护者速读 - 改了什么:批量流程停在 `map` 节点、某一项的子运行还挂起时,用父运行 id 续跑现在被拒绝(带错误码),提示改续跑那一项的子运行 id,什么都不推进。 - 为什么改:以前这样续跑会让 `map` 直接跳过那一项:父运行记为完成,那一项根本没跑,它的挂起记录被遗弃。分诊裁定这是静默跳过,必须拒绝。 - 风险与回滚:收窄(BREAKING):以前答成功的父 id 续跑现在被拒。续跑子运行、`subflow` 委派、服务专属节点的 403 都不变。拒绝码暂用 `RESUME_IN_PROGRESS` 占位,是否换新码等分诊答复(卡评论 6514753022)。回滚即还原这一个 commit。 - 先看:`src/run-resume.ts`(`refuseGatedResume` 与新的 `refuseMapItemSkip`)、`src/engine.ts`(`resolveSuspensionChain` 与 `LINKED_RUN_PREFIXES` 文档块)、`src/map-parent-resume-refusal.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run src/map-parent-resume-refusal.test.ts src/resume-authority-gate.test.ts`,预期 27 个测试全绿。 - 需要你做的:无,席位落地(先等分诊定拒绝码)。 ## Measured at the in-process door (`engine.resume`) A run paused at a `map` node (correlation `map:CHILD`), resumed by the **parent** run id, with the item child parked on a node declaring `resumeAuthority: 'any'`: | | Before (`origin/main` e41642d) | After (7edf37b) | |:--|:--|:--| | answer | `success: true`, no code | `success: false`, code `RESUME_IN_PROGRESS` (see *Open question*), message names the item run id: "Resume run 'ITEM' instead" | | parent run row | `completed` | no terminal row; parent still suspended | | map progress | moved past the item | unchanged (`started: 1, results: []`) | | item child | still in `listSuspendedRuns()`, orphaned | still suspended and resumable | Measured before the fix with a scratch test (not committed): `{"res":{"success":true},"after":["one_item:child"],"parentRow":["completed"]}`. ## What changes - **The resume gate (`refuseGatedResume`, `run-resume.ts`)** walks the linked-run chain once (`resolveSuspensionChain`, which replaces `resolveEffectiveSuspension` and returns every suspension it loaded, the caller's first). Authority is judged first, so an item only its owning service may resume keeps its `PERMISSION_DENIED` (403). Otherwise `refuseMapItemSkip` refuses when the chain holds a `map:` link whose item child is still suspended, naming the item run of the DEEPEST such link, before any state is touched. It refuses whatever the signal carries: a skip is not a question of authority. - **The engine's own continuations are untouched.** The item's up-bubble goes through `resumeInternal`, never `resume`, so resuming the item run continues the map exactly as before. A `subflow` link is not refused: its signal is delegated to the child. - **A `map:` link whose item row is gone** (cancelled, or never persisted) is not refused; there is nothing parked to skip. That shape is left as it was. - **`MAP_ITEM_PENDING_REFUSAL_CODE`** in `run-resume.ts` (module export, not on the package entry) is the one spelling of the code; the refusal and its pins read it. - **Docblocks:** `LINKED_RUN_PREFIXES`, `resume`, `refuseGatedResume` and the chain walk now state the refusal. Two comments in `degraded-suspended-run-load-log-cause.test.ts` follow the rename. - **Changeset:** `@objectstack/service-automation` patch, BREAKING accept-set narrowing, ADR-0087 `not-required (no-migration-prescription)`. ## Open question (asked on the card, not decided here) No existing code fits a coded 409. `AutomationResult.code` is a closed `packages/spec` union, and the runtime door's `RESUME_REFUSAL_ROWS` maps exactly one resume code to 409: `RESUME_IN_PROGRESS`, documented as a benign concurrent duplicate. The order forbids a `packages/spec` edit, so the code is a placeholder in one constant while the seat's route question (card comment 6514753022, option B: a new code across `packages/spec` + `packages/runtime`) is open. If it is ruled B, the constant, the changeset's `Now.` line and this table's code cell change; nothing else does. ## Tests `src/map-parent-resume-refusal.test.ts` (new, 5): 1. Parent-id resume refused with the code and the item run id; the parent, the map state and the item are unchanged, nothing ran, no `completed` row. 2. Refused even with the owning-service stamp on the signal. 3. Resuming the named item run works and the map continues to completion (two items, parent `completed`, nothing left suspended). 4. A `subflow` parent over a parked `map`: refused at the top id, naming the map's item run. 5. A `subflow` parent-id resume is unchanged (delegated, completes). `src/resume-authority-gate.test.ts` (changed, 3): "leaves a map over ungated items resumable" pinned the narrowed behaviour and now asserts the refusal. The two map-handoff forgery cases cancel the item first, so the parent's `map:` link has nothing behind it and the `INVALID_SIGNAL` guard is still the answer they pin. <details><summary>Ablation through scripts/ablation-replace.mjs (fix committed first)</summary> ``` ABLATE run-resume.ts anchor " for (let i = chain.length - 2; i >= 0; i--) {" x1 -> x0, blob 067d164 -> 8cef8f91 × refuses with a code naming the item run, and nothing advances × is refused whatever the signal carries — a skip is not a question of authority × follows a subflow link down to a map parked on its item, and names that item × refuses the MAP PARENT over an ungated item too — the item is the door (#27877) Tests 4 failed | 23 passed (27) ok restored: blob == HEAD (067d164) and `git diff HEAD` is empty ``` The item-door and `subflow`-unchanged pins stay green under the ablation, as preservation pins should. </details> ## Verification - `@objectstack/service-automation` on 7edf37b: vitest 219 files / 2808 tests green; `tsc --noEmit` and `check:test-typecheck` green. - `dispatch-gates.mjs --commands` on 7edf37b: 62 derived; 61 exit 0 (`check-plugin-teardown-shape --self-test` after fetching its pinned commit), 1 NOT MEASURED (`check:dual-build-cjs-loads`, exit 3, needs every package's dist); `--ran` reconcile: 62 accounted, 0 unrun. `pnpm lint` exit 0. - Full dogfood battery: owed on the final head (the narrowing arm); reported on the card once it has run. ## Acceptance notes - The refusal message names the run id only in `error` text; `AutomationResult` has no structured member for it, and adding one is a spec change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cxEzAw9N6UiWHC6Zp2 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2e955dd commit 4793aa4

15 files changed

Lines changed: 361 additions & 40 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@objectstack/spec': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
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)
8+
9+
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).
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is added, removed, renamed or re-shaped: the one protocol change is a new member of a response code enum, which no metadata author writes. A resume the engine accepted is refused before any state is touched, so there is no stored or authored shape for a migration to rewrite and no tombstone. -->
13+
14+
**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`).
15+
16+
- **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.
17+
- **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.
18+
- **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.
19+
- **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.
20+
- **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.
21+
22+
**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.

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ const result = AutomationApiErrorCode.parse(data);
347347

348348
| Property | Type | Required | Description |
349349
| :--- | :--- | :--- | :--- |
350-
| **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 |
350+
| **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 |
351351
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
352352

353353
### Allowed Values: `FlowInnerRefusal.code`
@@ -631,6 +631,7 @@ const result = AutomationApiErrorCode.parse(data);
631631
* `REQUEST_NOT_FOUND`
632632
* `RESEED_NO_ROWS`
633633
* `RESEED_SKIPPED`
634+
* `RESUME_CHILD_PENDING`
634635
* `RESUME_FAILED`
635636
* `RESUME_IN_PROGRESS`
636637
* `RESUME_TARGET_LOST`

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **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) |
31+
| **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) |
3232
| **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) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **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);
319319
* `REQUEST_NOT_FOUND`
320320
* `RESEED_NO_ROWS`
321321
* `RESEED_SKIPPED`
322+
* `RESUME_CHILD_PENDING`
322323
* `RESUME_FAILED`
323324
* `RESUME_IN_PROGRESS`
324325
* `RESUME_TARGET_LOST`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,7 @@ const result = ErrorCode.parse(data);
486486
* `REQUEST_NOT_FOUND`
487487
* `RESEED_NO_ROWS`
488488
* `RESEED_SKIPPED`
489+
* `RESUME_CHILD_PENDING`
489490
* `RESUME_FAILED`
490491
* `RESUME_IN_PROGRESS`
491492
* `RESUME_TARGET_LOST`

‎packages/runtime/src/domains/automation-resume-refusal-codes.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,14 @@ function makeDispatcher(resumeResult: AutomationResult) {
5858
const ROWS = [...RESUME_REFUSAL_ROWS].map(([code, row]) => [code, row.status] as const);
5959

6060
describe('#21724 — the resume-refusal rows', () => {
61-
it('the table is the six engine refusals the contract publishes', () => {
61+
it('the table is the seven engine refusals the contract publishes', () => {
6262
// A floor, so an emptied or truncated table cannot pass the per-row
6363
// pins below vacuously.
6464
expect(ROWS.map(([code]) => code).sort()).toEqual([
6565
'INVALID_SCREEN_INPUT',
6666
'INVALID_SIGNAL',
6767
'PERMISSION_DENIED',
68+
'RESUME_CHILD_PENDING',
6869
'RESUME_IN_PROGRESS',
6970
'RUN_NOT_FOUND',
7071
'STORE_UNAVAILABLE',

‎packages/runtime/src/domains/automation.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2612,6 +2612,7 @@ export const RESUME_REFUSAL_ROWS: ReadonlyMap<string, { status: number; fallback
26122612
['RUN_NOT_FOUND', { status: 404, fallback: 'No such suspended run' }],
26132613
['STORE_UNAVAILABLE', { status: 503, fallback: 'Suspended-run store unavailable' }],
26142614
['RESUME_IN_PROGRESS', { status: 409, fallback: 'Run is already being resumed' }],
2615+
['RESUME_CHILD_PENDING', { status: 409, fallback: 'Resume the pending child run instead' }],
26152616
]);
26162617

26172618
/**
@@ -2625,7 +2626,7 @@ export const RESUME_REFUSAL_ROWS: ReadonlyMap<string, { status: number; fallback
26252626
* message on either door. This used to be inline in the REST arm. It moved here
26262627
* unchanged, and the arm's wire answers are byte-identical.
26272628
*
2628-
* The six coded rows are REFUSALS the engine made BEFORE consuming the
2629+
* The seven coded rows are REFUSALS the engine made BEFORE consuming the
26292630
* suspension, so the run is still parked and the caller can retry (the codes
26302631
* and why each has its status are listed at the REST arm).
26312632
*
@@ -3486,6 +3487,10 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
34863487
// existence is unknown; the same call is expected
34873488
// to work once it recovers (#4420)
34883489
// RESUME_IN_PROGRESS → 409, a concurrent resume already has this run
3490+
// RESUME_CHILD_PENDING → 409, the run waits on a `map` item whose
3491+
// child run is still suspended; the engine's
3492+
// message names that child run id to resume
3493+
// instead, and nothing moved (#27877)
34893494
// A result with NO code and `success: false` consumed its pause and
34903495
// ran: 400 `FLOW_FAILED` (#8684), whose details carry the engine's
34913496
// own verdict since #15221 — `status: 'stranded'` + `repairable`

‎packages/runtime/src/mcp-resume-run.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,7 @@ describe('MCP resumeRun — (d) the engine\'s answers match the REST resume door
517517
['RUN_NOT_FOUND', { success: false, code: 'RUN_NOT_FOUND', error: "Suspended node 'collect' no longer exists" }, 'RUN_NOT_FOUND'],
518518
['STORE_UNAVAILABLE', { success: false, code: 'STORE_UNAVAILABLE' }, 'STORE_UNAVAILABLE'],
519519
['RESUME_IN_PROGRESS', { success: false, code: 'RESUME_IN_PROGRESS', error: 'Run is already being resumed' }, 'RESUME_IN_PROGRESS'],
520+
['RESUME_CHILD_PENDING', { success: false, code: 'RESUME_CHILD_PENDING', error: "Resume run 'run_child' instead" }, 'RESUME_CHILD_PENDING'],
520521
['FLOW_FAILED (stranded)', {
521522
success: false, error: 'tail blew up', status: 'stranded',
522523
errorMessage: 'Please contact support', summary: { nodes: [] },

‎packages/services/service-automation/src/degraded-suspended-run-load-log-cause.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
// And it is live during boot, not only in theory: `plugin.ts` `start()` calls
3434
// `rearmSuspendedWaitTimers`, which calls `engine.resume(run.runId)` for every
3535
// overdue run, and the public `resume()` runs its authorization gate
36-
// (`refuseGatedResume` → `resolveEffectiveSuspension`) through THIS degrading
36+
// (`refuseGatedResume` → `resolveSuspensionChain`) through THIS degrading
3737
// reader before `resumeInternal` ever takes the strict one.
3838
//
3939
// ## The fix, and the one thing deliberately NOT changed
@@ -356,7 +356,7 @@ describe('#6230 — one "store unreachable while resuming" incident, both record
356356
* unrelated finding. `resume()` walks BOTH catches around
357357
* `loadSuspendedRunStrict`:
358358
*
359-
* gate → `refuseGatedResume` → `resolveEffectiveSuspension` →
359+
* gate → `refuseGatedResume` → `resolveSuspensionChain` →
360360
* `loadSuspendedRun` → this seam, `warn` → **stdout**
361361
* then → `resumeInternal` → strict read → #5912, `error` → stderr
362362
*

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6428,6 +6428,13 @@ export class AutomationEngine implements IAutomationService {
64286428
* (subflow delegation / up-bubble, `map` re-entry, wait-timer wake) go
64296429
* through {@link resumeInternal} and are not re-gated — they continue work
64306430
* some already-authorized call started.
6431+
*
6432+
* **A map whose item is still parked is refused, not advanced (#27877).**
6433+
* Resuming a run parked on a `map` node by ITS OWN id re-runs the map,
6434+
* which advances past the in-flight item — so a run whose item child is
6435+
* still suspended answers a coded refusal naming that child's run id, and
6436+
* nothing moves. Resuming the child is the door; the map continues when it
6437+
* completes. See {@link refuseGatedResume}.
64316438
*/
64326439
resume(runId: string, signal?: ResumeSignal): Promise<AutomationResult> {
64336440
// The body lives in ./run-resume.ts (#25222).
@@ -6453,8 +6460,18 @@ export class AutomationEngine implements IAutomationService {
64536460
* chain and judges the node the signal lands on (subflow) or would advance
64546461
* past (map) — see {@link LINKED_RUN_PREFIXES}. A run it cannot resolve at
64556462
* all (unknown run id, nothing suspended, no resolvable node type) is left to
6456-
* `resumeInternal`, which reports the machine-state error — the gate only
6457-
* ever speaks to authorization.
6463+
* `resumeInternal`, which reports the machine-state error — the gate speaks
6464+
* to authorization, and to the one machine-state fact only its chain walk
6465+
* can see.
6466+
*
6467+
* **That fact is a skip (#27877).** When the chain passes a `map` link
6468+
* whose item child is still suspended, continuing the id the caller holds
6469+
* would re-run the map and advance it PAST that item without running it —
6470+
* the parent completes and the item's suspension is left behind, orphaned.
6471+
* Authority is judged first, so a service-owned item keeps its 403; an item
6472+
* open to this route is refused with a coded 409 naming the child run id to
6473+
* resume instead, before any state is touched. A `subflow` link is not a
6474+
* skip — the signal is delegated to the child — and is unchanged.
64586475
*
64596476
* **An UNDECLARED node type is refused, not deferred** (#5561 step two). It
64606477
* used to be let through on the schema default's inherited `'any'`; a pause
@@ -6471,7 +6488,7 @@ export class AutomationEngine implements IAutomationService {
64716488
private refuseGatedResume(runId: string, signal?: ResumeSignal): Promise<AutomationResult | null> {
64726489
// The body lives in ./run-resume.ts (#25222).
64736490
const host: ThisParameterType<typeof refuseGatedResume> = {
6474-
resolveEffectiveSuspension: (...a) => this.resolveEffectiveSuspension(...a),
6491+
resolveSuspensionChain: (...a) => this.resolveSuspensionChain(...a),
64756492
resolveSuspendedNodeType: (...a) => this.resolveSuspendedNodeType(...a),
64766493
resolveResumeAuthority: (...a) => this.resolveResumeAuthority(...a),
64776494
resolveDeclaredResumeAuthority: (...a) => this.resolveDeclaredResumeAuthority(...a),
@@ -6649,38 +6666,48 @@ export class AutomationEngine implements IAutomationService {
66496666
* node is literally where it lands.
66506667
* - `map:` — the signal is not delegated; the `map` node RE-RUNS, and since
66516668
* `$mapState.started` was advanced past the in-flight item before the
6652-
* suspend, continuing here advances the map *past* the item whose child
6653-
* is still parked. Judging the parent's own `map` node (always
6669+
* suspend, continuing here would advance the map *past* the item whose
6670+
* child is still parked. Judging the parent's own `map` node (always
66546671
* `resumeAuthority: 'any'`) let a raw resume skip a pending approval in a
6655-
* batch-approval flow — the gate has to read the item, not the loop.
6672+
* batch-approval flow — the gate has to read the item, not the loop. And
6673+
* since #27877 the gate does not let that advance happen at all: a
6674+
* resume that reaches a `map:` link whose child is still suspended is
6675+
* refused with a code naming the child run id, whatever the item's
6676+
* authority — so no parent completes past an item that never ran, and no
6677+
* item suspension is left orphaned. The engine's own up-bubble from the
6678+
* completed child goes through `resumeInternal` and is not gated.
66566679
*/
66576680
private static readonly LINKED_RUN_PREFIXES = ['subflow:', 'map:'] as const;
66586681

66596682
/**
66606683
* Follow the linked-run chain from `runId` to the suspension whose node
6661-
* actually governs a resume — see {@link LINKED_RUN_PREFIXES}. The deepest
6662-
* reachable suspension, not the id the caller holds, is what a resume
6663-
* really continues (or skips past). Returns `null` when nothing is
6664-
* suspended under that id; stops at the last resolvable link when a child
6665-
* row is gone (that run is where `resumeInternal` will continue).
6666-
*/
6667-
private async resolveEffectiveSuspension(runId: string): Promise<SuspendedRun | null> {
6684+
* actually governs a resume — see {@link LINKED_RUN_PREFIXES}. Returns every
6685+
* suspension the walk loaded, the caller's own first: the LAST one, not the
6686+
* id the caller holds, is what a resume really continues (or skips past),
6687+
* and each `map:` link with a successor is an item still parked under it
6688+
* (#27877). Empty when nothing is suspended under that id; stops at the
6689+
* last resolvable link when a child row is gone (that run is where
6690+
* `resumeInternal` will continue).
6691+
*/
6692+
private async resolveSuspensionChain(runId: string): Promise<SuspendedRun[]> {
66686693
const seen = new Set<string>();
6694+
const chain: SuspendedRun[] = [];
66696695
let run = await this.loadSuspendedRun(runId);
66706696
for (let depth = 0; run && depth < AutomationEngine.MAX_SUSPENSION_CHAIN_DEPTH; depth++) {
6697+
chain.push(run);
66716698
const correlation = run.correlation;
66726699
const prefix = typeof correlation === 'string'
66736700
? AutomationEngine.LINKED_RUN_PREFIXES.find(p => correlation.startsWith(p))
66746701
: undefined;
6675-
if (!prefix) return run;
6702+
if (!prefix) return chain;
66766703
const childRunId = correlation!.slice(prefix.length);
6677-
if (!childRunId || seen.has(childRunId)) return run;
6704+
if (!childRunId || seen.has(childRunId)) return chain;
66786705
seen.add(childRunId);
6679-
const child = await this.loadSuspendedRun(childRunId);
6680-
if (!child) return run;
6681-
run = child;
6706+
run = await this.loadSuspendedRun(childRunId);
66826707
}
6683-
return run;
6708+
// The depth bound stopped the walk on a run it loaded but never read.
6709+
if (run) chain.push(run);
6710+
return chain;
66846711
}
66856712

66866713
/** Read a suspended run from the hot cache, falling back to the durable

0 commit comments

Comments
 (0)