Repository navigation
Commit 1ac1c24
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
File tree
- .changeset
- packages
- services/service-automation/src
- spec/src/contracts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
| 37 | + | |
37 | 38 | | |
38 | 39 | | |
39 | 40 | | |
| |||
141 | 142 | | |
142 | 143 | | |
143 | 144 | | |
| 145 | + | |
| 146 | + | |
144 | 147 | | |
145 | 148 | | |
146 | 149 | | |
| |||
746 | 749 | | |
747 | 750 | | |
748 | 751 | | |
749 | | - | |
| 752 | + | |
750 | 753 | | |
751 | | - | |
| 754 | + | |
752 | 755 | | |
753 | 756 | | |
754 | 757 | | |
| |||
835 | 838 | | |
836 | 839 | | |
837 | 840 | | |
838 | | - | |
| 841 | + | |
839 | 842 | | |
840 | 843 | | |
841 | 844 | | |
842 | 845 | | |
843 | 846 | | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
| 851 | + | |
| 852 | + | |
| 853 | + | |
| 854 | + | |
| 855 | + | |
| 856 | + | |
| 857 | + | |
| 858 | + | |
| 859 | + | |
| 860 | + | |
| 861 | + | |
| 862 | + | |
| 863 | + | |
| 864 | + | |
| 865 | + | |
| 866 | + | |
| 867 | + | |
| 868 | + | |
| 869 | + | |
| 870 | + | |
| 871 | + | |
| 872 | + | |
| 873 | + | |
| 874 | + | |
| 875 | + | |
| 876 | + | |
844 | 877 | | |
845 | 878 | | |
846 | 879 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
222 | 222 | | |
223 | 223 | | |
224 | 224 | | |
| 225 | + | |
225 | 226 | | |
226 | 227 | | |
227 | 228 | | |
| |||
2915 | 2916 | | |
2916 | 2917 | | |
2917 | 2918 | | |
| 2919 | + | |
| 2920 | + | |
| 2921 | + | |
| 2922 | + | |
| 2923 | + | |
| 2924 | + | |
| 2925 | + | |
| 2926 | + | |
| 2927 | + | |
| 2928 | + | |
2918 | 2929 | | |
2919 | 2930 | | |
2920 | 2931 | | |
| |||
3899 | 3910 | | |
3900 | 3911 | | |
3901 | 3912 | | |
| 3913 | + | |
| 3914 | + | |
| 3915 | + | |
| 3916 | + | |
| 3917 | + | |
| 3918 | + | |
| 3919 | + | |
| 3920 | + | |
| 3921 | + | |
3902 | 3922 | | |
3903 | 3923 | | |
3904 | 3924 | | |
| |||
4649 | 4669 | | |
4650 | 4670 | | |
4651 | 4671 | | |
| 4672 | + | |
4652 | 4673 | | |
4653 | 4674 | | |
4654 | 4675 | | |
| |||
4780 | 4801 | | |
4781 | 4802 | | |
4782 | 4803 | | |
| 4804 | + | |
4783 | 4805 | | |
4784 | 4806 | | |
4785 | 4807 | | |
| |||
5222 | 5244 | | |
5223 | 5245 | | |
5224 | 5246 | | |
| 5247 | + | |
| 5248 | + | |
| 5249 | + | |
| 5250 | + | |
| 5251 | + | |
5225 | 5252 | | |
5226 | 5253 | | |
5227 | 5254 | | |
| |||
5559 | 5586 | | |
5560 | 5587 | | |
5561 | 5588 | | |
| 5589 | + | |
| 5590 | + | |
| 5591 | + | |
| 5592 | + | |
| 5593 | + | |
| 5594 | + | |
| 5595 | + | |
| 5596 | + | |
| 5597 | + | |
| 5598 | + | |
| 5599 | + | |
| 5600 | + | |
| 5601 | + | |
| 5602 | + | |
| 5603 | + | |
| 5604 | + | |
| 5605 | + | |
| 5606 | + | |
| 5607 | + | |
| 5608 | + | |
| 5609 | + | |
| 5610 | + | |
| 5611 | + | |
| 5612 | + | |
| 5613 | + | |
| 5614 | + | |
| 5615 | + | |
| 5616 | + | |
| 5617 | + | |
| 5618 | + | |
| 5619 | + | |
| 5620 | + | |
| 5621 | + | |
| 5622 | + | |
| 5623 | + | |
| 5624 | + | |
| 5625 | + | |
| 5626 | + | |
| 5627 | + | |
| 5628 | + | |
| 5629 | + | |
| 5630 | + | |
5562 | 5631 | | |
5563 | 5632 | | |
5564 | 5633 | | |
| |||
5582 | 5651 | | |
5583 | 5652 | | |
5584 | 5653 | | |
| 5654 | + | |
| 5655 | + | |
| 5656 | + | |
5585 | 5657 | | |
5586 | 5658 | | |
5587 | 5659 | | |
5588 | 5660 | | |
5589 | 5661 | | |
5590 | 5662 | | |
5591 | 5663 | | |
| 5664 | + | |
5592 | 5665 | | |
5593 | 5666 | | |
5594 | 5667 | | |
| |||
9267 | 9340 | | |
9268 | 9341 | | |
9269 | 9342 | | |
| 9343 | + | |
| 9344 | + | |
| 9345 | + | |
| 9346 | + | |
| 9347 | + | |
| 9348 | + | |
9270 | 9349 | | |
9271 | 9350 | | |
9272 | 9351 | | |
| |||
9278 | 9357 | | |
9279 | 9358 | | |
9280 | 9359 | | |
| 9360 | + | |
| 9361 | + | |
| 9362 | + | |
9281 | 9363 | | |
9282 | 9364 | | |
9283 | 9365 | | |
| |||
0 commit comments