Skip to content

Commit 97466dd

Browse files
huangyiireneclaude
andauthored
fix(service-automation): a resumed child's refusal rolls up on both legs — delegated resume and up-bubble (#19158)
Fixes #18714 Clause-②: no A child flow that durably PAUSES and only then refuses reached its parent on two resumed legs, and neither had an arm for it. The triage instruction on the card is verbatim 「⛔ 不要把它折进 PR #18706 …… **另起一张 PR,并让两条腿各有一条能变红的钉**」 — so this is its own PR, and the two legs carry two independently reddening pins. ## The two legs, and why they need two pins They fail differently, which is the whole reason one "a refusal is handled" assertion would not do: | leg | measured on `origin/main` before this change | class | |---|---|---| | **Delegated resume** — `engine.resume(parentRunId)` | parent answers `{ success: true, successMessage: … }`, parent run row records `completed`, the node downstream of the `subflow` **runs** | refusal LOST, fail-open | | **Up-bubble** — `engine.resume(childRunId)` | child row records `refused` correctly; parent stays `paused` and stays in `listSuspendedRuns()` indefinitely | run LEAKED | The delegation block tested only `paused` and `!success`; a refused child is neither, because `finishRefusedRun` answers `{ success: true, status: 'refused' }` — *a refusal is a successful evaluation that says no*. And `bubbleToParent` was called on the completion path alone, so a child resumed to a refusal resolved exactly one of the two runs it is responsible for. Neither leg is a regression of #18110 / #18555. That delivery named the two executors and matched its ruling exactly; its own changeset files this card for the remaining half, naming the resumed leg as 「the one a screen flow actually takes」. ## The mechanism - Each leg records the child's refusal into one local, and **one throw site** inside the resume's traversal `try` raises the engine's existing internal refusal signal. The refusal therefore leaves through the same `finishRefusedRun` chokepoint every other producer already uses. ⛔ Deliberately not a second terminal exit per leg — this file's history is a list of outcomes that became a function of which route a run took. - The throw site sits **past the consumption** (`claimAdvance` / `forgetSuspendedRun`) and **before the traversal**: the parent's own pause is consumed exactly as on every other resume exit, so the terminal row and the pause can never disagree, and nothing downstream of the awaiting node runs. - The parent's terminal row reads `refused`, carrying the child's already-rendered `refusalMessage` verbatim, with its own `successMessage` silent. ⛔ Not `failed`: a refusal must not consume retry budget, must not be routable by a `fault` edge and must not be counted in `nodes[].failures`. - The up-bubble arm genuinely **resumes** the parent (with the refusal as its own argument, ⛔ never folded into the resume signal — that map is the parent's variables, and a refusal is control flow), so chains of any depth resolve by the same induction completions already rely on. ⛔ Not a direct ancestor walk like the failure cascade's, which records ancestors `failed` — the wrong word here. - The child's #4354 rollup survives on both legs, for the same reason it survives on the synchronous one. ## Clause-② — why `no` Nothing published moves. Both arms are inside `AutomationEngine`'s private `resumeInternal` / `bubbleToParent`; the one new type (`ChildRunRefusal`) is module-private and not barrel-exported. No schema key, no closed-set member, no export, no registry entry. ⭐ In particular **no new error code and no `ERROR_CODE_LEDGER` / `StandardErrorCode` entry is minted** — the refusal is named by the existing internal signal type and the published `refused` status (#15788), which is the same call the sibling card #18881 made and an at-tier review confirmed. Zero `packages/spec`. ## #18112 — read before choosing a mechanism, and this stays outside it #18112's ruling deliberately left the region/rethrow territory closed: option B not implemented, **no container taught to rethrow**. This change teaches no container anything. It adds no arm to `runRegion`, to `try_catch`, to `parallel` or to any container executor; it touches only the resume machinery's own two seams, which are outside every region body by construction — a region body runs synchronously inside the enclosing run and cannot carry a durable pause at all (#18881's whole premise). So there is nothing here for a container to rethrow or to swallow. ## The #19140 adjacency, checked Re-measured on this branch: `isRegionSuspensionRefusal` has 3 sites in `engine.ts` (import, the one-refusal-one-failure suppression, the inner-boundary rethrow) and **none is on either resume leg**. The two predicates are structurally disjoint — the region refusal is branded with a registered `Symbol.for` on an `Error` subclass, the flow refusal is a non-`Error` sentinel carrying `__flowRefused` — so neither can be mistaken for the other. A run that resumes INTO a structured region and meets `FlowRegionSuspensionRefusalError` still falls to the generic failure arm and is failed, which is #18881's intended outcome; this change does not intercept it. ⇒ **the resume legs do not need to handle it**, measured rather than assumed. ## Verification - `pnpm --filter @objectstack/service-automation test` — **140 files / 1671 tests, all green**, so the synchronous leg (#18110 / #18555), the region refusal (#18881) and the retryable delegated resume-bag codes (#14379) keep their pins. - `pnpm --filter @objectstack/service-automation typecheck` — green, including `check:test-typecheck`. - **Ablation, both legs, one at a time** (`scripts/ablation-replace.mjs`, anchor-must-hit + on-disk blob proof + proven restore; the pins resolve `./engine.js` from package source, which the reddening itself demonstrates): | mutation | result | |---|---| | delete the delegated-leg detection | **4 leg-1 assertions red** (`expected undefined to be 'refused'`; `[ 'child-work', 'downstream' ]` where `[ 'child-work' ]` was expected) · all 5 leg-2 assertions and both controls **green** | | delete the up-bubble of the refusal | **3 leg-2 assertions red** (`expected 'paused' to be 'refused'`; `hasSuspendedRun` → `expected true to be false`) · all 4 leg-1 assertions and both controls **green** | Two distinct failure signatures, each reachable only through its own arm — ⛔ not one measurement restated. Both restore legs proved `blob == HEAD` and an empty `git diff HEAD`. ## Acceptance notes - Two of the leg-2 assertions are measured green on **both** sides of the ablation and are annotated in the file as such, so a reader never mistakes them for pins: `downstream nodes do NOT run` holds against the defect too (a parent that is never resumed also never walks on) and is kept as the pin on the WRONG fix — bubbling this refusal as a completion; and `the child's own caller is told the truth about the CHILD` is an invariance pin on the half a fix here could break. - Noted, not filed: the delegated-resume block special-cases the `subflow:` correlation only, so a `map` parent reaches its child's outcome exclusively through the up-bubble. That is consistent and covered by leg 2, and the asymmetry is a shape of the two node types rather than a defect. Successor: no PR or person is known to be heading for this seam. - Noted, not filed: `bubbleToParent`'s per-outcome #4632 grading is unreached on a refusal, because a refused parent answers `success: true`. That is correct — a refusal is not a degradation — but it means the `stranded` strand-recording arm is exercised only by a parent that fails downstream of a refusal, which the existing `subflow-bubble-strand-log-level` pin already drives from the completion side. Successor: no PR or person is known to be heading for this seam. - No label writes were made from here: the dispatch budget is the report comment and this PR. If `needs:contract-review` or a size label is owed, it is the seat's to apply. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8b8258d commit 97466dd

