Split out of #13807 by the domain:services PM seat (#6021) after its step-1 measurement (comment on that card, PR #13899 carries the reproducing instrument). #13807 reported this through the approvals reject door; the measurement established the strand is general to workflow resume and lives here, so this card owns it and #13807 keeps only the approvals-side atomicity question.
⚠️No re-laning: packages/services/service-automation is packages/services/* ⇒ domain:services. Both halves stay in one lane; the split is by blast radius and reviewer, not by ownership.
The mechanism — one ordering, verified on origin/main
AutomationEngine.resumeInternal consumes the suspension before running the downstream nodes:
engine.ts:4635 await this.forgetSuspendedRun(run, 'resumed');
engine.ts:4649 await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
⇒ A node that throws at :4649 throws with the pause already gone, and the catch arm records the run failed. The run cannot be resumed again, because there is no suspension left to resume.
⭐ There is no run state called stranded.AutomationResult.status is 'completed' | 'paused' | 'failed' (packages/spec/src/contracts/automation-service.ts:281), and the word stranded appears zero times in service-automation — it exists only in plugin-approvals' error prose and tests. So the condition has no name the platform can report, query or act on. That is part of the defect, not a naming quibble.
Reach — five callers, and the decisive one is not approvals
All five reach the identical arm. The decisive one is the generic REST doorPOST /api/v1/automation/:name/runs/:runId/resume, which answers HTTP 400 FLOW_FAILED; its own in-place comment already says "what reaches HERE consumed its pause and ran". Both wait-node paths and the subflow recursion reach it too.
⇒ ⛔ An approvals-only fix leaves four callers stranding exactly as today. That is why this card exists.
It is terminal — measured, with a zero-control
Nothing moves a run out: resume answers RUN_NOT_FOUND, cancelRun is a no-op on it, the REST run surface has no cancel or retry route, the CLI has no run commands, and none of the engine's 14 public methods does it. The scan for recovery verbs (restartRun|retryRun|unstrand|reviveRun|reopenRun|requeueRun) returns nothing, while the same scan shape over the engine finds the 14 verbs that do exist — so the zero is a reading.
⭐ And the one inspector that should catch it reports all clear
ApprovalService.inspectStrandedRequests is structurally blind to this shape: it scans ['approved','rejected','returned'] so it does see the row, but its oracle if (terminal) continueskips it — because the failed resume wrote a terminal failed log row. releaseDeadRunRequests scans status:'pending' only.
⇒ An operator today has no repair verb AND an inspector that says everything is fine. That combination is why this class stayed silent.
Deliverables
- Decide the resume ordering. Either the suspension survives a downstream throw (so the run stays resumable), or it is consumed and something else guarantees recoverability. ⛔ "The pause is gone and the run is
failed" is not an acceptable end state for a node that merely threw. ⚠️ Whichever way it goes changes resume semantics for every pausing node type — that blast radius is why this is its own card. - An operator path out of a terminal run. Even a documented manual step is acceptable as a first increment; a state a deployment can enter and never leave is not.
- Give the condition a name the platform can report, so it can be queried rather than inferred from an error string in another package.
- Widen the inspector's oracle so this shape is visible. ⚠️ This is also the prerequisite for sizing the problem — see below.
⛔ Boundaries
⚠️ Not measured — needs the deployment, not this repo
How many runs are already stranded is unknown, and inspectStrandedRequests' measured blindness means the in-product answer would be 0 regardless. Sizing needs an operator census over sys_automation_run (status='failed') joined against sys_approval_request (terminal status with flow_run_id set). ⛔ Do not size the remedy from an in-repo zero — that repeats #13568's shape, a remedy that prevents future occurrences while saying nothing about the rows already stuck.
Related
#13807 (the parent report and the approvals-side half) · #13568 (ruled; auto-cancel prevents future orphans, says nothing about this) · PR #13899 (the reproducing instrument: a plain pausing node, resumeAuthority:'any', through the generic engine.resume() door, zero approvals involvement)
Split out of #13807 by the
domain:servicesPM seat (#6021) after its step-1 measurement (comment on that card, PR #13899 carries the reproducing instrument). #13807 reported this through the approvals reject door; the measurement established the strand is general to workflow resume and lives here, so this card owns it and #13807 keeps only the approvals-side atomicity question.packages/services/service-automationispackages/services/*⇒domain:services. Both halves stay in one lane; the split is by blast radius and reviewer, not by ownership.The mechanism — one ordering, verified on
origin/mainAutomationEngine.resumeInternalconsumes the suspension before running the downstream nodes:⇒ A node that throws at
:4649throws with the pause already gone, and the catch arm records the runfailed. The run cannot be resumed again, because there is no suspension left to resume.⭐ There is no run state called
stranded.AutomationResult.statusis'completed' | 'paused' | 'failed'(packages/spec/src/contracts/automation-service.ts:281), and the wordstrandedappears zero times inservice-automation— it exists only inplugin-approvals' error prose and tests. So the condition has no name the platform can report, query or act on. That is part of the defect, not a naming quibble.Reach — five callers, and the decisive one is not approvals
All five reach the identical arm. The decisive one is the generic REST door
POST /api/v1/automation/:name/runs/:runId/resume, which answers HTTP 400FLOW_FAILED; its own in-place comment already says "what reaches HERE consumed its pause and ran". Both wait-node paths and the subflow recursion reach it too.⇒ ⛔ An approvals-only fix leaves four callers stranding exactly as today. That is why this card exists.
It is terminal — measured, with a zero-control
Nothing moves a run out:
resumeanswersRUN_NOT_FOUND,cancelRunis a no-op on it, the REST run surface has no cancel or retry route, the CLI has no run commands, and none of the engine's 14 public methods does it. The scan for recovery verbs (restartRun|retryRun|unstrand|reviveRun|reopenRun|requeueRun) returns nothing, while the same scan shape over the engine finds the 14 verbs that do exist — so the zero is a reading.⭐ And the one inspector that should catch it reports all clear
ApprovalService.inspectStrandedRequestsis structurally blind to this shape: it scans['approved','rejected','returned']so it does see the row, but its oracleif (terminal) continueskips it — because the failed resume wrote a terminalfailedlog row.releaseDeadRunRequestsscansstatus:'pending'only.⇒ An operator today has no repair verb AND an inspector that says everything is fine. That combination is why this class stayed silent.
Deliverables
failed" is not an acceptable end state for a node that merely threw.⛔ Boundaries
POST /api/v1/approvals/requests/{id}/rejectreturns 500 while its effect lands AND strands the workflow run — three inconsistent outcomes from one call #13807's, and it is much cheaper to specify after this card decides whether a failed mid-resume can leave the pause intact — "neither happens" is only implementable if the engine can roll the suspension back.POST /api/v1/approvals/requests/{id}/rejectreturns 500 while its effect lands AND strands the workflow run — three inconsistent outcomes from one call #13807 names that explicitly and the same posture holds here.How many runs are already stranded is unknown, and
inspectStrandedRequests' measured blindness means the in-product answer would be0regardless. Sizing needs an operator census oversys_automation_run(status='failed') joined againstsys_approval_request(terminal status withflow_run_idset). ⛔ Do not size the remedy from an in-repo zero — that repeats #13568's shape, a remedy that prevents future occurrences while saying nothing about the rows already stuck.Related
#13807 (the parent report and the approvals-side half) · #13568 (ruled; auto-cancel prevents future orphans, says nothing about this) · PR #13899 (the reproducing instrument: a plain pausing node,
resumeAuthority:'any', through the genericengine.resume()door, zero approvals involvement)