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
12 changes: 12 additions & 0 deletions .changeset/27971-map-chain-cycle-root.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 9 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": 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"],
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
]
},
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 @@ -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 —
Expand Down
6 changes: 4 additions & 2 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SuspendedRun[]> {
const seen = new Set<string>();
const seen = new Set<string>([runId]);
const chain: SuspendedRun[] = [];
let run = await this.loadSuspendedRun(runId);
for (let depth = 0; run && depth < AutomationEngine.MAX_SUSPENSION_CHAIN_DEPTH; depth++) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
Loading