You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 4793aa4
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
Copy file name to clipboardExpand all lines: content/docs/references/api/automation-api.mdx
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -347,7 +347,7 @@ const result = AutomationApiErrorCode.parse(data);
347
347
348
348
| Property | Type | Required | Description |
349
349
| :--- | :--- | :--- | :--- |
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 |
351
351
|**message**|`string`| ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
352
352
353
353
### Allowed Values: `FlowInnerRefusal.code`
@@ -631,6 +631,7 @@ const result = AutomationApiErrorCode.parse(data);
Copy file name to clipboardExpand all lines: content/docs/references/api/contract.mdx
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
28
28
29
29
| Property | Type | Required | Description |
30
30
| :--- | :--- | :--- | :--- |
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) |
32
32
|**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) |
|**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);
0 commit comments