Skip to content

Commit 3a2f4c0

Browse files
fix(service-automation)!: a map whose item run was cancelled records the item as cancelled on a parent-id resume (#28176)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DG3gN5hhvu8HEmLydwk8EsW)_ Fixes #27971 Clause-②: yes (widening) Metadata-protocol: added Ruling-ref: 6523833308 Skills-docs: none — `skills/**` names neither `RESUME_CHILD_PENDING` nor a map's cancelled item, so no skill text describes the shape that changes; the hand-written guide `content/docs/automation/flows.mdx` (batch-approval paragraph) is updated here, and the generated `content/docs/references/automation/builtin-node-config.mdx` is regenerated from the spec describe. ## 维护者速读 - 改了什么:批量节点(map)里某一项的子流程被取消后,再用 map 自己的 run id 恢复,map 会继续往下走,并在该项的位置记下 `{ $status: 'cancelled', $itemRunId }` 标记;`count` 只算真正跑过的项。子项挂起记录没了、运行历史又不是 cancelled 的,直接 409 拒绝,什么都不动。 - 为什么改:以前取消一项后恢复批次,map 会悄悄跳过这一项,results 少一项、下标错位,批次还显示 completed。按你的裁决 A 修。 - 风险与回滚:只影响停在内置 map 节点、且子项挂起记录已不在的那次恢复;正常完成的项、仍挂起的项(409)不变。回滚即 revert 本 PR。 - 先看:`packages/services/service-automation/src/run-resume.ts`(continuePastCancelledMapItem 的判定与拒绝)、`src/builtin/map-node.ts`(标记写入与 count)、`src/map-cancelled-item-resume.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/map-cancelled-item-resume.test.ts`,预期 7 个用例全绿。 - 需要你做的:无,席位在 fable 契约复审通过后落地。 ## What changes - **The resume gate reads the item's run history** (`run-resume.ts`, `continuePastCancelledMapItem`). When a run's linked-run chain ends on a `map:` link whose item suspension row is gone, and the run is parked at the built-in `map` node, the gate calls `getRun(itemRunId)`: - status `cancelled` (written by `cancelRun`, which never touches the parent): `resume` continues with an engine-built signal that sets the handoff variable `MAPNODE.$mapItemCancelled` to the item run id; - anything else — no history row, an unreadable store (`getRun` degrades to `null`), a status other than `cancelled`: refused `409 RESUME_CHILD_PENDING` (#27877's code) before any state is touched, with a message saying the item is no longer suspended and what its history says. - **The map records the marker** (`builtin/map-node.ts`). On re-entry with that handoff it pushes `{ $status: 'cancelled', $itemRunId }` into the item's slot, so `results[i]` is still item `i`. `count` is the items that ran (`isMapItemCancelledMarker` filters the markers); the cancelled item adds nothing to the step totals. The marker is closed and `$`-keyed: a child flow's output keys are its declared variable names, which `flowBoundVariableNameSchema` keeps off `$`, so no real output can look like it. - **Through a `subflow` link too.** The engine-built signal is delegated down to the map one level below, the same way a caller's signal is. - **The caller still cannot write the handoff.** A caller signal that writes the engine's `$` namespace gets no continuation; `resumeInternal` refuses it `400 INVALID_SIGNAL` with the run left parked, as before. - **Only the built-in `map` node.** Another executor that mints a `map:` correlation keeps its resume unchanged (the concurrent-replica test's `map_items` node is one). - **Spec and docs.** `MapConfigSchema.outputVariable`'s describe and the map node's own config-schema description say an element can be the marker; `flows.mdx`'s batch paragraph describes the marker, `count`, and both 409 arms; the reference page is regenerated. - Changeset: `@objectstack/service-automation` minor, `@objectstack/spec` minor (`Metadata-protocol: added`), with `Ruling-ref: 6523833308` and the ADR-0087 answer `not-required (no-migration-prescription)`. ## Tests New `src/map-cancelled-item-resume.test.ts` (7 cases): the cancelled item's slot holds the marker, the map parks on item 2, and the downstream node reads `[marker, {done: 'ran t2'}]` with `count` 1; the same one level down through a `subflow` parent; an item row gone without a `cancelled` history row is refused 409 with nothing moved; an unreadable history is refused 409; a caller signal writing `$mapItemCancelled` is refused `INVALID_SIGNAL`; controls: a still-suspended item is still refused naming it, and items that complete are recorded as before with `count` 2. <details><summary>Ablations (fix committed first; the tests import ./engine.js and ./builtin/map-node.js from src, no dist on the path)</summary> Each via `scripts/ablation-replace.mjs` (anchor x1 → x0 on disk, then the run, then restore to the HEAD blob with `git diff HEAD` empty): - status check `item?.status !== 'cancelled'` → `item == null`: *refuses … without a cancelled history row* red (1 failed, 6 passed). - the caller-signal guard (`applyResumeSignal(new Map(), signal, link.nodeId)` line) deleted: *still refuses a caller signal that writes the engine namespace* red (1 failed, 6 passed). - `count` filter → `state.results.length`: the two marker cases red (2 failed, 5 passed). - the marker push deleted: the two marker cases red (2 failed, 5 passed). - the `nodeType !== 'map'` guard deleted: `concurrent-replica-resume-race.test.ts` *THE CONDITION (correlation)* red (1 failed, 11 passed). That pin is how the guard was found: without it the gate refused that test's synthetic `map:item_1` correlation. </details> ## Acceptance notes - Measured on `main` f6adf48 before the fix: `cancelRun(item1)` answered `true` and `getRun(item1).status` read `cancelled`; `resume(parentId)` answered `success`/`paused`, `$mapState` became `{started: 2, results: []}`, and after item 2 completed the parent was `completed` with a downstream node reading `results` = [item 2's output] and `count` 1. - The marker's keys (`$status`, `$itemRunId`) are the dev's proposal under ruling A; the ruling leaves their exact spelling to the contract review. - The approval revise-window recall (`plugin-approvals` calling `cancelRun`) is a producer of a cancelled item in a batch sign-off; read, not measured here. ## Verification <details><summary>Package suites</summary> - `pnpm --filter @objectstack/service-automation test`: Test Files 231 passed (231), Tests 2911 passed (2911). `typecheck`: check:test-typecheck OK, 0 errors. - `pnpm --filter @objectstack/spec test`: Test Files 756 passed (756), Tests 22513 passed, 1 todo. - `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date, after `gen:schema` + `gen:docs` and a spec build. </details> <details><summary>Full dogfood (the title carries a bang)</summary> Closure built with `pnpm --filter '@objectstack/dogfood^...' build`, then `pnpm --filter @objectstack/dogfood test` under the verify lock: VERDICT command-exit 0; Test Files 358 passed, 3 skipped (361); Tests 2883 passed, 20 skipped (2903). Run on 0dfb617; the later commit only adds the changeset's ADR-0087 line. </details> <details><summary>Derived gate families (dispatch-gates --commands, reconciled with --ran)</summary> 107 derived, 107 run with an exit code, 0 NOT MEASURED. They ran at 0dfb617. `check-adr-0087-registration --base origin/main` first exited 1 (no disposition line), fixed in the changeset and re-run at 84b980b with exit 0; the five changeset families were also re-run at 84b980b, all exit 0. `check-platform-checklist --changed-from origin/main`: OK, 17 anchors judged, 0 lost. `git merge-tree` against the current `origin/main`: clean. `pnpm lint` is CI's. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (describe text only) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4d1oZadHHFdEZXANxiG --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4d1oZadHHFdEZXANxiG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3efccfd commit 3a2f4c0

13 files changed

Lines changed: 449 additions & 19 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
fix(service-automation)!: a `map` whose item run was cancelled records the item as cancelled when the map's own run is resumed
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
Ruling-ref: 6523833308
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No authored key or accepted value is added, removed, renamed or re-shaped: `MapConfigSchema` keeps every key it had, and only the description of `outputVariable` gains a sentence. What changes is a runtime value, the entry a `map` records for an item whose run was cancelled, and a refusal of a resume that used to advance past an unrecorded item. No stored metadata holds either, so `objectstack migrate meta` has nothing to rewrite. -->
13+
14+
`cancelRun` on a `map` item's run terminates that item and leaves the map's run parked on it. Resuming the map's run by its own id used to re-run the map, which moved past the item with nothing recorded: `results` held only the later items' outputs, `results[i]` stopped being item `i`, and the run finished `completed`.
15+
16+
- **A cancelled item keeps its slot.** The resume reads the item run's history. When it says `cancelled`, the map goes on, and that item's entry in `outputVariable` (and in the node's `results`) is the closed marker `{ $status: 'cancelled', $itemRunId }` instead of an output. A downstream node reads it where it reads the results: `results[i]['$status'] == 'cancelled'`. A child flow's output keys are its declared variable names, which never start with `$`, so no real output looks like the marker.
17+
- **Never a run or a success.** The node's `count` is the items that ran; the marker is not counted, and the cancelled item adds nothing to the step's totals.
18+
- **Nothing to record is refused.** An item whose suspension is gone without a `cancelled` entry in its run history (no entry, an unreadable store, a run that completed without reaching the map) is refused `409 RESUME_CHILD_PENDING` before anything moves, the same code as an item that is still suspended.
19+
- **Through a `subflow` too.** Resuming a run that waits on the map through a `subflow` link records the marker one level down, the same way.
20+
- A caller still cannot write the map's handoff variables: a resume signal that writes the engine's `$` namespace is refused `400 INVALID_SIGNAL`, cancelled item or not.
21+
22+
`MapConfigSchema.outputVariable`'s description in `@objectstack/spec` now says an element can be that marker.

‎content/docs/automation/flows.mdx‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1512,7 +1512,22 @@ The run holds a **single program counter** the whole time: only one item's
15121512
approval is open at any moment; when it is decided the engine **re-enters** the
15131513
map node to start the next item. Resuming the map's own run id while an item is
15141514
still awaiting a service-gated decision is **refused** (403) — otherwise the
1515-
batch would step past that item as if it had been decided. v1 is sequential and
1515+
batch would step past that item as if it had been decided; an item open to that
1516+
route is refused **409 `RESUME_CHILD_PENDING`**, naming the item run to resume.
1517+
1518+
**A cancelled item keeps its slot.** When an item's run is cancelled
1519+
(`cancelRun`, which leaves the map's run parked), resuming the map's own run id
1520+
moves the batch on, and that item's entry in `outputVariable` (and in the
1521+
node's `results`) is the closed marker
1522+
`{ $status: 'cancelled', $itemRunId: '<the item run id>' }` instead of an
1523+
output, so `results[i]` is still item `i`. The marker is never a run or a
1524+
success: the node's `count` is the items that ran. A downstream step reads it
1525+
where it reads the results — `signoffResults[i]['$status'] == 'cancelled'` (a
1526+
child flow's output keys never start with `$`, so no real output looks like
1527+
it). An item whose run is gone *without* a `cancelled` entry in its run history
1528+
is refused with the same 409: the map has nothing true to record for it.
1529+
1530+
v1 is sequential and
15161531
fail-fast (the first item whose subflow fails fails the map). Concurrent fan-out (all items at once) is the
15171532
larger [ADR-0039](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0039-token-scope-tree-execution.md)
15181533
Track B work. Worked example: `showcase_release_signoff` → `showcase_one_task_signoff`.

‎content/docs/references/automation/builtin-node-config.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
251251
| **indexVariable** | `string` | optional | Optional variable holding the current index — a name without a leading `$` (the `$` names are the flow engine's own) |
252252
| **itemObject** | `string` | optional | When items are records, the object they belong to (exposes each item as the child's record) |
253253
| **input** | `Record<string, any>` | optional | Params passed to each item's subflow, keyed by its input variables: each value a CEL value envelope `{ dialect: 'cel', source }` evaluated per item (the item variable is in scope), or a literal written as it is — a `{…}` template token is refused |
254-
| **outputVariable** | `string` | optional | Each item's subflow output, collected in order — bound to a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
254+
| **outputVariable** | `string` | optional | Each item's subflow output, collected in order — bound to a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}`. An item whose run was cancelled keeps its slot as the closed marker `{ $status: 'cancelled', $itemRunId }`: never an output, and not counted as a run |
255255

256256

257257
---

‎docs/qa/platform-checklist/areas/automation.json‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1634,7 +1634,7 @@
16341634
"title": "The flow-dispatch HTTP status contract: every never-dispatched, ran-and-failed and non-terminal outcome answers its own status + ADR-0112 code, at every door that dispatches a flow",
16351635
"since": "v17",
16361636
"status": "active",
1637-
"revision": 9,
1637+
"revision": 10,
16381638
"priority": "P1",
16391639
"surface": "api",
16401640
"personas": ["seeded admin (admin@objectos.ai / admin123)", "org member for the resume 403 arm — created by the admin with POST /api/v1/auth/admin/create-user and past its password change (POST /api/v1/auth/change-password): a held session answers 403 PASSWORD_RESET_REQUIRED, the right status for the wrong reason"],
@@ -1728,7 +1728,7 @@
17281728
"resume · 400 INVALID_SIGNAL — signal wrote the engine's $ namespace, retryable",
17291729
"resume · 400 INVALID_SCREEN_INPUT — bag violates the screen's field contract, retryable",
17301730
"resume · 409 RESUME_IN_PROGRESS — a concurrent resume holds the run, retryable",
1731-
"resume · 409 RESUME_CHILD_PENDING — a parent-id resume of a map whose item run is still suspended; the message names the item run to resume, retryable. No stock flow reaches it: showcase_release_signoff's item parks on an approval, which answers 403 PERMISSION_DENIED first, so a run stages a map over a raw-resumable item (a screen) and resumes the map's own run id",
1731+
"resume · 409 RESUME_CHILD_PENDING — a parent-id resume of a map whose item run is still suspended; the message names the item run to resume, retryable. The same code answers when the item's suspension is gone without a cancelled entry in its run history (no entry, or an unreadable store); the message names the remedy, cancelling the parent run, and nothing moves. A CANCELLED item is not refused: the map continues and records {$status: 'cancelled', $itemRunId} in its slot. No stock flow reaches it: showcase_release_signoff's item parks on an approval, which answers 403 PERMISSION_DENIED first, so a run stages a map over a raw-resumable item (a screen) and resumes the map's own run id",
17321732
"resume · 503 STORE_UNAVAILABLE — durable store unreadable, retryable",
17331733
"resume · 404 RUN_NOT_FOUND — no such suspension, or a STALE one; terminal",
17341734
"resume · 400 FLOW_FAILED — the resumed run consumed its pause and failed; terminal",
@@ -1800,6 +1800,12 @@
18001800
"date": "2026-10-30",
18011801
"change": "acceptance[4] and the variants name the resume door's map-item refusal, 409 RESUME_CHILD_PENDING (PR #27951): a parent-id resume of a map whose item run is still suspended is refused before anything moves, naming the item run. Read at base f6adf480 (run-resume.ts refuseMapItemSkip; runtime automation.ts code→status table)",
18021802
"ref": "#27971"
1803+
},
1804+
{
1805+
"revision": 10,
1806+
"date": "2026-10-30",
1807+
"change": "the RESUME_CHILD_PENDING variant names the code's second arm and the cancelled-item record (PR #28176): an item whose suspension is gone without a cancelled history entry is refused with the same 409, naming the remedy (cancel the parent run); a cancelled item is recorded in its slot and the map continues. Read at head of PR #28176 (run-resume.ts continuePastCancelledMapItem; builtin/map-node.ts)",
1808+
"ref": "#27971"
18031809
}
18041810
]
18051811
},

‎packages/client/src/index.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5951,7 +5951,9 @@ export class ObjectStackClient {
59515951
* intact keep their own codes and stay retryable — `INVALID_SIGNAL` /
59525952
* `INVALID_SCREEN_INPUT` (400), `RESUME_IN_PROGRESS` (409),
59535953
* `RESUME_CHILD_PENDING` (409: the run waits on a `map` item run that is
5954-
* still suspended; the message names that run, resume it instead),
5954+
* still suspended; the message names that run, resume it instead — or
5955+
* whose suspension is gone without a `cancelled` history entry; the
5956+
* message names the remedy, cancelling the parent run),
59555957
* `STORE_UNAVAILABLE` (503).
59565958
*/
59575959
resume: async <T extends AutomationResult = AutomationResult>(

‎packages/services/service-automation/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,9 @@ if (result.success) {
309309
machine-readable classification is `code` (not `errorCode`) — resume refusals such as
310310
`RUN_NOT_FOUND`, `STORE_UNAVAILABLE`, `RESUME_IN_PROGRESS`, `RESUME_CHILD_PENDING` (a
311311
parent-id resume of a `map` whose item run is still suspended; resume the item run the
312-
message names), plus the trigger-time
312+
message names. The same code answers when the item's suspension is gone without a
313+
`cancelled` entry in its run history; cancel the parent run to end the batch. A cancelled
314+
item is recorded in its slot instead), plus the trigger-time
313315
`FLOW_DISABLED` / `FLOW_NO_START_NODE`.
314316

315317
⚠️ `runAs` on `AutomationContext` is derived by the engine from the flow definition —

‎packages/services/service-automation/src/builtin/map-node.ts‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,40 @@ import { collectionNotAList, resolveCollectionSlot, resolveValueSlotMap } from '
1313
/** Hard cap on map fan-out — turns a runaway collection into a clean error. */
1414
const MAX_MAP_ITEMS = 10_000;
1515

16+
/**
17+
* [#27971] The result a `map` records in an item's slot when that item's run was
18+
* cancelled (`cancelRun`) while the map waited on it, and the map's own run is
19+
* then resumed. The item keeps its position, so `results[i]` is still item `i`;
20+
* the marker is never a run or a success, and `count` leaves it out.
21+
*
22+
* Closed and `$`-keyed: a child flow's output keys are its output variable
23+
* names, which never start with `$`, so no item output can look like it.
24+
* Downstream nodes read it where they read the results:
25+
* `results[i]['$status'] == 'cancelled'`.
26+
*/
27+
export interface MapItemCancelledMarker {
28+
readonly $status: 'cancelled';
29+
/** The item's cancelled run, as its run history records it. */
30+
readonly $itemRunId: string;
31+
}
32+
33+
/** Whether a recorded result is the {@link MapItemCancelledMarker}. */
34+
export function isMapItemCancelledMarker(value: unknown): value is MapItemCancelledMarker {
35+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
36+
const keys = Object.keys(value);
37+
const marker = value as Record<string, unknown>;
38+
return keys.length === 2 && marker.$status === 'cancelled' && typeof marker.$itemRunId === 'string';
39+
}
40+
41+
/**
42+
* The handoff variable the resume gate sets, on the map node, when it lets a
43+
* resume continue past a cancelled item: `<nodeId>.$mapItemCancelled` holds the
44+
* cancelled item run id. Engine-owned like `$mapItemDone`; a caller cannot write it.
45+
*/
46+
export function mapItemCancelledVariable(nodeId: string): string {
47+
return `${nodeId}.$mapItemCancelled`;
48+
}
49+
1650
/**
1751
* `map` built-in node — **sequential multi-instance** (ADR-0037 Track A2).
1852
*
@@ -87,7 +121,7 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
87121
// `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`), so this stays the open map
88122
// the Studio keyValue editor renders, like the CRUD `fields`.
89123
input: { type: 'object', additionalProperties: true, title: 'Item input', description: 'Params passed to each item’s subflow: each value a CEL value envelope evaluated per item (the item variable is in scope), or a literal.' },
90-
outputVariable: { type: 'string', title: 'Output variable', description: 'Each item’s subflow output, collected in order.' },
124+
outputVariable: { type: 'string', title: 'Output variable', description: 'Each item’s subflow output, collected in order. An item whose run was cancelled keeps its slot as { $status: \'cancelled\', $itemRunId }.' },
91125
},
92126
required: ['collection', 'flowName'],
93127
},
@@ -142,6 +176,15 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
142176
variables.delete(`${node.id}.$mapItemDone`);
143177
variables.delete(`${node.id}.$mapItemOutput`);
144178
}
179+
// [#27971] Re-entry past an item whose run was cancelled: the resume gate
180+
// read `cancelled` in that run's history and says so here. The item keeps
181+
// its slot with the marker, not an output — nothing ran, so no totals move.
182+
const cancelledItemRunId = variables.get(mapItemCancelledVariable(node.id));
183+
if (typeof cancelledItemRunId === 'string' && cancelledItemRunId) {
184+
const marker: MapItemCancelledMarker = { $status: 'cancelled', $itemRunId: cancelledItemRunId };
185+
state.results.push(marker);
186+
}
187+
variables.delete(mapItemCancelledVariable(node.id));
145188

146189
const parentRunId = variables.get('$runId');
147190

@@ -329,7 +372,9 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
329372
if (outVar) variables.set(outVar, state.results);
330373
return {
331374
success: true,
332-
output: { results: state.results, count: state.results.length },
375+
// `count` is the items that RAN: a cancelled item's marker holds its
376+
// slot in `results` but is never counted as a run (#27971).
377+
output: { results: state.results, count: state.results.filter(r => !isMapItemCancelledMarker(r)).length },
333378
metrics: { selected, acted, ...(unmeasured ? { unmeasuredEffect: true } : {}), ...rolledFailures() },
334379
};
335380
},

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ import {
165165
forgetSuspendedRun,
166166
evictConsumedSuspension,
167167
takeSubflowParentStrand,
168+
type GatedResumeContinuation,
168169
} from './run-resume.js';
169170
// [#25222] The dispatch and suspension claims' bodies: claiming, settling and
170171
// reading a dispatch, pruning the expired in-process claims, minting a run id,
@@ -1547,7 +1548,7 @@ export function isRetryableResumeRefusal(code: AutomationResult['code']): boolea
15471548
const ENGINE_BUILT_SIGNAL = Symbol('objectstack.automation.resume.engineBuilt');
15481549

15491550
/** Tag a signal the engine constructed for its own continuation. */
1550-
function engineBuilt(signal: ResumeSignal): ResumeSignal {
1551+
export function engineBuilt(signal: ResumeSignal): ResumeSignal {
15511552
return Object.assign(signal, { [ENGINE_BUILT_SIGNAL]: true });
15521553
}
15531554

@@ -6467,6 +6468,13 @@ export class AutomationEngine implements IAutomationService {
64676468
* still suspended answers a coded refusal naming that child's run id, and
64686469
* nothing moves. Resuming the child is the door; the map continues when it
64696470
* completes. See {@link refuseGatedResume}.
6471+
*
6472+
* **A cancelled item is recorded, never skipped (#27971).** When the item's
6473+
* run was cancelled (`cancelRun`, which leaves the parent parked), resuming
6474+
* the map's run advances it and the item keeps its slot in the results as
6475+
* `{ $status: 'cancelled', $itemRunId }`, outside `count`. An item whose
6476+
* suspension is gone WITHOUT a `cancelled` history row is refused with the
6477+
* same code: the map has nothing true to record for it.
64706478
*/
64716479
resume(runId: string, signal?: ResumeSignal): Promise<AutomationResult> {
64726480
// The body lives in ./run-resume.ts (#25222).
@@ -6503,7 +6511,10 @@ export class AutomationEngine implements IAutomationService {
65036511
* Authority is judged first, so a service-owned item keeps its 403; an item
65046512
* open to this route is refused with a coded 409 naming the child run id to
65056513
* resume instead, before any state is touched. A `subflow` link is not a
6506-
* skip — the signal is delegated to the child — and is unchanged.
6514+
* skip — the signal is delegated to the child — and is unchanged. A map
6515+
* whose item row is gone answers by that item's run history (#27971):
6516+
* `cancelled` hands {@link resume} a continuation that records the item's
6517+
* marker; anything else is refused with the same code.
65076518
*
65086519
* **An UNDECLARED node type is refused, not deferred** (#5561 step two). It
65096520
* used to be let through on the schema default's inherited `'any'`; a pause
@@ -6517,13 +6528,14 @@ export class AutomationEngine implements IAutomationService {
65176528
* never declared — the failure mode this whole issue is about, restated as a
65186529
* log line.
65196530
*/
6520-
private refuseGatedResume(runId: string, signal?: ResumeSignal): Promise<AutomationResult | null> {
6531+
private refuseGatedResume(runId: string, signal?: ResumeSignal): Promise<AutomationResult | GatedResumeContinuation | null> {
65216532
// The body lives in ./run-resume.ts (#25222).
65226533
const host: ThisParameterType<typeof refuseGatedResume> = {
65236534
resolveSuspensionChain: (...a) => this.resolveSuspensionChain(...a),
65246535
resolveSuspendedNodeType: (...a) => this.resolveSuspendedNodeType(...a),
65256536
resolveResumeAuthority: (...a) => this.resolveResumeAuthority(...a),
65266537
resolveDeclaredResumeAuthority: (...a) => this.resolveDeclaredResumeAuthority(...a),
6538+
getRun: (...a) => this.getRun(...a),
65276539
logger: this.logger,
65286540
};
65296541
return refuseGatedResume.call(host, runId, signal);

0 commit comments

Comments
 (0)