fix(service-automation): a resumed child's refusal rolls up on both legs — delegated resume and up-bubble - #19158
Conversation
The delegated-resume block tested only `paused` / `!success`, so a child that paused and then refused fell through the ordinary success exit and the parent recorded `completed` with its downstream node run. `bubbleToParent` was called on the completion path alone, so a child resumed to a refusal left its parent parked in `listSuspendedRuns()` forever. Both legs now record the refusal and hand it to one throw site inside the resume traversal, past the consumption and before the traversal, so the run terminates through the existing `finishRefusedRun` chokepoint. Claude-Session: https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 2 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin de0c11fc31351ee9cf20ace5ab23210f1803b987 && git checkout de0c11fc31351ee9cf20ace5ab23210f1803b987
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6819dcbd610397ded7a1533dbedf8a481d20fbdf b2203ba3b726f624eb4b84bd8aec3332a0a0b11c && git checkout -B drift-repro 6819dcbd610397ded7a1533dbedf8a481d20fbdf && git merge --no-ff b2203ba3b726f624eb4b84bd8aec3332a0a0b11c
node scripts/docs-audit/affected-docs.mjs --json 6819dcbd610397ded7a1533dbedf8a481d20fbdf
|
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:
origin/mainbefore this changeengine.resume(parentRunId){ success: true, successMessage: … }, parent run row recordscompleted, the node downstream of thesubflowrunsengine.resume(childRunId)refusedcorrectly; parent stayspausedand stays inlistSuspendedRuns()indefinitelyThe delegation block tested only
pausedand!success; a refused child is neither, becausefinishRefusedRunanswers{ success: true, status: 'refused' }— a refusal is a successful evaluation that says no. AndbubbleToParentwas 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
tryraises the engine's existing internal refusal signal. The refusal therefore leaves through the samefinishRefusedRunchokepoint 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.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.refused, carrying the child's already-renderedrefusalMessageverbatim, with its ownsuccessMessagesilent. ⛔ Notfailed: a refusal must not consume retry budget, must not be routable by afaultedge and must not be counted innodes[].failures.failed— the wrong word here.Clause-② — why
noNothing published moves. Both arms are inside
AutomationEngine's privateresumeInternal/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 noERROR_CODE_LEDGER/StandardErrorCodeentry is minted — the refusal is named by the existing internal signal type and the publishedrefusedstatus (#15788), which is the same call the sibling card #18881 made and an at-tier review confirmed. Zeropackages/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, totry_catch, toparallelor 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:
isRegionSuspensionRefusalhas 3 sites inengine.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 registeredSymbol.foron anErrorsubclass, the flow refusal is a non-Errorsentinel carrying__flowRefused— so neither can be mistaken for the other. A run that resumes INTO a structured region and meetsFlowRegionSuspensionRefusalErrorstill 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 ([finding] service-automation: asubflowchild that endsrefusedis rolled up by the parent as an ordinary success — the refusal reaches nobody #18110 / [finding] service-automation:maprolls arefusedchild up as an ordinary success too — the same fail-open shape as #18110, second file #18555), the region refusal (service-automation: a durable suspension inside a structured region (loop / parallel branch / try_catch) must fail the run with a named error, not leave progress state and report success — runtime half of #15646 ruling D #18881) and the retryable delegated resume-bag codes (service-automation: a subflow parent resume treats the delegated child screen's RETRYABLE refusal (INVALID_SCREEN_INPUT) as a terminal child failure — the parent is failed, the still-paused child is orphaned, and the corrected retry answers RUN_NOT_FOUND #14379) keep their pins.pnpm --filter @objectstack/service-automation typecheck— green, includingcheck:test-typecheck.scripts/ablation-replace.mjs, anchor-must-hit + on-disk blob proof + proven restore; the pins resolve./engine.jsfrom package source, which the reddening itself demonstrates):expected undefined to be 'refused';[ 'child-work', 'downstream' ]where[ 'child-work' ]was expected) · all 5 leg-2 assertions and both controls greenexpected 'paused' to be 'refused';hasSuspendedRun→expected true to be false) · all 4 leg-1 assertions and both controls greenTwo distinct failure signatures, each reachable only through its own arm — ⛔ not one measurement restated. Both restore legs proved
blob == HEADand an emptygit diff HEAD.Acceptance notes
downstream nodes do NOT runholds 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; andthe child's own caller is told the truth about the CHILDis an invariance pin on the half a fix here could break.subflow:correlation only, so amapparent 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.bubbleToParent's per-outcome [convention] best-effort 降级导致"看起来正常、实则不持久"时不应记 warn——把 #4460 的点状修复定成规则 #4632 grading is unreached on a refusal, because a refused parent answerssuccess: true. That is correct — a refusal is not a degradation — but it means thestrandedstrand-recording arm is exercised only by a parent that fails downstream of a refusal, which the existingsubflow-bubble-strand-log-levelpin already drives from the completion side. Successor: no PR or person is known to be heading for this seam.needs:contract-reviewor a size label is owed, it is the seat's to apply.🤖 Generated with Claude Code
Generated by Claude Code