Skip to content

Commit 554e928

Browse files
huangyiireneclaude
andauthored
fix(service-automation): a durable suspension inside a structured region fails the run with a named refusal (#19140)
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: `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 #19140 closes #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>
1 parent 1047fe1 commit 554e928

6 files changed

Lines changed: 672 additions & 17 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
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.

‎packages/services/service-automation/src/builtin/parallel-node.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { ParallelConfigParsed } from '@objectstack/spec/automation';
66
import type { AutomationContext } from '@objectstack/spec/contracts';
77
import type { AutomationEngine, StepLogEntry } from '../engine.js';
88
import { parseNodeConfig } from './parse-config.js';
9+
import { isRegionSuspensionRefusal } from '../region-suspension-refusal.js';
910

1011
/**
1112
* `parallel` built-in node — a **structured parallel block** with an
@@ -93,6 +94,14 @@ export function registerParallelNode(engine: AutomationEngine, ctx: PluginContex
9394
),
9495
);
9596
} catch (err) {
97+
// [#18881] A durable pause raised inside a BRANCH is refused at the
98+
// region boundary and must reach the run, ⛔ not be folded into this
99+
// node's returned failure. A returned failure is routable by a `fault`
100+
// edge on the `parallel` node, and routing this one would re-open the
101+
// silence the card closes: the run would continue past a region that
102+
// parked nothing and report success. Re-thrown, so the engine's own
103+
// catch path records it once and fails the run.
104+
if (isRegionSuspensionRefusal(err)) throw err;
96105
const message = err instanceof Error ? err.message : String(err);
97106
return { success: false, error: `parallel '${node.id}': branch failed — ${message}` };
98107
}

‎packages/services/service-automation/src/builtin/try-catch-node.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type { AutomationContext } from '@objectstack/spec/contracts';
77
import type { AutomationEngine, StepLogEntry } from '../engine.js';
88
import { parseNodeConfig } from './parse-config.js';
99
import { currentLoopIteration } from './loop-frame.js';
10+
import { isRegionSuspensionRefusal } from '../region-suspension-refusal.js';
11+
import { attachPartialSteps } from '../partial-steps.js';
1012

1113
/**
1214
* `try_catch` built-in node — **structured try/catch/retry** (ADR-0031 §Decision 3).
@@ -214,6 +216,23 @@ export function registerTryCatchNode(engine: AutomationEngine, ctx: PluginContex
214216
childSteps: [...failedAttemptSteps, ...trySteps],
215217
};
216218
} catch (err) {
219+
// [#18881] ⛔ NOT a try-region failure, and the one arm that decides
220+
// whether this card's defect exists. A durable pause raised inside
221+
// this region is refused at the region boundary; read as a failure it
222+
// would run the catch handler and the node would return SUCCESS — the
223+
// measured shape, `loop { try_catch { map(pausing child) } }`
224+
// reporting `completed` with `summary.failed = 0` over a sweep that
225+
// processed nothing. It is re-thrown so the RUN fails, and ⛔ no
226+
// retry attempt is spent on it: re-entering the region would re-enter
227+
// the pausing node, and the metadata is what is wrong.
228+
//
229+
// The attempt's steps ride out with it (#13803's channel), so the
230+
// rows the region really did write before the pause stay in the run
231+
// log and in the #4354 totals.
232+
if (isRegionSuspensionRefusal(err)) {
233+
attachPartialSteps(err, [...failedAttemptSteps, ...attemptSteps]);
234+
throw err;
235+
}
217236
lastError = err instanceof Error ? err.message : String(err);
218237
const innerError = variables.get('$error');
219238
// Only a `$error` that actually CHANGED (identity, not content —
@@ -284,6 +303,14 @@ export function registerTryCatchNode(engine: AutomationEngine, ctx: PluginContex
284303
childSteps: [...failedAttemptSteps, ...catchSteps],
285304
};
286305
} catch (catchErr) {
306+
// [#18881] The CATCH region is a region body too — ruling D names
307+
// `try_catch`'s try and catch alike — so a durable pause raised in
308+
// the handler is refused on exactly the same terms and travels out
309+
// rather than becoming this node's returned failure.
310+
if (isRegionSuspensionRefusal(catchErr)) {
311+
attachPartialSteps(catchErr, [...failedAttemptSteps, ...catchAttemptSteps]);
312+
throw catchErr;
313+
}
287314
const catchMsg = catchErr instanceof Error ? catchErr.message : String(catchErr);
288315
// #14222 — the THIRD returned-failure path, and the last one still
289316
// discarding its record. #13803 taught the engine to fold a dying

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ const FLOW_NODE_UNKNOWN_KEY_GUIDANCE: Record<string, Record<string, string>> = {
211211
import { runIsUnscopedUserMode, flowTouchesData } from './runtime-identity.js';
212212
import { isGuardRefusal, refuseNode } from './guard-refusal.js';
213213
import { readPartialSteps } from './partial-steps.js';
214+
import { isRegionSuspensionRefusal, refuseRegionSuspension } from './region-suspension-refusal.js';
214215
import { summarizeRun, formatRunSummaryLine } from './run-summary.js';
215216
// #5660 — the degrade registration reports a FOREIGN failure (a third-party
216217
// provider factory's text), so it renders it as structured `meta` rather than
@@ -9507,15 +9508,35 @@ export class AutomationEngine implements IAutomationService {
95079508
}
95089509
} catch (execErr: unknown) {
95099510
const errMsg = execErr instanceof Error ? execErr.message : String(execErr);
9510-
steps.push({
9511-
nodeId: node.id,
9512-
nodeType: node.type,
9513-
status: 'failure',
9514-
startedAt: stepStartedAt,
9515-
completedAt: new Date().toISOString(),
9516-
durationMs: Date.now() - stepStart,
9517-
error: { code: 'EXECUTION_ERROR', message: errMsg },
9518-
});
9511+
// [#18881] ONE region refusal is ONE failure. The refusal names
9512+
// the region node whose body could not carry the pause, and
9513+
// that node's own frame records it exactly as any other thrown
9514+
// failure does. Every ENCLOSING container the unwind passes
9515+
// through — the `loop` around the `try_catch` in the card's
9516+
// reproduction — records nothing: it did not fail, it is the
9517+
// frame a failure is travelling out through, and a step for it
9518+
// would make `summary.failed` count the NESTING DEPTH rather
9519+
// than the fault. `summary.failed` is `Σ nodes[].failures`
9520+
// (#14456), so a second step here reads as a second lost row to
9521+
// every operator and every #4354 reader.
9522+
//
9523+
// Keyed on the refusal's OWN `regionNodeId` rather than on a
9524+
// mutable "already reported" flag: the identity is decided once
9525+
// at the boundary that raised it and cannot drift as the error
9526+
// travels.
9527+
const enclosingFrameOfRegionRefusal =
9528+
isRegionSuspensionRefusal(execErr) && execErr.regionNodeId !== node.id;
9529+
if (!enclosingFrameOfRegionRefusal) {
9530+
steps.push({
9531+
nodeId: node.id,
9532+
nodeType: node.type,
9533+
status: 'failure',
9534+
startedAt: stepStartedAt,
9535+
completedAt: new Date().toISOString(),
9536+
durationMs: Date.now() - stepStart,
9537+
error: { code: 'EXECUTION_ERROR', message: errMsg },
9538+
});
9539+
}
95199540

95209541
// #13803 — a structured container that DIED mid-body still did
95219542
// whatever its completed iterations did, and those writes are
@@ -9573,8 +9594,17 @@ export class AutomationEngine implements IAutomationService {
95739594
// untouched and still decides, alone, which failures a `fault`
95749595
// edge may carry. Nor is the thrown value touched — `execErr` is
95759596
// rethrown below exactly as caught.
9576-
variables.set('$error', { nodeId: node.id, message: errMsg });
9577-
this.setNodeError(variables, node.id, errMsg);
9597+
//
9598+
// [#18881] …and it is published for the SAME frames that record
9599+
// a step, for the same reason: an enclosing container the
9600+
// region refusal is travelling out through did not fail, so
9601+
// `{$error}` naming it would be a false sentence about which
9602+
// node produced the run's failure. The region node's own frame
9603+
// publishes, as any failing node does.
9604+
if (!enclosingFrameOfRegionRefusal) {
9605+
variables.set('$error', { nodeId: node.id, message: errMsg });
9606+
this.setNodeError(variables, node.id, errMsg);
9607+
}
95789608

95799609
// #3863 — a guard that THROWS is as un-routable as one that
95809610
// returns: `UnscopedRunDataAccessError` (ADR-0049/#1888) reports
@@ -9997,9 +10027,15 @@ export class AutomationEngine implements IAutomationService {
999710027
* larger seams than an out-parameter the two callers that want it opt into.
999810028
* Callers that do not pass a sink (`loop`, `parallel`) are unaffected.
999910029
*
10000-
* Durable pause (`suspend`) inside a region is not supported in this
10001-
* iteration — it is converted into a clear error (mirrors the `subflow`
10002-
* nested-pause guard).
10030+
* [#18881] Durable pause (`suspend`) inside a region is not supported —
10031+
* #3267 ruled that limit 禁, and this boundary is where the run meets it.
10032+
* The conversion is a NAMED refusal
10033+
* ({@link FlowRegionSuspensionRefusalError}) carrying the region node, the
10034+
* suspending node and the sub-flow, ⛔ not the plain `Error` it used to
10035+
* raise: an enclosing `try_catch` read that one as an ordinary region
10036+
* failure, ran its catch handler, and the run reported success over a sweep
10037+
* that had processed nothing. The container executors test for the named
10038+
* type and re-throw, so no region can contain it.
1000310039
*/
1000410040
async runRegion(
1000510041
region: FlowRegionParsed,
@@ -10065,10 +10101,35 @@ export class AutomationEngine implements IAutomationService {
1006510101
// this path's contract is (still) to throw.
1006610102
tag();
1006710103
partialSteps?.push(...regionSteps);
10104+
// [#18881] A refusal raised at an INNER region boundary is already
10105+
// the named one, and it is re-thrown untouched. Re-wrapping it here
10106+
// would rename the region: for `loop { try_catch { map } }` the
10107+
// author's fault is the try region, and the loop is only the frame
10108+
// the unwind passes through. Tested before the suspend arm because
10109+
// this error is not a suspend signal and must not reach the generic
10110+
// rethrow below with an enclosing region's identity stamped on it.
10111+
if (isRegionSuspensionRefusal(err)) throw err;
10112+
// [#18881] The runtime half of #15646's ruling D: a region body
10113+
// cannot carry a durable pause, and the refusal is now NAMED
10114+
// (region node, suspending node, sub-flow) instead of a plain
10115+
// `Error` that an enclosing `try_catch` read as an ordinary region
10116+
// failure and handed to its catch handler. See
10117+
// `region-suspension-refusal.ts` for the measurement that is.
10118+
//
10119+
// The sub-flow is read off the suspending node's own `config`
10120+
// (`map` / `subflow` name their child there). The node is looked up
10121+
// in THIS region's `nodes` because this is the innermost boundary
10122+
// the signal crosses — a deeper suspension was already converted by
10123+
// the arm above, so `err.nodeId` always names a node of this body.
1006810124
if (isSuspendSignal(err)) {
10069-
throw new Error(
10070-
`durable pause inside a structured region (node '${err.nodeId}') is not supported`,
10071-
);
10125+
const suspended = region.nodes.find(n => n.id === err.nodeId);
10126+
const flowName = (suspended?.config as { flowName?: unknown } | undefined)?.flowName;
10127+
throw refuseRegionSuspension({
10128+
regionNodeId: grouping?.parentNodeId ?? entryId,
10129+
regionKind: grouping?.regionKind ?? 'region',
10130+
suspendedNodeId: err.nodeId,
10131+
...(typeof flowName === 'string' && flowName ? { subFlowName: flowName } : {}),
10132+
});
1007210133
}
1007310134
// [#15788] The refusing `end` node's signal, converted at exactly
1007410135
// the same boundary and for the same reason: a control signal must

0 commit comments

Comments
 (0)