3 files changed

Lines changed: 525 additions & 4 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): a child that PAUSES and then refuses now rolls its refusal up on both resumed legs — the delegated resume and the up-bubble (#18714)
6+
7+
**Clause-②: no** — nothing published moves. The two arms are added inside `AutomationEngine`'s private `resumeInternal` / `bubbleToParent`, and the one new type (`ChildRunRefusal`) is module-private, not barrel-exported. No schema key, no closed-set member, no export and no registry entry changes; `refused` has been a published terminal status since #15788 and no new status, code or `ERROR_CODE_LEDGER` entry is minted here.
8+
9+
#18110 / #18555 gave the `subflow` and `map` executors an arm for `child.status === 'refused'`, and that arm reads the value `engine.execute` **returned** to them — so it covers exactly one shape: a child that runs straight through without pausing. A child that durably PAUSES first (a nested `approval` / `screen` / `wait`) never returns through that call at all. Its outcome reaches its parent on one of two **resumed** legs instead, and neither had an arm. Both pre-date #18110/#18555 and neither is a regression of it; that delivery named the two executors and matched its ruling exactly, and its own changeset filed this card for the remaining half.
10+
11+
The two legs failed **differently**, so each gets its own arm and its own pin:
12+
13+
- **Delegated resume** — `engine.resume(parentRunId)`, the path a screen-flow runner takes when it holds one stable run id and posts every wizard step to it. The delegation block tested only `paused` and `!success`; a refused child is neither, so it fell through the ordinary success exit. Measured: the parent answered `{ success: true, successMessage: … }`, its run row recorded **`completed`**, and the node downstream of the `subflow` **ran**. The refusal was lost **fail-open** — the identical shape #18110 closed on the synchronous leg.
14+
- **Up-bubble** — `engine.resume(childRunId)`. `bubbleToParent` was called on the completion path only, so a child resumed to a refusal resolved exactly one of the two runs it is responsible for. Measured: the child row recorded `refused` correctly and the parent stayed **`paused`**, in `listSuspendedRuns()`, indefinitely. Nothing looks wrong; a run is **leaked**.
15+
16+
What changed:
17+
18+
- **One terminal shape, both legs.** Each leg records the child's refusal and hands it to a single throw site inside the resume's traversal `try`, which raises the engine's existing internal refusal signal — so the refusal leaves through the same `finishRefusedRun` chokepoint every other producer already uses. ⛔ Deliberately not a second terminal exit per leg: this file's history is a list of outcomes that became a function of which route a run took.
19+
- **The throw site sits past the consumption and before the traversal.** The parent's own suspension is consumed exactly as it is on every other way a resume can end, so the terminal row and the pause can never disagree; and nothing downstream of the awaiting node runs.
20+
- **The parent's terminal row reads `refused`**, carrying the child's already-rendered `refusalMessage` verbatim, and the parent's own `successMessage` stays silent. ⛔ Not `failed`: a refusal is not a failure — it must not consume retry budget, must not be routable by a `fault` edge and must not be counted in `nodes[].failures`.
21+
- **The child's #4354 rollup (`selected` / `acted` / `unmeasuredEffect`) survives on both legs**, for the same reason it survives on the synchronous one: the refusal is raised after the awaiting step has been credited. A child that refused really can have written rows before it said no.
22+
- **Chains of any depth resolve**, because the up-bubble arm resumes the parent for real — the parent consumes its pause, records its own terminal row and bubbles to *its* parent in turn, by the same induction completions already rely on. ⛔ Not a direct ancestor walk like the failure cascade's: that verb records ancestors `failed`, which is the wrong word here.
23+
- **The child's own resumer is told exactly what it was told before** — the bubble is still best-effort at the engine layer and never rewrites the child's envelope.
24+
25+
Unchanged: the synchronous leg (#18110/#18555), the region-containment refusal (#18881 — a different error type on a different path, which neither resume leg raises or consumes), the retryable delegated resume-bag codes (#14379), the terminal child-failure cascade, and the `RESUME_IN_PROGRESS` / `STORE_UNAVAILABLE` / stranded gradings on the bubble.
26+
27+
⚠️ **Behavioural direction**: a run that previously finished green over a refusing paused child now terminates `refused`, and a parent that previously sat in `listSuspendedRuns()` forever is now resolved. Both are the authored outcome arriving where it never did; a composition that depended on the fail-open was depending on the defect.

0 commit comments

Comments
 (0)