You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 554e928
Browse filesBrowse the repository at this point in the historyBrowse files
fix(service-automation): a durable suspension inside a structured region fails the run with a named refusal (#19140)
Fixes#18881Fixes#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: `FlowRegionSuspensionRefusalError` lives in
a new internal module that `src/index.ts` does not re-export, exactly as
`guard-refusal.ts` and `partial-steps.ts` do.
## The defect, measured before anything was written
A node contained in an ADR-0031 structured region body — a `loop` body,
a `parallel` branch, a `try_catch` try 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. `runRegion`
already converted such a suspension, but into a plain `Error`, which is
indistinguishable from a node that simply failed.
The card's own reproduction, `loop { try_catch { map(pausing child) }
}`, on `origin/main` at `c70581bc` with only the new test file added:
```
× the card's reproduction: `loop { try_catch { map(pausing) } }` fails, and the catch handler never sees it
AssertionError: expected true to be false // result.success
× the error NAMES the region node, the suspending node and the sub-flow
AssertionError: the given combination of arguments (undefined and string) is invalid
× counts the refusal ONCE: `summary.failed = 1`, on the region node
AssertionError: expected +0 to be 1
× `loop { map(pausing) }` — the region node is the `loop`
Expected: "sweep"
Received: "durable pause inside a structured region (node 'per_cell') is not supported"
Tests 6 failed | 2 passed (8)
```
Read that fourth line: even where the run DID fail, the sentence named
neither the region nor the sub-flow. The `try_catch` case is worse — the
enclosing container read the plain `Error` as "the try region failed",
ran its catch handler, and the run finished `completed` with
`summary.failed = 0`. The `map`'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 saw `started === collection.length`, ran nothing, and
reported success again.
## What changed
- **`src/region-suspension-refusal.ts`** (new, internal):
`FlowRegionSuspensionRefusalError` carries `regionNodeId`, `regionKind`,
`suspendedNodeId` and `subFlowName` as FIELDS as well as in its message,
so a reader never parses the sentence. Branded as a #3863 guard refusal,
so a `fault` edge 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, matching
`isGuardRefusal` / `isSuspendSignal`, because this package ships ESM and
CJS from one source and a cross-realm `instanceof` answering `false`
would mean a container swallowing the refusal again.
- **`runRegion`** raises it in place of the plain `Error`, resolving the
sub-flow from the suspending node's own `config.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_catch` re-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. **`parallel` re-throws it** rather than folding it into its
returned — and therefore `fault`-routable — branch failure. `loop`
already re-threw unchanged. The attempt's steps ride out on #13803's
channel, so rows the region really did write stay in the run log and in
the #4354 totals.
- **One refusal is one failure.** The region node's own frame records
the `EXECUTION_ERROR` step and publishes `{$error}`, exactly as any
thrown node failure does; every enclosing container records nothing, so
`summary.failed` counts the fault and not the nesting depth.
⛔ **No parse-time rule is added here.** #18688 landed that half. ⛔ **No
new `error.code`**: the closed `ERROR_CODE_LEDGER` (ADR-0112) lives in
`packages/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 says `Fixes`, 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
with `Fixes#15646` once both are on `main`.」 Both halves exist:
`78436637` (PR #18688) is an ancestor of `origin/main` and of this
branch's base, and the parse-time refusal does **not** reach the card's
reproduction — `map` and `subflow` are 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-claim` refuses a PR closing a card whose thread
carries no live `Claim:` naming that PR's head branch. #15646 carried
three claims, all naming `claude/issue-15646-region-pause-end-refusal`,
and its newest protocol event was a `Release:` (`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 shipped `Part of`
and 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:spec` seat had already released this card's runtime half to
`domain:services` in 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-claim` **exits 0**, reporting 「PR
#19140closes#18881, #15646, and each carries a `Claim:` whose
`Branch:` line names
`claude/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:
```
pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 \
src/region-durable-suspension-refusal.test.ts
Test Files 1 passed (1)
Tests 8 passed (8)
```
**The whole package**, which is what CI runs and the only scope that can
see this class of breakage:
```
pnpm --filter @objectstack/service-automation test
Test Files 139 passed (139)
Tests 1660 passed (1660)
pnpm --filter @objectstack/service-automation typecheck
check:test-typecheck: OK — 0 file(s) / 0 error(s) / 0 pinned signature(s)
```
⭐ **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 `end` test 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 against `HEAD`.** The package's own tests import their
subject through relative specifiers (`./engine.js`), so the mutation is
live from source with no `dist` on the path — which each ablation
demonstrates by going red.
1. **The `try_catch` re-throw arm**, the one that decides whether this
card's defect exists:
```
ablation-replace: anchor "if (isRegionSuspensionRefusal(err)) {" x1 -> x0
ablation-replace: blob c4ed5b3 -> c3b44a8af269
× the card's reproduction: `loop { try_catch { map(pausing) } }` fails …
× the error NAMES the region node, the suspending node and the sub-flow
× counts the refusal ONCE: `summary.failed = 1`, on the region node
Tests 3 failed | 5 passed (8)
ablation-replace: ok restored: blob == HEAD (c4ed5b3) and `git diff HEAD` is empty
```
The three `loop` / `parallel` cases and both controls stay GREEN under
that mutation, which is the second reading it buys: the `try_catch` arm
is precisely what closes the contained case, and the other two region
kinds are closed by a different arm.
2. **The one-refusal-one-failure rule**, because `summary.failed = 1` is
the assertion most at risk of being vacuously true:
```
ablation-replace: anchor "isRegionSuspensionRefusal(execErr) && execErr.regionNodeId !== node.id;" x1 -> x0
× counts the refusal ONCE: `summary.failed = 1`, on the region node
AssertionError: expected 2 to be 1
Tests 1 failed | 7 passed (8)
ablation-replace: ok restored: blob == HEAD (53fee28) and `git diff HEAD` is empty
```
`2` is exactly the nesting-depth reading — the `try_catch` and the
`loop` each 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`:
```
Run reconciliation — 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 59 derived famil(ies) accounted for — 59 run,
0 NOT-MEASURED (a DERIVED zero — all 59 recorded an exit code and none of them is 3)
```
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-loads` and `check:type-check-debt`. Both were
re-run after `pnpm exec turbo run build --filter='./packages/*'
--filter='./packages/*/*'` (72/72 tasks) and both exit 0.
`check:plugin-teardown-shape --self-test` first answered exit 3 on a
pinned fixture commit this shallow checkout could not reach; after `git
fetch --unshallow` it passes its 48 cases.
**Lint — a declared narrowing, and a measured one.** `pnpm lint` is
`eslint . --no-inline-config`: a whole-repo scan whose broadest block is
`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`,
read from `eslint.config.mjs` itself. Run instead over this diff's five
lintable paths, counted from the linter's own `--format json` output:
**5 files, 0 errors, 0 warnings**. The narrowing excludes nothing, and
that is a property of the config rather than an assumption:
`eslint.config.mjs` **never enables type-aware linting for ANY file** —
no `parserOptions.project`, no typed `@typescript-eslint` rules — stated
verbatim at `eslint.config.mjs:326` with 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-gates` reports its derivation as taken from a tree behind
`origin/main`, with `scripts/check-release-spec-changes.mjs` and
`scripts/ts-parse.mjs` changed across that range. Neither declares a
`packages/services` population — `check:spec-changes` is
spec-release-scoped and `ts-parse.mjs` is 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`'s `isRefusalSignal` arm (#15788) still converts a
region-contained REFUSAL into a plain `Error`, so an enclosing
`try_catch` can still contain that one. It is not reachable today:
#18688 refuses an `end` inside a region body at parse, and the other
producers are a `subflow` / `map` whose CHILD run refused — a different
card's surface (#18112's option B is explicitly not implemented).
Carrier if it ever becomes reachable: #18112. ⛔ Deliberately left alone;
touching it would move accept/reject behaviour outside this card's
ruling.
- The resumed and up-bubble legs of a region-contained pause are
#18714's and are held serial behind this card, so nothing here touches
`resumeInternal` or `creditChildRun`.
---
_Generated by [Claude Code](https://claude.ai/code)_
Co-authored-by: Claude <noreply@anthropic.com>
A node that **durably suspends inside a structured region body** now FAILS the run with a named refusal that carries the region node, the suspending node and the sub-flow — instead of being read as an ordinary region failure that a `try_catch` could contain, after which the run reported success over a sweep that had processed nothing (#18881, the runtime half of #15646's ruling D).
6
+
7
+
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. #3267 ruled that limit 禁. `runRegion` already converted such a suspension, but into a plain `Error`, which is indistinguishable from a node that simply failed.
8
+
9
+
Measured on the card's reproduction, `loop { try_catch { map(pausing child) } }`, before this change:
10
+
11
+
```
12
+
result.success true // the catch handler ran and "recovered"
13
+
run.status completed
14
+
summary.failed 0 // over 0 of 10 child runs
15
+
```
16
+
17
+
The `map`'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 saw `started === collection.length`, ran nothing, and reported success. A sweep that reports green having done nothing is the worst available failure, and it is the one the run-level `failed` counter (#14456) was built to expose.
18
+
19
+
What changed:
20
+
21
+
-**`FlowRegionSuspensionRefusalError`** (new internal module `region-suspension-refusal.ts`, ⛔ not exported from the package entry) carries `regionNodeId`, `regionKind`, `suspendedNodeId` and `subFlowName` as fields as well as in its message, so a reader never parses the sentence. It is branded as a #3863 guard refusal, so a `fault` edge on the enclosing container cannot route it either.
22
+
-**`try_catch` re-throws it** from both the try-attempt arm and the catch-region arm rather than treating it as a region failure, and ⛔ spends no retry attempt on it — re-entering the region would re-enter the pausing node, and the metadata is what is wrong. **`parallel` re-throws it** rather than folding it into its returned (and therefore routable) branch failure. `loop` already re-threw unchanged.
23
+
-**One refusal is one failure.** The region node's own frame records the `EXECUTION_ERROR` step and publishes `{$error}`, exactly as any thrown node failure does; every enclosing container the unwind passes through records nothing, so `summary.failed` counts the fault and ⛔ not the nesting depth.
24
+
25
+
⛔ **Nothing changes for a region whose nodes complete synchronously.**`loop { map(synchronous child) }`, `parallel { branch: [map(synchronous child)] }` and #15616's regression suite run exactly as before — pinned as explicit controls beside every refusal case, because without them a reader cannot tell "the durable pause is refused" from "the region path was closed off".
26
+
27
+
⛔ **No authoring-time rule is added here**: #18688 landed that half in `packages/spec` and it refuses `screen` / `wait` / `approval` / `approval_revise` / `end` inside a region body by type. `map` and `subflow` are deliberately not refused there — whether they pause is decided by the child flow record their `config.flowName` names — which is exactly why the runtime arm has to exist.
28
+
29
+
⛔ **No new `error.code`.** The closed `ERROR_CODE_LEDGER` (ADR-0112) lives in `packages/spec`; the refusal is named by its type and its fields, and the step it produces keeps the `EXECUTION_ERROR` code every thrown node failure has always carried.
0 commit comments