Skip to content

Commit d6c0a75

Browse files
fix(service-automation): a corrupt correlation cycle back to the resumed run no longer names that run as its own child (#28101)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DG3gN5hhvu8HEmLydwk8EsW)_ Part of #27971 — this PR carries the rider (comment 6516883756) and the docs half only. The card's main fix (recording a cancelled `map` item on a parent-id resume) waits on the maintainer's ruling (decision analysis 6523020455) and is not in this PR. Clause-②: no Metadata-protocol: none Skills-docs: none — `skills/**` names neither `RESUME_CHILD_PENDING` nor map cancellation; under `content/docs/**` the code appears only in the generated `references/api/` pages (automation-api, contract, error-code-ledger), which already list it. ## 维护者速读 - 改了什么:恢复闸门沿关联 run 链往下走时,先把调用方自己的 run 记为已访问;另在客户端注释、README、QA 清单补上 409 RESUME_CHILD_PENDING。 - 为什么改:关联数据损坏成 A→B→A 环时,拒绝信息会让调用方去恢复它正在恢复的那个 run,指错了门。 - 风险与回滚:只影响损坏的环形关联;正常链路的遍历结果不变。回滚即还原 engine.ts 里那一行。 - 先看:`packages/services/service-automation/src/engine.ts`(resolveSuspensionChain 的 seen 初值)、`src/map-parent-resume-refusal.test.ts`(新环形用例)。 - 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/map-parent-resume-refusal.test.ts`,预期 6 个用例全绿。 - 需要你做的:无,席位落地。#27971 主修复的形状另在卡上等你裁决。 ## What changes - **Rider 6516883756.** `resolveSuspensionChain` (`service-automation/src/engine.ts`) seeds its `seen` set with the caller's run id before the walk. A corrupt correlation cycle A → B → A used to yield the chain `[A, B, A]`, and the `409 RESUME_CHILD_PENDING` refusal then told the caller to resume A, the run it was already resuming. The chain now ends at B and the refusal names B. - **Docs half.** `409 RESUME_CHILD_PENDING` is added to the client `resume()` docblock's list of refusals that leave the suspension intact (`packages/client/src/index.ts`), to the service-automation README's code list, and to the automation checklist item `automation.trigger-status-contract` (acceptance[4], a new variant row, history revision 9). - Changeset: `@objectstack/service-automation` patch. ## Tests - New pin in `map-parent-resume-refusal.test.ts`: *names the item run, never the caller's own, when a corrupt correlation cycles back to the caller*. It points the item's row back at its parent (`map:PARENT`), resumes the parent, and asserts the code, that the message names the item run and not the parent, and that the map state did not move. - Ablation (fix committed first; the test imports `./engine.js` from `src`, so no `dist` is on the path): `scripts/ablation-replace.mjs` replaced the seeded constructor with an empty one (anchor x1 → x0, blob c3ff057 → 43ab85a7); the new pin went red (1 failed, 5 passed); the restore matched the HEAD blob c3ff057 and `git diff HEAD` was empty. ## Acceptance notes - Measured on `main` f6adf48 for the card's main half (not changed here): after `cancelRun` on a parked map item, a parent-id resume advances past it. The parent completes, a downstream node sees `results` holding only item 2's output, and `count` is 1. - No stock showcase flow reaches `409 RESUME_CHILD_PENDING`: `showcase_release_signoff`'s item parks on an approval, which answers 403 first. The new checklist variant says so. ## Verification <details><summary>Package suite and typecheck at 24787ee</summary> `pnpm --filter @objectstack/service-automation test`: Test Files 226 passed (226), Tests 2863 passed (2863). `pnpm --filter @objectstack/service-automation typecheck`: check:test-typecheck OK, 0 errors. The head after that commit only adds the changeset. </details> <details><summary>Derived gate families (dispatch-gates --commands, reconciled with --ran)</summary> 67 derived families: 66 ran with exit 0 and 1 is NOT MEASURED. 58 of them ran at 24787ee; the 9 changeset families the changeset added ran at 1b77a96. `check-plugin-teardown-shape --self-test` was re-run after fetching its pinned positive-control commit, and `check:skill-examples` after building `@objectstack/client-react` and its dependencies; both exit 0. NOT MEASURED: `pnpm check:dual-build-cjs-loads` exited 3 (prerequisite): it reads the `dist/` of about 40 packages, apps and adapters included, that this container did not build. CI's run is the reading for it. `node scripts/check-platform-checklist.mjs --changed-from origin/main`: exit 0, 14 anchors judged, 0 lost. `pnpm lint` is a repo-wide scan owned by CI. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [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. (not touched) - [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 645534d commit d6c0a75

6 files changed

Lines changed: 44 additions & 5 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation): a corrupt correlation cycle back to the resumed run no longer names that run as its own child
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The resume gate walks a run's linked-run chain to find the suspension a resume really continues. That walk now counts the caller's own run as already visited, so a corrupt `map:` / `subflow:` correlation that points back at it (A → B → A) ends the chain at B. The `409 RESUME_CHILD_PENDING` refusal then names the item run B to resume, never run A, the one the caller is already resuming.
11+
12+
The client's `resume()` docs, the service-automation README and the automation QA checklist now list `409 RESUME_CHILD_PENDING` beside the other resume refusals that leave the suspension intact.

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

