Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/27971-map-cancelled-item.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/service-automation': minor
'@objectstack/spec': minor
---

fix(service-automation)!: a `map` whose item run was cancelled records the item as cancelled when the map's own run is resumed

Clause-②: yes (widening)
Metadata-protocol: added
Ruling-ref: 6523833308

<!-- 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. -->

`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`.

- **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.
- **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.
- **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.
- **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.
- 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.

`MapConfigSchema.outputVariable`'s description in `@objectstack/spec` now says an element can be that marker.
17 changes: 16 additions & 1 deletion content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1512,7 +1512,22 @@ The run holds a **single program counter** the whole time: only one item's
approval is open at any moment; when it is decided the engine **re-enters** the
map node to start the next item. Resuming the map's own run id while an item is
still awaiting a service-gated decision is **refused** (403) — otherwise the
batch would step past that item as if it had been decided. v1 is sequential and
batch would step past that item as if it had been decided; an item open to that
route is refused **409 `RESUME_CHILD_PENDING`**, naming the item run to resume.

**A cancelled item keeps its slot.** When an item's run is cancelled
(`cancelRun`, which leaves the map's run parked), resuming the map's own run id
moves the batch on, and that item's entry in `outputVariable` (and in the
node's `results`) is the closed marker
`{ $status: 'cancelled', $itemRunId: '<the item run id>' }` instead of an
output, so `results[i]` is still item `i`. The marker is never a run or a
success: the node's `count` is the items that ran. A downstream step reads it
where it reads the results — `signoffResults[i]['$status'] == 'cancelled'` (a
child flow's output keys never start with `$`, so no real output looks like
it). An item whose run is gone *without* a `cancelled` entry in its run history
is refused with the same 409: the map has nothing true to record for it.

v1 is sequential and
fail-fast (the first item whose subflow fails fails the map). Concurrent fan-out (all items at once) is the
larger [ADR-0039](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0039-token-scope-tree-execution.md)
Track B work. Worked example: `showcase_release_signoff` → `showcase_one_task_signoff`.
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| **indexVariable** | `string` | optional | Optional variable holding the current index — a name without a leading `$` (the `$` names are the flow engine's own) |
| **itemObject** | `string` | optional | When items are records, the object they belong to (exposes each item as the child's record) |
| **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 |
| **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 }}` |
| **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 |


---
Expand Down
10 changes: 8 additions & 2 deletions docs/qa/platform-checklist/areas/automation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1634,7 +1634,7 @@
"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",
"since": "v17",
"status": "active",
"revision": 9,
"revision": 10,
"priority": "P1",
"surface": "api",
"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"],
Expand Down Expand Up @@ -1728,7 +1728,7 @@
"resume · 400 INVALID_SIGNAL — signal wrote the engine's $ namespace, retryable",
"resume · 400 INVALID_SCREEN_INPUT — bag violates the screen's field contract, retryable",
"resume · 409 RESUME_IN_PROGRESS — a concurrent resume holds the run, retryable",
"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",
"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",
"resume · 503 STORE_UNAVAILABLE — durable store unreadable, retryable",
"resume · 404 RUN_NOT_FOUND — no such suspension, or a STALE one; terminal",
"resume · 400 FLOW_FAILED — the resumed run consumed its pause and failed; terminal",
Expand Down Expand Up @@ -1800,6 +1800,12 @@
"date": "2026-10-30",
"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)",
"ref": "#27971"
},
{
"revision": 10,
"date": "2026-10-30",
"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)",
"ref": "#27971"
}
]
},
Expand Down
4 changes: 3 additions & 1 deletion packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5946,7 +5946,9 @@ export class ObjectStackClient {
* intact keep their own codes and stay retryable — `INVALID_SIGNAL` /
* `INVALID_SCREEN_INPUT` (400), `RESUME_IN_PROGRESS` (409),
* `RESUME_CHILD_PENDING` (409: the run waits on a `map` item run that is
* still suspended; the message names that run, resume it instead),
* still suspended; the message names that run, resume it instead — or
* whose suspension is gone without a `cancelled` history entry; the
* message names the remedy, cancelling the parent run),
* `STORE_UNAVAILABLE` (503).
*/
resume: async <T extends AutomationResult = AutomationResult>(
Expand Down
4 changes: 3 additions & 1 deletion packages/services/service-automation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,9 @@ if (result.success) {
machine-readable classification is `code` (not `errorCode`) — resume refusals such as
`RUN_NOT_FOUND`, `STORE_UNAVAILABLE`, `RESUME_IN_PROGRESS`, `RESUME_CHILD_PENDING` (a
parent-id resume of a `map` whose item run is still suspended; resume the item run the
message names), plus the trigger-time
message names. The same code answers when the item's suspension is gone without a
`cancelled` entry in its run history; cancel the parent run to end the batch. A cancelled
item is recorded in its slot instead), plus the trigger-time
`FLOW_DISABLED` / `FLOW_NO_START_NODE`.

⚠️ `runAs` on `AutomationContext` is derived by the engine from the flow definition —
Expand Down
49 changes: 47 additions & 2 deletions packages/services/service-automation/src/builtin/map-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,40 @@ import { collectionNotAList, resolveCollectionSlot, resolveValueSlotMap } from '
/** Hard cap on map fan-out — turns a runaway collection into a clean error. */
const MAX_MAP_ITEMS = 10_000;

/**
* [#27971] The result a `map` records in an item's slot when that item's run was
* cancelled (`cancelRun`) while the map waited on it, and the map's own run is
* then resumed. The item keeps its position, so `results[i]` is still item `i`;
* the marker is never a run or a success, and `count` leaves it out.
*
* Closed and `$`-keyed: a child flow's output keys are its output variable
* names, which never start with `$`, so no item output can look like it.
* Downstream nodes read it where they read the results:
* `results[i]['$status'] == 'cancelled'`.
*/
export interface MapItemCancelledMarker {
readonly $status: 'cancelled';
/** The item's cancelled run, as its run history records it. */
readonly $itemRunId: string;
}

/** Whether a recorded result is the {@link MapItemCancelledMarker}. */
export function isMapItemCancelledMarker(value: unknown): value is MapItemCancelledMarker {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
const keys = Object.keys(value);
const marker = value as Record<string, unknown>;
return keys.length === 2 && marker.$status === 'cancelled' && typeof marker.$itemRunId === 'string';
}

/**
* The handoff variable the resume gate sets, on the map node, when it lets a
* resume continue past a cancelled item: `<nodeId>.$mapItemCancelled` holds the
* cancelled item run id. Engine-owned like `$mapItemDone`; a caller cannot write it.
*/
export function mapItemCancelledVariable(nodeId: string): string {
return `${nodeId}.$mapItemCancelled`;
}

/**
* `map` built-in node — **sequential multi-instance** (ADR-0037 Track A2).
*
Expand Down Expand Up @@ -87,7 +121,7 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
// `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`), so this stays the open map
// the Studio keyValue editor renders, like the CRUD `fields`.
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.' },
outputVariable: { type: 'string', title: 'Output variable', description: 'Each item’s subflow output, collected in order.' },
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 }.' },
},
required: ['collection', 'flowName'],
},
Expand Down Expand Up @@ -142,6 +176,15 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
variables.delete(`${node.id}.$mapItemDone`);
variables.delete(`${node.id}.$mapItemOutput`);
}
// [#27971] Re-entry past an item whose run was cancelled: the resume gate
// read `cancelled` in that run's history and says so here. The item keeps
// its slot with the marker, not an output — nothing ran, so no totals move.
const cancelledItemRunId = variables.get(mapItemCancelledVariable(node.id));
if (typeof cancelledItemRunId === 'string' && cancelledItemRunId) {
const marker: MapItemCancelledMarker = { $status: 'cancelled', $itemRunId: cancelledItemRunId };
state.results.push(marker);
}
variables.delete(mapItemCancelledVariable(node.id));

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

Expand Down Expand Up @@ -329,7 +372,9 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
if (outVar) variables.set(outVar, state.results);
return {
success: true,
output: { results: state.results, count: state.results.length },
// `count` is the items that RAN: a cancelled item's marker holds its
// slot in `results` but is never counted as a run (#27971).
output: { results: state.results, count: state.results.filter(r => !isMapItemCancelledMarker(r)).length },
metrics: { selected, acted, ...(unmeasured ? { unmeasuredEffect: true } : {}), ...rolledFailures() },
};
},
Expand Down
18 changes: 15 additions & 3 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ import {
forgetSuspendedRun,
evictConsumedSuspension,
takeSubflowParentStrand,
type GatedResumeContinuation,
} from './run-resume.js';
// [#25222] The dispatch and suspension claims' bodies: claiming, settling and
// reading a dispatch, pruning the expired in-process claims, minting a run id,
Expand Down Expand Up @@ -1547,7 +1548,7 @@ export function isRetryableResumeRefusal(code: AutomationResult['code']): boolea
const ENGINE_BUILT_SIGNAL = Symbol('objectstack.automation.resume.engineBuilt');

/** Tag a signal the engine constructed for its own continuation. */
function engineBuilt(signal: ResumeSignal): ResumeSignal {
export function engineBuilt(signal: ResumeSignal): ResumeSignal {
return Object.assign(signal, { [ENGINE_BUILT_SIGNAL]: true });
}

Expand Down Expand Up @@ -6467,6 +6468,13 @@ export class AutomationEngine implements IAutomationService {
* 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}.
*
* **A cancelled item is recorded, never skipped (#27971).** When the item's
* run was cancelled (`cancelRun`, which leaves the parent parked), resuming
* the map's run advances it and the item keeps its slot in the results as
* `{ $status: 'cancelled', $itemRunId }`, outside `count`. An item whose
* suspension is gone WITHOUT a `cancelled` history row is refused with the
* same code: the map has nothing true to record for it.
*/
resume(runId: string, signal?: ResumeSignal): Promise<AutomationResult> {
// The body lives in ./run-resume.ts (#25222).
Expand Down Expand Up @@ -6503,7 +6511,10 @@ export class AutomationEngine implements IAutomationService {
* 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.
* skip — the signal is delegated to the child — and is unchanged. A map
* whose item row is gone answers by that item's run history (#27971):
* `cancelled` hands {@link resume} a continuation that records the item's
* marker; anything else is refused with the same code.
*
* **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
Expand All @@ -6517,13 +6528,14 @@ export class AutomationEngine implements IAutomationService {
* never declared — the failure mode this whole issue is about, restated as a
* log line.
*/
private refuseGatedResume(runId: string, signal?: ResumeSignal): Promise<AutomationResult | null> {
private refuseGatedResume(runId: string, signal?: ResumeSignal): Promise<AutomationResult | GatedResumeContinuation | null> {
// The body lives in ./run-resume.ts (#25222).
const host: ThisParameterType<typeof refuseGatedResume> = {
resolveSuspensionChain: (...a) => this.resolveSuspensionChain(...a),
resolveSuspendedNodeType: (...a) => this.resolveSuspendedNodeType(...a),
resolveResumeAuthority: (...a) => this.resolveResumeAuthority(...a),
resolveDeclaredResumeAuthority: (...a) => this.resolveDeclaredResumeAuthority(...a),
getRun: (...a) => this.getRun(...a),
logger: this.logger,
};
return refuseGatedResume.call(host, runId, signal);
Expand Down
Loading
Loading