Repository navigation
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
Conversation
…hild that can suspend, and cancel the child run the region refusal leaves behind A region body cannot carry a durable pause, so a `subflow` / `map` node inside a `loop` body, a `parallel` branch or a `try_catch` slot whose child suspends fails the run at the region boundary. The arming gate now declines such a caller with its own unbound cause, `suspending_subflow_in_region`, re-judged whenever a flow its calls reach is registered. The region refusal itself is unchanged, but before it is thrown the engine cancels the child run the call parked, and any run that child is parked on. Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 168 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 68f2981eeb8e2a1b610861d2c993b18f37f30ab2 && git checkout 68f2981eeb8e2a1b610861d2c993b18f37f30ab2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d1f3ac05038cffc28b9f8bcbe2116afe1b5f1a2e 65a9454675b4b557c5ca827a63b9748c1ba1ac26 && git checkout -B drift-repro d1f3ac05038cffc28b9f8bcbe2116afe1b5f1a2e && git merge --no-ff 65a9454675b4b557c5ca827a63b9748c1ba1ac26
node scripts/docs-audit/affected-docs.mjs --json d1f3ac05038cffc28b9f8bcbe2116afe1b5f1a2e
|
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 Contract-review-claim: session_01ADHkmQJdgarUjPLNgFBY67 · head 65a9454 · 2026-11-02T08:15Z |
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 Contract reviewServed-tier: Isolated at-tier review subagent of the seat session; it did not write the change. Net diff ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing makes ruling D's own refusal ( Must-fix: none. Should-fix (not blocking): 1. Implemented-by: VERDICT: PASS |
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 席位核对 · ACCEPT · PR #29096 · 2026-11-02T08:24ZHead-sha: 维护者速读(席位终稿)
核对六项
|
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 asubflow/mapcall or the arming gate's unbound causes, and this changes no authorable shape维护者速读
loop、parallel分支或try_catch里的subflow/map调用了一个会挂起的子流程,这个流程现在照常注册,但不启用触发器。以前它会启用,子流程第一次挂起时,运行在区域边界失败(区域里不能持久挂起,裁定 D)。unboundCause值suspending_subflow_in_region(加进 spec 的FLOW_UNBOUND_CAUSES)。/_status的原因点名区域节点(最内层的容器)、调用节点和子流程里的挂起节点。只打一次warn。没有新闸门,复用现有的启用闸门,排在停用子流程、save_notice两半之后。paused)。之后有人恢复它,会把子流程剩下的步骤跑完,再去恢复已经失败的调用方,结果只是一条warn。现在区域拒绝抛出之前,会用引擎已有的cancelRun取消这个子运行,以及它再挂着的孙运行,记录为cancelled。拒绝本身(错误类型、文字、不可路由)没有改。campaign_enrollment在 loop 里调用两个不会挂起的子流程,仍然可用;它是 screen 流程,本来就不经过启用闸门。What changes
The arming gate's region half (
packages/services/service-automation/src/engine.ts,declineOntoRegionSuspendingCall).activateFlowTriggerasks it afterdeclineOntoDisabledSubflowsanddeclineOntoSuspendingSubflowNotice, before the trigger lookup. A flow whose region-nestedsubflow/mapnode calls a child that can suspend:GET /automation/_statusasbound: false, with that sentence andunboundCause: 'suspending_subflow_in_region'.The decline record lives in
subflowRegionDeclinedFlows.describeUnboundReasonreads it last, in the gate's own order, andwithdrawFlowdrops 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 everysubflow/mapnode throughcollectFlowGraphsand keeps its node id and the container holding it.subflowTargetsis nowsubflowCalls(flow).map(…), so the §7.3 guards and the region half read the same calls and no second region walk exists.regionSuspendingCallasks 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 sharesdescribeSuspendingNodewith the save-notice half, factored out ofdescribeSubflowNoticeTrap(its wording is unchanged).The re-judge (
rejudgeSubflowCallers,subflow-guards.ts). Callers come fromtransitiveSubflowCallers, 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). Asubflow/mapnode 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,runRegionnow:nestedChildRunId;resolveSuspensionChain;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, ascancelRun's own store failure is). It never takes the refusal's place.Prose.
region-suspension-refusal.tsheader now says the arming gate is the first door and this refusal the second. It also names the cancellation.subflow-guards.tsfile header no longer says every body was moved unedited, and it names the bodies edited since (review 6569776675).FLOW_UNBOUND_CAUSESgains 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 withRESOURCE_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
subflow/map, with a child that suspends at await:execute()answeredfailedwith the region refusal.listSuspendedRuns()andlistSuspendedRunsDurable(),hasSuspendedRunwastrue, and its run record readpaused.resume(childRunId)answeredsuccess: trueand ran the rest of the child. The record becamecompleted. The up-bubble then failed with the warn "subflow run '…' completed but resuming parent '…' failed". So this was a leak.subflow-region-suspension.test.tshas 20 cases. On base, 17 failed and 3 passed (the 3 controls). On head, 20 of 20 pass.unboundCauseand the sentence's region, call and pause names, plus one warn.cancelled.scripts/ablation-replace.mjs, each red and then restored (blob == HEAD, tree clean). Each ran over this file andsubflow-guard-region-calls.test.ts, 35 cases:subflowTargetsreads top-level calls onlysave_noticeafter a loop whose body calls a suspending child) asserted the caller armed. It is now declined withsuspending_subflow_in_region, and the pin asserts that the reason does not name thesave_notice. The notice half itself stays top-level.examples/**andpackages/qa/**: everysubflow/mapnode is top-level. The showcase file mixing regions and calls (app-showcase/src/automation/flows/index.ts) holds its calls at the top level.origin/main(d4ffe99):campaign_enrollmentis the one region-nested caller. Its children,campaign_lead_member_enrollandcampaign_contact_member_enroll, hold no pausing node and no call, and the flow istype: '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.dispatch-gates.mjs --commandsderived 83.--ranreconciles 83 run, 0 NOT-MEASURED and 0 UNRUN, all exit 0.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/specsrc/automationandsrc/contracts: 95 files, 2214 tests.pnpm typecheck(6 GB heap) stopped when@objectstack/spec's test-layertscwas killed without diagnostics (exitnull) on the shared box, the trap automation: subflow guards read top-level nodes only, so a subflow or map inside a region body escapes every subflow guard #28846 met. Run alone,@objectstack/spec(8 GB heap) and@objectstack/service-automationpnpm typecheckeach exit 0. The other packages were not re-run one by one: NOT MEASURED.eslint --no-inline-configoverpackages/services/service-automationandpackages/spec/src/automationexits 0.(narrowing)arm), underos-verify-lock.sh: the closure build gaveVERDICT command-exit 0.pnpm --filter @objectstack/dogfood testwas still running when this PR opened (wrap-up order): NOT MEASURED when this PR opened. The os-dev-report on automation: a subflow or map in a region body whose child suspends arms, fails every run at the region boundary, and leaves the child run suspended #28897 carries its verdict.Readings for the seat
subflownode 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.campaign_enrollment) never reach the arming gate. The second door (the cancellation) covers their runs.Acceptance notes
Metadata-protocol: none. Registration, the save door andobjectstack validateaccept 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, andFLOW_UNBOUND_CAUSES, a published tuple, gainssuspending_subflow_in_region. The typeEqpin inautomation-service.test.tsmoved with it. Both packages shippatch, under ruling A forMetadata-protocol: none, with the BREAKING banner and the ADR-0087no-migration-prescriptionmarker.FLOW_PAUSE_CAPABLE_NODE_TYPES(ruling D), the save-notice half's walk andui/**. No new gate. No hotcrm change.Human Minimum
packages/specholds no runtime logic.pnpm lintpasses.main, one pull request.packages/spectouched (one tuple value and its TSDoc): generated artifacts are checked bycheck-generatedand the spec artifact gates on this head.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code