Lines changed: 9 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": 8,
1637+
"revision": 9,
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"],
@@ -1690,7 +1690,7 @@
16901690
"evidence": "the 400 bodies from both doors, keys shown"
16911691
},
16921692
{
1693-
"clause": "every RESUME refusal answers its own status and code, and the retryable ones LEAVE THE SUSPENSION INTACT: 403 PERMISSION_DENIED, 400 INVALID_SIGNAL, 400 INVALID_SCREEN_INPUT, 409 RESUME_IN_PROGRESS, 503 STORE_UNAVAILABLE all keep the run paused; 404 RUN_NOT_FOUND and 400 FLOW_FAILED are terminal",
1693+
"clause": "every RESUME refusal answers its own status and code, and the retryable ones LEAVE THE SUSPENSION INTACT: 403 PERMISSION_DENIED, 400 INVALID_SIGNAL, 400 INVALID_SCREEN_INPUT, 409 RESUME_IN_PROGRESS, 409 RESUME_CHILD_PENDING, 503 STORE_UNAVAILABLE all keep the run paused; 404 RUN_NOT_FOUND and 400 FLOW_FAILED are terminal",
16941694
"oracle": "api",
16951695
"verify": "per reachable arm: the (status, code) pair, THEN a re-read of the run proving it is still paused and a subsequent valid resume still succeeds. The intact-suspension half is what separates these arms from the terminal ones and no status shows it. 503 STORE_UNAVAILABLE has no stock trigger: drive it with the qa-flow-status-doors recipe's step 5 (sys_automation_run renamed away in the boot's telemetry database for one resume, then back), and only then re-read and resume. ⛔ Never infer it from the code being registered; a run that cannot edit the telemetry file (a non-file datasource) records the arm blocked(fixture), with packages/runtime/src/domains/automation-resume-refusal-codes.test.ts (the in-process 503 mapping) beside it as evidence of its own layer only (RUNNER rule 6)",
16961696
"evidence": "per-arm response + the post-refusal run state + the successful later resume"
@@ -1728,6 +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",
17311732
"resume · 503 STORE_UNAVAILABLE — durable store unreadable, retryable",
17321733
"resume · 404 RUN_NOT_FOUND — no such suspension, or a STALE one; terminal",
17331734
"resume · 400 FLOW_FAILED — the resumed run consumed its pause and failed; terminal",
@@ -1793,6 +1794,12 @@
17931794
"date": "2026-10-27",
17941795
"change": "acceptance[0], the variants and fixtures.knownGaps name classifyFlowRefusal's fifth trigger row, 422 FLOW_INPUT_SCHEMA_INVALID. Checklist-accuracy finding of the 18.2.0 wave 2 (QA run #26476, release-18.2.0-w2-manual-api, at 756220c4; wave anchor #25807, slice A): the clause said 'every TRIGGER row' and listed four, while packages/runtime/src/flow-dispatch-status.ts classifyFlowRefusal answers five; the run did not drive the fifth. Re-read at base e4ba846f (flow-dispatch-status.ts table and classifyFlowRefusal; service-automation engine.ts InputSchemaViolationError arm). The new knownGaps line says no stock flow reaches the row, so it scores not-run until a run stages one",
17951796
"ref": "#25807"
1797+
},
1798+
{
1799+
"revision": 9,
1800+
"date": "2026-10-30",
1801+
"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)",
1802+
"ref": "#27971"
17961803
}
17971804
]
17981805
},

‎packages/client/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5945,6 +5945,8 @@ export class ObjectStackClient {
59455945
* and the pause is gone for good. The refusals that leave the suspension
59465946
* intact keep their own codes and stay retryable — `INVALID_SIGNAL` /
59475947
* `INVALID_SCREEN_INPUT` (400), `RESUME_IN_PROGRESS` (409),
5948+
* `RESUME_CHILD_PENDING` (409: the run waits on a `map` item run that is
5949+
* still suspended; the message names that run, resume it instead),
59485950
* `STORE_UNAVAILABLE` (503).
59495951
*/
59505952
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
@@ -307,7 +307,9 @@ if (result.success) {
307307
`AutomationResult` fields: `success`, `output?`, `error?`, `durationMs?`, `code?`,
308308
`status?`, `runId?`, `screen?`, `successMessage?`, `errorMessage?`, `summary?`. The
309309
machine-readable classification is `code` (not `errorCode`) — resume refusals such as
310-
`RUN_NOT_FOUND`, `STORE_UNAVAILABLE`, `RESUME_IN_PROGRESS`, plus the trigger-time
310+
`RUN_NOT_FOUND`, `STORE_UNAVAILABLE`, `RESUME_IN_PROGRESS`, `RESUME_CHILD_PENDING` (a
311+
parent-id resume of a `map` whose item run is still suspended; resume the item run the
312+
message names), plus the trigger-time
311313
`FLOW_DISABLED` / `FLOW_NO_START_NODE`.
312314

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

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6713,10 +6713,12 @@ export class AutomationEngine implements IAutomationService {
67136713
* and each `map:` link with a successor is an item still parked under it
67146714
* (#27877). Empty when nothing is suspended under that id; stops at the
67156715
* last resolvable link when a child row is gone (that run is where
6716-
* `resumeInternal` will continue).
6716+
* `resumeInternal` will continue). The caller's own id is seen before the
6717+
* walk starts, so a corrupt correlation cycle back to it ends the chain
6718+
* there instead of listing the caller's run again as its own child.
67176719
*/
67186720
private async resolveSuspensionChain(runId: string): Promise<SuspendedRun[]> {
6719-
const seen = new Set<string>();
6721+
const seen = new Set<string>([runId]);
67206722
const chain: SuspendedRun[] = [];
67216723
let run = await this.loadSuspendedRun(runId);
67226724
for (let depth = 0; run && depth < AutomationEngine.MAX_SUSPENSION_CHAIN_DEPTH; depth++) {

‎packages/services/service-automation/src/map-parent-resume-refusal.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,20 @@ describe('a parent-id resume of a map whose item is still parked (#27877)', () =
169169
expect(downstream).toEqual([]);
170170
});
171171

172+
it('names the item run, never the caller\'s own, when a corrupt correlation cycles back to the caller', async () => {
173+
const paused = await launch();
174+
const [item1] = parked('one_item');
175+
// Corrupt the item's row so it points back at its parent: map:A → map:B → A.
176+
(engine as any).suspendedRuns.get(item1!.runId).correlation = `map:${paused.runId}`;
177+
178+
const refused = await engine.resume(paused.runId!, {});
179+
180+
expect(refused.code).toBe(MAP_ITEM_PENDING_REFUSAL_CODE);
181+
expect(refused.error).toContain(`Resume run '${item1!.runId}' instead`);
182+
expect(refused.error).not.toContain(`Resume run '${paused.runId}'`);
183+
expect(mapState(paused.runId!)).toEqual({ started: 1, results: [] });
184+
});
185+
172186
it('leaves a subflow parent-id resume unchanged: the signal is delegated to the child', async () => {
173187
engine.registerFlow('child_flow', itemFlow('child_flow') as never);
174188
engine.registerFlow('parent_flow', hostFlow('parent_flow', {

0 commit comments

Comments
 (0)