fix(service-automation): a durable suspension inside a structured region fails the run with a named refusal - #19140
Conversation
…ion fails the run with a named refusal
An ADR-0031 region body — a `loop` body, a `parallel` branch, a
`try_catch` try or catch region, at any depth — runs synchronously
inside the enclosing run and cannot park it on a durable pause.
`runRegion` already converted such a suspension, but into a plain
`Error`: indistinguishable from a node that simply failed, so an
enclosing `try_catch` read it as "the try region failed", ran its catch
handler, and the run reported success over a sweep that had processed
nothing.
Measured on `loop { try_catch { map(pausing child) } }` before this
change: `success: true`, run `completed`, `summary.failed = 0`, 0 of 10
child runs executed. The `map`'s progress state lives in the enclosing
scope, so the residue a contained refusal leaves is read back as
progress by the next entry to the same node.
`FlowRegionSuspensionRefusalError` carries the region node, the region
kind, the suspending node and the sub-flow as fields as well as in its
message, and is branded as a guard refusal so a `fault` edge cannot
route it. `try_catch` and `parallel` re-throw it instead of containing
it; `loop` already did. One refusal is one failure: the region node's
own frame records the step and publishes `{$error}`, enclosing frames
record nothing, so `summary.failed` counts the fault and not the
nesting depth.
Nothing changes for a region whose nodes complete synchronously — every
refusal case is pinned beside a synchronous control.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E
📓 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 23d7e010aadd0390c7492a2369a2d37b62092117 && git checkout 23d7e010aadd0390c7492a2369a2d37b62092117
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aadea24b893661605af747abdd2cce9c036e300c bad6404b085213ef0c4c08b947c1034162683808 && git checkout -B drift-repro aadea24b893661605af747abdd2cce9c036e300c && git merge --no-ff bad6404b085213ef0c4c08b947c1034162683808
node scripts/docs-audit/affected-docs.mjs --json aadea24b893661605af747abdd2cce9c036e300c
|
Fixes #18881
Fixes #15646
Clause-②: no — this is a refusal being added, pulling runtime behaviour back to #3267's declared 禁. The flow accept set does not widen, and no export leaves this package:
FlowRegionSuspensionRefusalErrorlives in a new internal module thatsrc/index.tsdoes not re-export, exactly asguard-refusal.tsandpartial-steps.tsdo.The defect, measured before anything was written
A node contained in an ADR-0031 structured region body — a
loopbody, aparallelbranch, atry_catchtry or catch region, at any depth — that durably suspends now FAILS the run with a named refusal carrying the region node, the suspending node and the sub-flow.runRegionalready converted such a suspension, but into a plainError, which is indistinguishable from a node that simply failed.The card's own reproduction,
loop { try_catch { map(pausing child) } }, onorigin/mainatc70581bcwith only the new test file added:Read that fourth line: even where the run DID fail, the sentence named neither the region nor the sub-flow. The
try_catchcase is worse — the enclosing container read the plainErroras "the try region failed", ran its catch handler, and the run finishedcompletedwithsummary.failed = 0. Themap's progress state (nodeId.$mapState) is written into the ENCLOSING scope, so the residue a contained refusal leaves is read back as progress by the next entry to the same node: iteration 2 sawstarted === collection.length, ran nothing, and reported success again.What changed
src/region-suspension-refusal.ts(new, internal):FlowRegionSuspensionRefusalErrorcarriesregionNodeId,regionKind,suspendedNodeIdandsubFlowNameas FIELDS as well as in its message, so a reader never parses the sentence. Branded as a Node-level error branches: let a flow handle a failed node instead of aborting the run #3863 guard refusal, so afaultedge on the enclosing container cannot route it either — the one-edge switch that would otherwise re-open the same silence. The predicate is duck-typed on a registered symbol, matchingisGuardRefusal/isSuspendSignal, because this package ships ESM and CJS from one source and a cross-realminstanceofansweringfalsewould mean a container swallowing the refusal again.runRegionraises it in place of the plainError, resolving the sub-flow from the suspending node's ownconfig.flowName. A refusal raised at an INNER boundary is re-thrown untouched, so the region named is the one the author nested the pause in and not the frame the unwind passes through.try_catchre-throws it from both the try-attempt arm and the catch-region arm, and spends NO retry attempt on it: re-entering the region would re-enter the pausing node, and the metadata is what is wrong.parallelre-throws it rather than folding it into its returned — and thereforefault-routable — branch failure.loopalready re-threw unchanged. The attempt's steps ride out on [finding] A dyingloopdiscards its body's completed steps wholesale — the run summary reportsacted: 0while 5 writes actually happened (childSteps splice only after a SUCCESSFUL node result) #13803's channel, so rows the region really did write stay in the run log and in the Surface flow run summaries (selected / acted / skipped) — a scheduled flow that does nothing is currently indistinguishable from one with nothing to do #4354 totals.EXECUTION_ERRORstep and publishes{$error}, exactly as any thrown node failure does; every enclosing container records nothing, sosummary.failedcounts the fault and not the nesting depth.⛔ No parse-time rule is added here. #18688 landed that half. ⛔ No new
error.code: the closedERROR_CODE_LEDGER(ADR-0112) lives inpackages/spec, outside this card's declared file surface; the refusal is named by its type and its fields.The closing keyword on #15646, and the round trip it took to get there
⭐ This section previously explained why the body said
Part of #15646. It now saysFixes, and the reasoning is kept rather than deleted because the round trip is the record.Ruling D's execution clause names the mechanism, verbatim: 「The spec half lands with
Part of #15646; the runtime card's PR closes this card withFixes #15646once both are onmain.」 Both halves exist:78436637(PR #18688) is an ancestor oforigin/mainand of this branch's base, and the parse-time refusal does not reach the card's reproduction —mapandsubfloware deliberately not judged by type there — so the runtime arm is live and every fixture in the new suite registers and runs.What blocked the literal mechanism was a shipped gate.
check:closing-target-claimrefuses a PR closing a card whose thread carries no liveClaim:naming that PR's head branch. #15646 carried three claims, all namingclaude/issue-15646-region-pause-end-refusal, and its newest protocol event was aRelease:(5730095126) — no live claim, none naming this branch. ⛔ A dispatched executor may not post a claim of its own to clear that, so the delivering dev shippedPart ofand reported the conclusion as falsified. That was the correct call at its authority level.The gate's own remedy 1 is a PM act, and the PM seat performed it: the
domain:specseat had already released this card's runtime half todomain:servicesin writing (「由 services 车道重新认领」), so the services seat claimed it on this branch (issuecomment-5737225992) and assigned itself. The claim is simply true — this card's remaining half really is in flight here.Re-measured after the claim, with the gate's own prescribed invocation:
check:closing-target-claimexits 0, reporting 「PR #19140 closes #18881, #15646, and each carries aClaim:whoseBranch:line namesclaude/issue-18881-region-durable-suspension-refusal」.⇒ the ruling is executed literally, via the gate's prescribed route. ⛔ Not a re-adjudication of the ruling, and ⛔ not an evasion of the gate — the gate exists to stop a second seat duplicating work on an unclaimed card, and a truthful claim serves that purpose.
Tests
All figures below were taken at
bad6404b0, the final commit on this branch.The new suite —
packages/services/service-automation/src/region-durable-suspension-refusal.test.ts, 8 tests, every refusal case paired with a synchronous control:The whole package, which is what CI runs and the only scope that can see this class of breakage:
⭐ 1652 + 8 = 1660. PR #18688's body measured this package at 138 files / 1652 tests, all passing on the tree this branch is cut from. This branch adds exactly one file and eight tests and lands at 139 / 1660, so nothing was lost, re-homed, skipped or quarantined. #15616's five tests, #15788's runtime
endtest and #16314's rollup suite are untouched in the diff and green in that run.Reverse validation — two ablations, each mutated on disk through
scripts/ablation-replace.mjs(anchor must hit, blob hash must change) and restored againstHEAD. The package's own tests import their subject through relative specifiers (./engine.js), so the mutation is live from source with nodiston the path — which each ablation demonstrates by going red.try_catchre-throw arm, the one that decides whether this card's defect exists:The three
loop/parallelcases and both controls stay GREEN under that mutation, which is the second reading it buys: thetry_catcharm is precisely what closes the contained case, and the other two region kinds are closed by a different arm.summary.failed = 1is the assertion most at risk of being vacuously true:2is exactly the nesting-depth reading — thetry_catchand theloopeach recording the same event — that the suppression prevents.Gates. Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths passed, reconciled with--ran:Two of the 59 first answered
PREREQUISITE NOT MET(exit 3, ⛔ not a finding) because they read built output the whole tree has to supply —check:dual-build-cjs-loadsandcheck:type-check-debt. Both were re-run afterpnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'(72/72 tasks) and both exit 0.check:plugin-teardown-shape --self-testfirst answered exit 3 on a pinned fixture commit this shallow checkout could not reach; aftergit fetch --unshallowit passes its 48 cases.Lint — a declared narrowing, and a measured one.
pnpm lintiseslint . --no-inline-config: a whole-repo scan whose broadest block isfiles: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']minusNEVER_LINTED, read fromeslint.config.mjsitself. Run instead over this diff's five lintable paths, counted from the linter's own--format jsonoutput: 5 files, 0 errors, 0 warnings. The narrowing excludes nothing, and that is a property of the config rather than an assumption:eslint.config.mjsnever enables type-aware linting for ANY file — noparserOptions.project, no typed@typescript-eslintrules — stated verbatim ateslint.config.mjs:326with its own positive-control measurement, so every rule is per-file and syntactic and no edit here can move the verdict on a file it does not contain.dispatch-gatesreports its derivation as taken from a tree behindorigin/main, withscripts/check-release-spec-changes.mjsandscripts/ts-parse.mjschanged across that range. Neither declares apackages/servicespopulation —check:spec-changesis spec-release-scoped andts-parse.mjsis a parser library that declares no family — so the derived list is unchanged at 59. CI derives it again on the merge base.Acceptance notes
Noted while reading, ⛔ not filed and ⛔ not fixed here — none is a reproducible defect, a contract violation or a metadata-authoring trap:
runRegion'sisRefusalSignalarm (service-automation: honouroutcome: 'refused'on the flowendnode — a terminalrefusedrun status (distinct fromfailed) with the interpolated message persisted on the run (lane 2 of the #14945 ruling 2′) #15788) still converts a region-contained REFUSAL into a plainError, so an enclosingtry_catchcan still contain that one. It is not reachable today: feat(spec)!: a structured region body refuses a pause-capable node and an 'end' node #18688 refuses anendinside a region body at parse, and the other producers are asubflow/mapwhose CHILD run refused — a different card's surface ([Decision] service-automation: should a refusingendINSIDE a structured region propagate out and terminate the run, or stay a loud refusal at the region boundary? #18112's option B is explicitly not implemented). Carrier if it ever becomes reachable: [Decision] service-automation: should a refusingendINSIDE a structured region propagate out and terminate the run, or stay a loud refusal at the region boundary? #18112. ⛔ Deliberately left alone; touching it would move accept/reject behaviour outside this card's ruling.pausedforever #18714's and are held serial behind this card, so nothing here touchesresumeInternalorcreditChildRun.Generated by Claude Code