Repository navigation
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
|
…egs — delegated resume and up-bubble (objectstack-ai#19158) Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#18110 / objectstack-ai#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 objectstack-ai#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 (objectstack-ai#15788), which is the same call the sibling card objectstack-ai#18881 made and an at-tier review confirmed. Zero `packages/spec`. ## objectstack-ai#18112 — read before choosing a mechanism, and this stays outside it objectstack-ai#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 (objectstack-ai#18881's whole premise). So there is nothing here for a container to rethrow or to swallow. ## The objectstack-ai#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 objectstack-ai#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 (objectstack-ai#18110 / objectstack-ai#18555), the region refusal (objectstack-ai#18881) and the retryable delegated resume-bag codes (objectstack-ai#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 objectstack-ai#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>
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