From d008938dd78081ca6f1508262f44f6fb261e44c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:04:26 +0000 Subject: [PATCH 1/2] docs(flows): split the subflow-strand paragraph into its three cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paragraph conflated three outcomes of a paused subflow chain into one prose block, and its closing sentence ("an ancestor is never stranded, because resuming it is not what moves it") was written for the child-strand case only. #15556 (PR #17908, merged c8a006fc41) shipped a third case the paragraph never described: the child completes, bubbleToParent resumes the parent, and the parent's own downstream node throws — the parent itself lands on the engine's 'stranded' exit and is reported on resumeFailure. Measured against origin/main a88a1bb39: - packages/services/service-automation/src/engine.ts:1737 (SubflowParentStrand), :7434 (the 'stranded' exit bubbleToParent records it on) - packages/plugins/plugin-approvals/src/approval-service.ts:3476 (the resumeFailure shape: RESUME_FAILED / stranded / repairable) --- content/docs/automation/flows.mdx | 35 +++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 074b4657f05..cfe14b31897 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -1102,17 +1102,30 @@ the chain resolves from either end: parked on an `approval`, resuming the parent is refused too. A child that fails terminally after the pause fails every waiting ancestor, so -no run is stranded as resumable-forever. **When the child's failure is a -strand** — its resume consumed the pause and a downstream node threw — each -ancestor's consumed pause is recorded too, and the repair verb puts the whole -chain back in one call: `POST …/runs/{runId}/restore-suspension` on **any** -member re-arms every member, deepest first, so the continuation re-issued on -the run you named flows back up through the ancestors instead of completing a -leaf into a parent that never continues. Re-arming an ancestor is all the verb -does — an ancestor is never *stranded*, because resuming it is not what moves -it. A cascade from a child that is **not** repairable records no ancestor -snapshot, deliberately, so the verb never promises a chain repair it could not -finish. +no run is stranded as resumable-forever — that sentence is scoped to exactly +this case, not the two below it. **When the child's failure is a strand** — +its resume consumed the pause and a downstream node threw — each ancestor's +consumed pause is recorded too, and the repair verb puts the whole chain back +in one call: `POST …/runs/{runId}/restore-suspension` on **any** member +re-arms every member, deepest first, so the continuation re-issued on the run +you named flows back up through the ancestors instead of completing a leaf +into a parent that never continues. Re-arming an ancestor is all the verb does +in this case — no ancestor is stranded here, because resuming it is not what +moves it. A cascade from a child that is **not** repairable records no +ancestor snapshot, deliberately, so the verb never promises a chain repair it +could not finish. + +**A third case is the opposite: the bubble itself is what strands an +ancestor.** The child completes cleanly, `bubbleToParent` resumes the parent +on the child's behalf, and the parent's own downstream node throws. There the +bubble — not a resume the caller issued — is exactly what moves the parent, so +the claim above does not hold for it: the parent lands on the engine's +`'stranded'` exit, terminal, and repairable only by an operator's +`restoreConsumedSuspension`. The child's own resume genuinely succeeded, so its +resumer (an approvals decision door, a wait timer) is told the resume +succeeded; as of #15556 an approval `decide()` also reports the stranded +parent on `resumeFailure` (`{ code: 'RESUME_FAILED', runId: '', +status: 'stranded', repairable: true }`). See the worked example pair in the showcase app: `showcase_project_closure` invokes the reusable `showcase_closure_signoff` From 28c110796165fd66381cb565162c787139679d0a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:15:16 +0000 Subject: [PATCH 2/2] docs(flows): drop self-reference, name the repair verb an operator can call PM review on PR #20377: remove the two sentences that talk about the page's own prose ("that sentence is scoped to..." / "the claim above does not hold for it") and state the scoping as behaviour instead. Name the third case's repair verb the same way the other two cases do -- the `restore-suspension` REST verb (routed to `restoreConsumedSuspension` at packages/runtime/src/domains/automation.ts:2752), issued on the parent's run id, the one `resumeFailure` names -- rather than only the engine method name. --- content/docs/automation/flows.mdx | 40 +++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index cfe14b31897..c43cf4ba0da 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -1102,30 +1102,30 @@ the chain resolves from either end: parked on an `approval`, resuming the parent is refused too. A child that fails terminally after the pause fails every waiting ancestor, so -no run is stranded as resumable-forever — that sentence is scoped to exactly -this case, not the two below it. **When the child's failure is a strand** — -its resume consumed the pause and a downstream node threw — each ancestor's -consumed pause is recorded too, and the repair verb puts the whole chain back -in one call: `POST …/runs/{runId}/restore-suspension` on **any** member -re-arms every member, deepest first, so the continuation re-issued on the run -you named flows back up through the ancestors instead of completing a leaf -into a parent that never continues. Re-arming an ancestor is all the verb does -in this case — no ancestor is stranded here, because resuming it is not what -moves it. A cascade from a child that is **not** repairable records no -ancestor snapshot, deliberately, so the verb never promises a chain repair it -could not finish. +none of them is left stranded as resumable-forever. **When the child's failure +is a strand** — its resume consumed the pause and a downstream node threw — +each ancestor's consumed pause is recorded too, and the repair verb puts the +whole chain back in one call: `POST …/runs/{runId}/restore-suspension` on +**any** member re-arms every member, deepest first, so the continuation +re-issued on the run you named flows back up through the ancestors instead of +completing a leaf into a parent that never continues. Re-arming an ancestor is +all the verb does in this case — no ancestor is stranded here, because +resuming it is not what moves it. A cascade from a child that is **not** +repairable records no ancestor snapshot, deliberately, so the verb never +promises a chain repair it could not finish. **A third case is the opposite: the bubble itself is what strands an ancestor.** The child completes cleanly, `bubbleToParent` resumes the parent on the child's behalf, and the parent's own downstream node throws. There the -bubble — not a resume the caller issued — is exactly what moves the parent, so -the claim above does not hold for it: the parent lands on the engine's -`'stranded'` exit, terminal, and repairable only by an operator's -`restoreConsumedSuspension`. The child's own resume genuinely succeeded, so its -resumer (an approvals decision door, a wait timer) is told the resume -succeeded; as of #15556 an approval `decide()` also reports the stranded -parent on `resumeFailure` (`{ code: 'RESUME_FAILED', runId: '', -status: 'stranded', repairable: true }`). +bubble — not a resume the caller issued — is exactly what moves the parent, +and it is the parent, not the child, that lands on the engine's `'stranded'` +exit, terminal. The child's own resume genuinely succeeded, so its resumer +(an approvals decision door, a wait timer) is told the resume succeeded; as +of #15556 an approval `decide()` also reports the stranded parent on +`resumeFailure` (`{ code: 'RESUME_FAILED', runId: '', status: +'stranded', repairable: true }`). Repair it the same way: the same +`restore-suspension` verb (`restoreConsumedSuspension` underneath), issued on +the parent's run id — the `runId` `resumeFailure` names, not the child's. See the worked example pair in the showcase app: `showcase_project_closure` invokes the reusable `showcase_closure_signoff`