diff --git a/.changeset/27971-map-chain-cycle-root.md b/.changeset/27971-map-chain-cycle-root.md new file mode 100644 index 00000000000..1a975432a67 --- /dev/null +++ b/.changeset/27971-map-chain-cycle-root.md @@ -0,0 +1,12 @@ +--- +'@objectstack/service-automation': patch +--- + +fix(service-automation): a corrupt correlation cycle back to the resumed run no longer names that run as its own child + +Clause-②: no +Metadata-protocol: none + +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. + +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. diff --git a/docs/qa/platform-checklist/areas/automation.json b/docs/qa/platform-checklist/areas/automation.json index 52a4d2c4c2a..5a00af33024 100644 --- a/docs/qa/platform-checklist/areas/automation.json +++ b/docs/qa/platform-checklist/areas/automation.json @@ -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": 8, + "revision": 9, "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"], @@ -1690,7 +1690,7 @@ "evidence": "the 400 bodies from both doors, keys shown" }, { - "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", + "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", "oracle": "api", "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)", "evidence": "per-arm response + the post-refusal run state + the successful later resume" @@ -1728,6 +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 · 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", @@ -1793,6 +1794,12 @@ "date": "2026-10-27", "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", "ref": "#25807" + }, + { + "revision": 9, + "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" } ] }, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 2a2c5a26bf8..c49328815e7 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -5945,6 +5945,8 @@ export class ObjectStackClient { * and the pause is gone for good. The refusals that leave the suspension * 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), * `STORE_UNAVAILABLE` (503). */ resume: async ( diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index bbc16472be1..c8aea163c19 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -307,7 +307,9 @@ if (result.success) { `AutomationResult` fields: `success`, `output?`, `error?`, `durationMs?`, `code?`, `status?`, `runId?`, `screen?`, `successMessage?`, `errorMessage?`, `summary?`. The machine-readable classification is `code` (not `errorCode`) — resume refusals such as -`RUN_NOT_FOUND`, `STORE_UNAVAILABLE`, `RESUME_IN_PROGRESS`, plus the trigger-time +`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 `FLOW_DISABLED` / `FLOW_NO_START_NODE`. ⚠️ `runAs` on `AutomationContext` is derived by the engine from the flow definition — diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index c7f9dad2bd2..c3ff057cc35 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -6687,10 +6687,12 @@ export class AutomationEngine implements IAutomationService { * and each `map:` link with a successor is an item still parked under it * (#27877). Empty when nothing is suspended under that id; stops at the * last resolvable link when a child row is gone (that run is where - * `resumeInternal` will continue). + * `resumeInternal` will continue). The caller's own id is seen before the + * walk starts, so a corrupt correlation cycle back to it ends the chain + * there instead of listing the caller's run again as its own child. */ private async resolveSuspensionChain(runId: string): Promise { - const seen = new Set(); + const seen = new Set([runId]); const chain: SuspendedRun[] = []; let run = await this.loadSuspendedRun(runId); for (let depth = 0; run && depth < AutomationEngine.MAX_SUSPENSION_CHAIN_DEPTH; depth++) { diff --git a/packages/services/service-automation/src/map-parent-resume-refusal.test.ts b/packages/services/service-automation/src/map-parent-resume-refusal.test.ts index ff1ff8337e7..74a6036e86b 100644 --- a/packages/services/service-automation/src/map-parent-resume-refusal.test.ts +++ b/packages/services/service-automation/src/map-parent-resume-refusal.test.ts @@ -169,6 +169,20 @@ describe('a parent-id resume of a map whose item is still parked (#27877)', () = expect(downstream).toEqual([]); }); + it('names the item run, never the caller\'s own, when a corrupt correlation cycles back to the caller', async () => { + const paused = await launch(); + const [item1] = parked('one_item'); + // Corrupt the item's row so it points back at its parent: map:A → map:B → A. + (engine as any).suspendedRuns.get(item1!.runId).correlation = `map:${paused.runId}`; + + const refused = await engine.resume(paused.runId!, {}); + + expect(refused.code).toBe(MAP_ITEM_PENDING_REFUSAL_CODE); + expect(refused.error).toContain(`Resume run '${item1!.runId}' instead`); + expect(refused.error).not.toContain(`Resume run '${paused.runId}'`); + expect(mapState(paused.runId!)).toEqual({ started: 1, results: [] }); + }); + it('leaves a subflow parent-id resume unchanged: the signal is delegated to the child', async () => { engine.registerFlow('child_flow', itemFlow('child_flow') as never); engine.registerFlow('parent_flow', hostFlow('parent_flow', {