service-automation: a resume consumes the pause BEFORE running downstream nodes, so any node that throws leaves the run terminally unresumable — and the only inspector for it reports all clear #13909

Description

@os-steve

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

  1. 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.
  2. 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.
  3. Give the condition a name the platform can report, so it can be queried rather than inferred from an error string in another package.
  4. 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)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
       blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      Skip to content

      service-automation: a resume consumes the pause BEFORE running downstream nodes, so any node that throws leaves the run terminally unresumable — and the only inspector for it reports all clear #13909

      Description

      @os-steve

      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

      1. 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.
      2. 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.
      3. Give the condition a name the platform can report, so it can be queried rather than inferred from an error string in another package.
      4. 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)

      Metadata

      Metadata

      Assignees

      No one assigned

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          Skip to content

          service-automation: a resume consumes the pause BEFORE running downstream nodes, so any node that throws leaves the run terminally unresumable — and the only inspector for it reports all clear #13909

          Description

          @os-steve

          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

          1. 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.
          2. 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.
          3. Give the condition a name the platform can report, so it can be queried rather than inferred from an error string in another package.
          4. 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)

          Metadata

          Metadata

          Assignees

          No one assigned

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              service-automation: a resume consumes the pause BEFORE running downstream nodes, so any node that throws leaves the run terminally unresumable — and the only inspector for it reports all clear #13909

              Description

              @os-steve

              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

              1. 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.
              2. 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.
              3. Give the condition a name the platform can report, so it can be queried rather than inferred from an error string in another package.
              4. 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)

              Metadata

              Metadata

              Assignees

              No one assigned

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  Skip to content

                  service-automation: a resume consumes the pause BEFORE running downstream nodes, so any node that throws leaves the run terminally unresumable — and the only inspector for it reports all clear #13909

                  Description

                  @os-steve

                  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

                  1. 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.
                  2. 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.
                  3. Give the condition a name the platform can report, so it can be queried rather than inferred from an error string in another package.
                  4. 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)

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Type

                    No type

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      Skip to content

                      service-automation: a resume consumes the pause BEFORE running downstream nodes, so any node that throws leaves the run terminally unresumable — and the only inspector for it reports all clear #13909

                      Description

                      @os-steve

                      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

                      1. 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.
                      2. 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.
                      3. Give the condition a name the platform can report, so it can be queried rather than inferred from an error string in another package.
                      4. 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)

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Type

                        No type

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          Skip to content

                          service-automation: a resume consumes the pause BEFORE running downstream nodes, so any node that throws leaves the run terminally unresumable — and the only inspector for it reports all clear #13909

                          Description

                          @os-steve

                          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

                          1. 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.
                          2. 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.
                          3. Give the condition a name the platform can report, so it can be queried rather than inferred from an error string in another package.
                          4. 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)

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Type

                            No type

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              Skip to content

                              service-automation: a resume consumes the pause BEFORE running downstream nodes, so any node that throws leaves the run terminally unresumable — and the only inspector for it reports all clear #13909

                              Description

                              @os-steve

                              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

                              1. 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.
                              2. 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.
                              3. Give the condition a name the platform can report, so it can be queried rather than inferred from an error string in another package.
                              4. 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)

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions