Skip to content

Commit 1ac1c24

Browse files
feat(service-automation)!: decline to arm a region-nested call to a child that can suspend, and cancel the child run the region refusal leaves behind (#29096)
domain:server#3 · os-project-manager · session_01FEkQ5omF1nCbQTGgW1nbRA Fixes #28897 Clause-②: yes (narrowing) Metadata-protocol: none Skills-docs: none — `skills/**` does not teach region bodies around a `subflow` / `map` call or the arming gate's unbound causes, and this changes no authorable shape ## 维护者速读 - **BREAKING(收窄)**:记录触发的流程里,如果 `loop`、`parallel` 分支或 `try_catch` 里的 `subflow` / `map` 调用了一个会挂起的子流程,这个流程现在照常注册,但**不启用触发器**。以前它会启用,子流程第一次挂起时,运行在区域边界失败(区域里不能持久挂起,裁定 D)。 - 新的 `unboundCause` 值 `suspending_subflow_in_region`(加进 spec 的 `FLOW_UNBOUND_CAUSES`)。`/_status` 的原因点名区域节点(最内层的容器)、调用节点和子流程里的挂起节点。只打一次 `warn`。没有新闸门,复用现有的启用闸门,排在停用子流程、`save_notice` 两半之后。 - 不依赖加载顺序:每注册一个流程,调用链能到达它的流程都会重新判定,和 #28840 一样。子流程去掉挂起后重新发布,调用方自动重新启用。 - **实测的泄漏,已修**:改动前,区域拒绝之后子运行一直停在持久存储里(`paused`)。之后有人恢复它,会把子流程剩下的步骤跑完,再去恢复已经失败的调用方,结果只是一条 `warn`。现在区域拒绝抛出之前,会用引擎已有的 `cancelRun` 取消这个子运行,以及它再挂着的孙运行,记录为 `cancelled`。拒绝本身(错误类型、文字、不可路由)没有改。 - 已发布的流程里没有这种形状。HotCRM 的 `campaign_enrollment` 在 loop 里调用两个不会挂起的子流程,仍然可用;它是 screen 流程,本来就不经过启用闸门。 - 有一点请席位确认(见 Readings 1):调用方在顶层调用一个子流程,而这个子流程自己在区域里调用会挂起的孙流程,调用方不在本次拒绝启用的范围内。合并前由席位做契约复核。 ## What changes **The arming gate's region half** (`packages/services/service-automation/src/engine.ts`, `declineOntoRegionSuspendingCall`). `activateFlowTrigger` asks it after `declineOntoDisabledSubflows` and `declineOntoSuspendingSubflowNotice`, before the trigger lookup. A flow whose region-nested `subflow` / `map` node calls a child that can suspend: - registers, but is not armed; - warns once per reason, naming the innermost container node, the call node and where the child suspends; - is reported by `GET /automation/_status` as `bound: false`, with that sentence and `unboundCause: 'suspending_subflow_in_region'`. The decline record lives in `subflowRegionDeclinedFlows`. `describeUnboundReason` reads it last, in the gate's own order, and `withdrawFlow` drops it. Any flow is judged, packaged or authored here, as the save-notice half is. **One reading of the calls** (`subflow-region-guard.ts`, new). `subflowCalls(flow)` reads every `subflow` / `map` node through `collectFlowGraphs` and keeps its node id and the container holding it. `subflowTargets` is now `subflowCalls(flow).map(…)`, so the §7.3 guards and the region half read the same calls and no second region walk exists. `regionSuspendingCall` asks the #28840 question of each region-nested call: `suspendingNode` (a pausing node reachable from the child's start, or a top-level call of the child's whose child can suspend). The sentence shares `describeSuspendingNode` with the save-notice half, factored out of `describeSubflowNoticeTrap` (its wording is unchanged). **The re-judge** (`rejudgeSubflowCallers`, `subflow-guards.ts`). Callers come from `transitiveSubflowCallers`, which already reads region calls (#28846). A caller is settled only when neither the save-notice half nor the region half would decline it, so a child that turns suspending disarms an armed caller, and a child republished without the pause arms it again. **The orphaned child run** (`dag-traversal.ts`, `runRegion`). A `subflow` / `map` node suspends by parking the child run it started and linking it through its correlation (`subflow:<id>` / `map:<id>`). When the region refusal fires on such a node, `runRegion` now: - reads the child run id with `nestedChildRunId`; - walks the chain the child is parked on with `resolveSuspensionChain`; - cancels each run, deepest first, with `cancelRun`, giving the refusal's own text as the reason. The refusal is then thrown as before. If a cancellation fails, it is logged at `error` (a DURABILITY verdict, as `cancelRun`'s own store failure is). It never takes the refusal's place. **Prose.** - The `region-suspension-refusal.ts` header now says the arming gate is the first door and this refusal the second. It also names the cancellation. - The `subflow-guards.ts` file header no longer says every body was moved unedited, and it names the bodies edited since (review 6569776675). - `FLOW_UNBOUND_CAUSES` gains a TSDoc entry for the new value, with its owner and remedy. **Ride-along enable-door pin** (review 6569776675). In `subflow-guard-region-calls.test.ts`, `toggleFlow(caller, true)` re-enables a switched-off caller whose loop body calls a switched-off packaged child. It is refused with `RESOURCE_CONFLICT` / 409, naming the child. ## Why #28897, found on #28846: a region-nested call whose child suspends armed. It failed the run at the region boundary the first time the child suspended, and left the child run parked. ## Verification - **Measured first** (a scratch test, not committed), for every region slot × `subflow` / `map`, with a child that suspends at a `wait`: - `execute()` answered `failed` with the region refusal. - The child run was in both `listSuspendedRuns()` and `listSuspendedRunsDurable()`, `hasSuspendedRun` was `true`, and its run record read `paused`. - `resume(childRunId)` answered `success: true` and ran the rest of the child. The record became `completed`. The up-bubble then failed with the warn "subflow run '…' completed but resuming parent '…' failed". So this was a leak. - **Red first.** `subflow-region-suspension.test.ts` has 20 cases. On base, 17 failed and 3 passed (the 3 controls). On head, 20 of 20 pass. - The 6 slot × call-type gate cases each assert `unboundCause` and the sentence's region, call and pause names, plus one warn. - The 6 slot × call-type cancel cases each assert that the refusal text is unchanged, that no run is left parked, and that the child records `cancelled`. - **Ablations**, through `scripts/ablation-replace.mjs`, each red and then restored (`blob == HEAD`, tree clean). Each ran over this file and `subflow-guard-region-calls.test.ts`, 35 cases: | Ablation | Result | |---|---| | A1: the gate half switched off | 11 red: the 10 gate cases, plus the updated #28846 notice pin | | A2: the re-judge ignores the region half | 2 red: both re-judge cases | | A3: no cancellation | 7 red: the 6 cancel cases and the chain case | | A4: only the child cancelled, not the chain | 1 red: the chain case | | A5: `subflowTargets` reads top-level calls only | 13 red: the 11 region-guard pins, including the new enable-door pin, and the 2 re-judge cases | | A6: the outermost container named instead of the innermost | 1 red: the nested case | - **One existing pin changed.** The first #28846 save-notice pin (a `save_notice` after a loop whose body calls a suspending child) asserted the caller armed. It is now declined with `suspending_subflow_in_region`, and the pin asserts that the reason does not name the `save_notice`. The notice half itself stays top-level. - **Shipped flows.** - `examples/**` and `packages/qa/**`: every `subflow` / `map` node is top-level. The showcase file mixing regions and calls (`app-showcase/src/automation/flows/index.ts`) holds its calls at the top level. - HotCRM `origin/main` (`d4ffe99`): `campaign_enrollment` is the one region-nested caller. Its children, `campaign_lead_member_enroll` and `campaign_contact_member_enroll`, hold no pausing node and no call, and the flow is `type: 'screen'` with no trigger. A pin reproduces the shape (two loop bodies, two packaged children that do not suspend): it arms and the run completes. - **Gates.** `dispatch-gates.mjs --commands` derived 83. `--ran` reconciles 83 run, 0 NOT-MEASURED and 0 UNRUN, all exit 0. - **Suites.** Full `vitest run`, exit 0 on each: - `@objectstack/service-automation`: 257 files, 3319 tests. - `@objectstack/plugin-approvals`: 107 files, 1376 tests. - `@objectstack/trigger-record-change`: 12 files, 121 tests. - `@objectstack/example-showcase`: 42 files, 511 tests. - `@objectstack/spec` `src/automation` and `src/contracts`: 95 files, 2214 tests. - **Typecheck.** The workspace `pnpm typecheck` (6 GB heap) stopped when `@objectstack/spec`'s test-layer `tsc` was killed without diagnostics (exit `null`) on the shared box, the trap #28846 met. Run alone, `@objectstack/spec` (8 GB heap) and `@objectstack/service-automation` `pnpm typecheck` each exit 0. The other packages were not re-run one by one: NOT MEASURED. - `eslint --no-inline-config` over `packages/services/service-automation` and `packages/spec/src/automation` exits 0. - **Dogfood battery** (owed by the `(narrowing)` arm), under `os-verify-lock.sh`: the closure build gave `VERDICT command-exit 0`. `pnpm --filter @objectstack/dogfood test` was still running when this PR opened (wrap-up order): NOT MEASURED when this PR opened. The os-dev-report on #28897 carries its verdict. ## Readings for the seat 1. **Only the caller's own region calls are read.** A caller with a top-level call to a child whose own region body calls a suspending grandchild is not declined. That child's run fails at its own region, so the caller's `subflow` node fails on the child's failure. This is a failed child, not a pause the caller inherits, and the order names the region-nested caller. Say if it should also count; it would be one more walk over top-level calls. 2. **Every region-nested call counts**, reachable or not, as the #28846 guards read them. 3. **Flows with no trigger** (screen and manual flows, such as `campaign_enrollment`) never reach the arming gate. The second door (the cancellation) covers their runs. ## Acceptance notes - **`Metadata-protocol: none`.** Registration, the save door and `objectstack validate` accept the same flows. A flow that registered still registers, and a run that failed at the region still fails there with the same refusal. - **`Clause-②: yes (narrowing)`.** The flows armed is narrowed, and `FLOW_UNBOUND_CAUSES`, a published tuple, gains `suspending_subflow_in_region`. The type `Eq` pin in `automation-service.test.ts` moved with it. Both packages ship `patch`, under ruling A for `Metadata-protocol: none`, with the BREAKING banner and the ADR-0087 `no-migration-prescription` marker. - **Unchanged:** the region refusal (its class, text, fields and guard-refusal brand), `FLOW_PAUSE_CAPABLE_NODE_TYPES` (ruling D), the save-notice half's walk and `ui/**`. No new gate. No hotcrm change. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched (one tuple value and its TSDoc): generated artifacts are checked by `check-generated` and the spec artifact gates on this head. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5omF1nCbQTGgW1nbRA)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 14208f2 commit 1ac1c24

12 files changed

Lines changed: 650 additions & 35 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(service-automation)!: a flow whose region body calls a child that can suspend is registered but not armed, and the region refusal cancels the child run it leaves behind (#28897)
7+
8+
Clause-②: yes (narrowing)
9+
Metadata-protocol: none
10+
11+
**BREAKING** for a record-triggered flow with a `subflow` or `map` node inside a `loop` body, a `parallel` branch or a `try_catch` slot, whose child flow can suspend the run.
12+
13+
- **Before.** A region body cannot carry a durable pause (ruling D on #15646). The flow parse refuses the pausing node types inside a region, but it cannot see into a `subflow` / `map` child. Such a flow armed, and the first time its child suspended, the run failed at the region boundary with the named refusal (`durable pause inside a structured region …`). The child run that node had started was left parked in the suspended-run store: a later resume ran the rest of the child for a caller that had already failed, and then failed to resume that caller.
14+
- **Now, the first door.** The arming gate in `activateFlowTrigger` declines such a flow, after its disabled-subflow and save-notice halves. "Can suspend" is the save-notice half's reading: the child holds a node of an always-suspending type reachable from its start, or calls, from its top-level graph, a child that can suspend. The flow still registers, is not armed, and warns once, naming the region node (the innermost container), the `subflow` / `map` node and the child's suspending node. `GET /automation/_status` reports it `bound: false` with that reason and the new `unboundCause` value `suspending_subflow_in_region` (added to `FLOW_UNBOUND_CAUSES` in `@objectstack/spec/contracts`). Whenever a flow is registered, every flow whose calls reach it is judged again, so the decline does not depend on load order, and a child republished without its suspension arms the caller again.
15+
- **Now, the second door.** A run that still meets the region refusal (a direct execute, or a flow with no trigger) fails exactly as before, with the same refusal. Before throwing it, the engine cancels the child run the `subflow` / `map` node parked, and every run that child is parked on, through `cancelRun`. Each cancelled run records `cancelled`, with the refusal as its reason.
16+
- **Not declined.** A region-nested call whose child cannot suspend, and a top-level call to a child that can (the top-level graph carries the pause). Registration, the save door and `objectstack validate` accept the same flows as before.
17+
- **The fix.** Move the `subflow` / `map` node the reason names onto the top-level graph and route the region's exit to it, or republish the child without the node that suspends. The flow arms on its own once the shape is gone.
18+
- **Who moves.** No shipped flow has the shape. HotCRM's `campaign_enrollment` calls two children from loop bodies, and neither child suspends. The showcase and dogfood calls are all top-level.
19+
20+
<!-- adr-0087: not-required (no-migration-prescription) no metadata key is added, removed, renamed or re-shaped and the save door accepts the same flows: a flow that registered still registers and is only left unarmed by the runtime, with its own reason naming what to move, a run that failed at the region still fails there with the same refusal, and the one contract change is a new value in the runtime status cause list, which no author writes and no migration could rewrite -->

‎packages/services/service-automation/src/dag-traversal.ts‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
FlowSuspendSignal,
3535
isRefusalSignal,
3636
isSuspendSignal,
37+
nestedChildRunId,
3738
type ExecutionLogEntry,
3839
type NodeExecutionResult,
3940
type StepLogEntry,
@@ -141,6 +142,8 @@ export interface DagTraversalHost {
141142
readonly persistSuspendedRun: AutomationEngine['persistSuspendedRun'];
142143
readonly flowChecksumOf: AutomationEngine['flowChecksumOf'];
143144
readonly renderFlowTerminalMessage: AutomationEngine['renderFlowTerminalMessage'];
145+
readonly resolveSuspensionChain: AutomationEngine['resolveSuspensionChain'];
146+
readonly cancelRun: AutomationEngine['cancelRun'];
144147
}
145148

146149
/** The body of {@link AutomationEngine.executeNode}, moved unedited; that method's docblock is its contract. */
@@ -746,9 +749,9 @@ export async function traverseNext(
746749
}
747750
}
748751

749-
/** The body of {@link AutomationEngine.runRegion}, moved unedited; that method's docblock is its contract. */
752+
/** The body of {@link AutomationEngine.runRegion}; that method's docblock is its contract. */
750753
export async function runRegion(
751-
this: Pick<DagTraversalHost, 'executeNode'>,
754+
this: Pick<DagTraversalHost, 'executeNode' | 'resolveSuspensionChain' | 'cancelRun' | 'logger'>,
752755
region: FlowRegionParsed,
753756
variables: Map<string, unknown>,
754757
context: AutomationContext,
@@ -835,12 +838,42 @@ export async function runRegion(
835838
if (isSuspendSignal(err)) {
836839
const suspended = region.nodes.find(n => n.id === err.nodeId);
837840
const flowName = (suspended?.config as { flowName?: unknown } | undefined)?.flowName;
838-
throw refuseRegionSuspension({
841+
const refusal = refuseRegionSuspension({
839842
regionNodeId: grouping?.parentNodeId ?? entryId,
840843
regionKind: grouping?.regionKind ?? 'region',
841844
suspendedNodeId: err.nodeId,
842845
...(typeof flowName === 'string' && flowName ? { subFlowName: flowName } : {}),
843846
});
847+
// [#28897] A `subflow` / `map` node suspends by parking the CHILD
848+
// run it started and linking it through its correlation
849+
// (`subflow:<childRunId>` / `map:<childRunId>`). This run fails
850+
// here, so nothing will ever resume through that link: measured,
851+
// the child stayed parked in the durable store, and a later
852+
// resume ran the rest of the child for a caller that had already
853+
// failed, then failed to resume that caller. So the child run is
854+
// cancelled, with every run it is itself parked on, deepest
855+
// first, through the engine's own `cancelRun`. ⛔ The refusal is
856+
// thrown exactly as before: a failed cancellation is logged, never
857+
// put in its place.
858+
const childRunId = nestedChildRunId(err.correlation);
859+
if (childRunId !== undefined) {
860+
try {
861+
const chain = await this.resolveSuspensionChain(childRunId);
862+
const reason = `the run that started it failed at a region boundary: ${refusal.message}`;
863+
for (const run of [...chain].reverse()) await this.cancelRun(run.runId, reason);
864+
} catch (cancelErr) {
865+
// #4632 verdict: DURABILITY, `error`, as `cancelRun`'s own
866+
// store failure: the child may still be parked.
867+
this.logger.error(
868+
`[automation] node '${err.nodeId}' suspended inside region '${refusal.regionNodeId}', and ` +
869+
`cancelling the child run '${childRunId}' it parked failed, so that run may still be ` +
870+
`parked. Cancel it with cancelRun('${childRunId}'). The failure is in this record's meta.`,
871+
undefined,
872+
describeThrownForLog(cancelErr),
873+
);
874+
}
875+
}
876+
throw refusal;
844877
}
845878
// [#15788] The refusing `end` node's signal, converted at exactly
846879
// the same boundary and for the same reason: a control signal must

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ import {
222222
refuseCustomerAuthoredToggle,
223223
} from './subflow-guards.js';
224224
import { describeSubflowNoticeTrap, subflowNoticeTrap } from './subflow-notice-guard.js';
225+
import { describeRegionSuspendingCall, regionSuspendingCall } from './subflow-region-guard.js';
225226
// [#25222] The consumed-suspension restore's bodies: journaling a consumed
226227
// suspension, resolving its witnesses, and restoring, peeking at or inspecting
227228
// one. Each `AutomationEngine` method of the same name calls its body.
@@ -2915,6 +2916,16 @@ export class AutomationEngine implements IAutomationService {
29152916
* registered, and dropped with the flow by {@link withdrawFlow}.
29162917
*/
29172918
private readonly subflowNoticeDeclinedFlows = new Map<string, string>();
2919+
/**
2920+
* [#28897] Flows {@link activateFlowTrigger} declined to arm because a
2921+
* `subflow` / `map` node inside one of their region bodies calls a child
2922+
* flow that can suspend, each with the reason `/_status` and the binding
2923+
* audit read. A record for {@link subflowNoticeDeclinedFlows}'s reasons,
2924+
* kept the same way: deleted the moment the flow gets past the gate,
2925+
* re-asked by {@link rejudgeSubflowCallers}, dropped by
2926+
* {@link withdrawFlow}.
2927+
*/
2928+
private readonly subflowRegionDeclinedFlows = new Map<string, string>();
29182929
/** Connectors registered by integration plugins, keyed by connector name (ADR-0018 §Addendum). */
29192930
private connectors = new Map<string, RegisteredConnector>();
29202931
/** Connector provider factories keyed by provider name (ADR-0097 §2 — `openapi`/`mcp`/`rest`/…). */
@@ -3899,6 +3910,15 @@ export class AutomationEngine implements IAutomationService {
38993910
// so the gate asks here and {@link rejudgeSubflowCallers} asks again
39003911
// when the child arrives or changes.
39013912
if (this.declineOntoSuspendingSubflowNotice(flowName, resolved.triggerType)) return;
3913+
// [#28897] The region half, a decline for the same reasons: a region
3914+
// body cannot carry a durable pause, so a region-nested `subflow` /
3915+
// `map` whose child suspends fails the run at the region boundary
3916+
// (`region-suspension-refusal.ts`), the first time that child
3917+
// suspends. That refusal stays as the second door; this is the first.
3918+
// Last of the halves, and {@link describeUnboundReason} reads the
3919+
// records in the same order, so a record an earlier half's decline
3920+
// left unasked is never the one reported.
3921+
if (this.declineOntoRegionSuspendingCall(flowName, resolved.triggerType)) return;
39023922
const trigger = this.triggers.get(resolved.triggerType);
39033923
if (!trigger) return;
39043924
try {
@@ -4649,6 +4669,7 @@ export class AutomationEngine implements IAutomationService {
46494669
policyDisabledFlows: this.policyDisabledFlows,
46504670
subflowDeclinedFlows: this.subflowDeclinedFlows,
46514671
subflowNoticeDeclinedFlows: this.subflowNoticeDeclinedFlows,
4672+
subflowRegionDeclinedFlows: this.subflowRegionDeclinedFlows,
46524673
flowRefusals: this.flowRefusals,
46534674
logger: this.logger,
46544675
};
@@ -4780,6 +4801,7 @@ export class AutomationEngine implements IAutomationService {
47804801
policyDisabledFlows: this.policyDisabledFlows,
47814802
subflowDeclinedFlows: this.subflowDeclinedFlows,
47824803
subflowNoticeDeclinedFlows: this.subflowNoticeDeclinedFlows,
4804+
subflowRegionDeclinedFlows: this.subflowRegionDeclinedFlows,
47834805
triggers: this.triggers,
47844806
};
47854807
return describeUnboundReason.call(host, name, resolved);
@@ -5222,6 +5244,11 @@ export class AutomationEngine implements IAutomationService {
52225244
* child be switched off under it. The regions are read through
52235245
* `collectFlowGraphs`, the spec's one region walk, never a second walker.
52245246
*
5247+
* [#28897] That reading is `subflowCalls` (`subflow-region-guard.ts`),
5248+
* which also keeps each call's node and the container holding it: the
5249+
* arming gate's region half ({@link declineOntoRegionSuspendingCall})
5250+
* reads the same calls, never a walk of its own.
5251+
*
52255252
* ⛔ The historical `flow` alias for `config.flowName` is NOT read here.
52265253
* ADR-0087 D2's conversion `flow-node-subflow-flow-alias` canonicalizes it
52275254
* at load, so only the canonical key reaches a registered definition;
@@ -5559,6 +5586,48 @@ export class AutomationEngine implements IAutomationService {
55595586
return true;
55605587
}
55615588

5589+
/**
5590+
* [#28897] The arming gate's region half, asked by
5591+
* {@link activateFlowTrigger} of an enabled flow it is about to arm: does a
5592+
* `subflow` / `map` node inside one of its region bodies (a `loop` body, a
5593+
* `parallel` branch, a `try_catch` slot, at any depth) call a child flow
5594+
* that can suspend ({@link regionSuspendingCall}, which reads the calls
5595+
* through {@link subflowTargets}' reading and where the child suspends
5596+
* through the save-notice half's)? A region body cannot carry a durable
5597+
* pause, so that run would fail at the region boundary the first time the
5598+
* child suspends (`region-suspension-refusal.ts`). Then it is declined:
5599+
* recorded on {@link subflowRegionDeclinedFlows} for `/_status` and the
5600+
* binding audit, and said once as a `warn` naming the region node, the call
5601+
* node and where the child suspends.
5602+
*
5603+
* Any flow, packaged or authored here, as the save-notice half: the failed
5604+
* run is the same. ⛔ A decline, never a refusal, for that half's reasons.
5605+
*
5606+
* #4632 verdict: FUNCTIONAL, `warn`, as the other halves. Said once per
5607+
* decline reason.
5608+
*
5609+
* @returns `true` when the flow is declined and must not be armed.
5610+
*/
5611+
private declineOntoRegionSuspendingCall(flowName: string, triggerType: string): boolean {
5612+
const trap = regionSuspendingCall(this.flows, flowName);
5613+
if (!trap) {
5614+
this.subflowRegionDeclinedFlows.delete(flowName);
5615+
return false;
5616+
}
5617+
const why = describeRegionSuspendingCall(trap);
5618+
const reason =
5619+
`not armed — ${why}, and it is armed; it is also armed once '${trap.child}' is republished without a ` +
5620+
`node that suspends`;
5621+
const previous = this.subflowRegionDeclinedFlows.get(flowName);
5622+
this.subflowRegionDeclinedFlows.set(flowName, reason);
5623+
if (previous === reason) return true;
5624+
this.logger.warn(
5625+
`Flow '${flowName}' is registered but NOT armed on trigger '${triggerType}' — ${why}. It is armed the ` +
5626+
`moment '${trap.callNode.id}' moves, or '${trap.child}' is republished without a node that suspends.`,
5627+
);
5628+
return true;
5629+
}
5630+
55625631
/**
55635632
* [#20725, ADR-0126 §7.3] `subflow` just changed state — (re)registered,
55645633
* switched on or off, or read off the ledger at boot — so ask the arming
@@ -5582,13 +5651,17 @@ export class AutomationEngine implements IAutomationService {
55825651
* change two calls away moves the answer. A caller that half declined is
55835652
* re-offered, and an armed caller it would now decline is unbound and
55845653
* offered again, both through the one gate.
5654+
*
5655+
* [#28897] The region half reads the same children through the same
5656+
* calls, so the same callers are re-asked for it, the same way.
55855657
*/
55865658
private rejudgeSubflowCallers(subflow: string): void {
55875659
// The body lives in ./subflow-guards.ts (#25222).
55885660
const host: ThisParameterType<typeof rejudgeSubflowCallers> = {
55895661
boundFlowTriggers: this.boundFlowTriggers,
55905662
subflowDeclinedFlows: this.subflowDeclinedFlows,
55915663
subflowNoticeDeclinedFlows: this.subflowNoticeDeclinedFlows,
5664+
subflowRegionDeclinedFlows: this.subflowRegionDeclinedFlows,
55925665
flows: this.flows,
55935666
subflowTargets: (...a) => this.subflowTargets(...a),
55945667
packagedSubflowCallers: (...a) => this.packagedSubflowCallers(...a),
@@ -9267,6 +9340,12 @@ export class AutomationEngine implements IAutomationService {
92679340
* failure, ran its catch handler, and the run reported success over a sweep
92689341
* that had processed nothing. The container executors test for the named
92699342
* type and re-throw, so no region can contain it.
9343+
*
9344+
* [#28897] When the suspending node is a `subflow` / `map`, the child run
9345+
* it parked (and any run that child is parked on) is cancelled before the
9346+
* refusal is thrown, so no run is left parked under a caller that failed.
9347+
* The arming gate declines such a caller first
9348+
* ({@link declineOntoRegionSuspendingCall}); this is the second door.
92709349
*/
92719350
runRegion(
92729351
region: FlowRegionParsed,
@@ -9278,6 +9357,9 @@ export class AutomationEngine implements IAutomationService {
92789357
// The body lives in ./dag-traversal.ts (#25222).
92799358
const host: ThisParameterType<typeof runRegion> = {
92809359
executeNode: (...a) => this.executeNode(...a),
9360+
resolveSuspensionChain: (...a) => this.resolveSuspensionChain(...a),
9361+
cancelRun: (...a) => this.cancelRun(...a),
9362+
logger: this.logger,
92819363
};
92829364
return runRegion.call(host, region, variables, context, grouping, partialSteps);
92839365
}

0 commit comments

Comments
 (0)