Commit 97466dd
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
- .changeset
- packages/services/service-automation/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
0 commit comments