fix(service-automation): subflow and map roll a COMPLETED child's contained failures into the run-level failed (#16314) - #18261
Conversation
…into the delegating node's failure count Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…rol for the contained-failure rollup Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…ailure count Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 2 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 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 fcdae9f590e8904f9d9fdd5497be5014f269a4da && git checkout fcdae9f590e8904f9d9fdd5497be5014f269a4da
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fd1247142a4beac65f9016de78f70caf7bb976b3 6938723d7d5684419bf09bfb7a9750e1c356a20a && git checkout -B drift-repro fd1247142a4beac65f9016de78f70caf7bb976b3 && git merge --no-ff 6938723d7d5684419bf09bfb7a9750e1c356a20a
node scripts/docs-audit/affected-docs.mjs --json fd1247142a4beac65f9016de78f70caf7bb976b3
|
Fixes #16314
Clause-②: no
Services half of #15617's ruling (director seat, decision batch #55, maintainer 「同意」 on option 1, 2026-09-06). The spec half landed the slot on
68d5dfd0f; this populates it.What moved
ExecutionStepMetrics.failuresis declared as "node executions that failed inside a child run this execution delegated to and went on from", folding intonodes[].failuresand so into the run-levelfailed. Nothing wrote it, so the engine's fold could not see a child's losses: a parent that delegated its rows reportedfailed: 0whileactedhad rolled up since #4354. Four producers, each read rather than assumed:builtin/subflow-node.tssummary.failed, on the non-failed exit onlybuiltin/map-node.tsfailedof the items that COMPLETED during this entry —mapdoes not sharesubflow's roll-up path, so it needed its ownengine.ts·creditChildRunrun-summary.ts·summarizeRunmetrics.failuresintonodes[].failuresand so intofailedThe measured target, driven on the real engine
The card's shape from #15617 — parent
loop { subflow(child) }, one child failing per five rows:The control, unchanged and pinned. A child that failed rather than contained is the delegating step's own failure, counted once:
call: {runs: 5, failures: 1}, parentfailed = 1, with nothing of the child's ownfailedriding up. That is the one place the rule parts fromacted's, which does carry a failed child's writes — asserted beside it so neither direction can be made symmetric without a red test.loopbody is fail-fast, so an unguarded call to a failing child ends the loop at the third row (call: {runs: 3}) and the loop node records a failure of its own beside the call's, givingfailed = 2. The control's declared numbers are the parent that CONTAINS — the same shape the contained-child case uses, differing only in which level contains. Both readings are in the test's own comments.A delegating node's
statusdoes not move.FlowRunNodeSummary.statusis declared judged on the node's OWN executions, so asubflowstep that ran fine and rolled a child's losses up readssuccesswithfailures > 0, and on such a nodefailuresmay exceedruns. The fold takes the status verdict before it adds the roll-up; that ordering is load-bearing and has its own ablation leg below.PR #15609's narrowed wording — "no node execution of this run failed" — was true only while the declaration's two paragraphs disagreed. It is widened back here in
formatRunSummaryLine's comment and incontent/docs/automation/flows.mdx.#18110 is a separate, still-ungraded card on the same file, in the opposite direction: a
refusedchild (the run-OUTCOME sense — anendnode saying no) rolled up as an ordinary success, the refusal reaching nobody.They are not mechanically inseparable, and the reason is mechanical rather than a judgement call:
selected/acted/unmeasuredEffectalready ride the exact exit a refused child takes today, andfailureswas added to that same exit. So this diff adds one total to an existing roll-up and decides nothing new about which child outcomes reach it. Nothing here pins the refused case in either direction — deliberately, so #18110's fix stays free to decide what a parent does with a refused child's totals, all four at once.Reverse verification — six ablation legs
Each leg mutates one COMMITTED source file, proves the mutation reached disk by occurrence counts on the anchored text plus a blob-hash inequality against
HEAD(never an editor's exit code), runs the suite, restores withgit checkout HEAD -- pathand proves restoration by blob hash equality withgit diff HEADempty. Atrapon EXIT/INT/TERM restores every touched path by absolute path. Baseline and post-restore runs are both 16/16 green.successwithfailures > 0" and "failuresmay exceedruns"subflowstops rolling a completed child upfailedrides up toomapstops accumulating its completed items' failuresmaptestcreditChildRunstops crediting a PAUSED childA first pass declared B/D/E VOID rather than green: its landing proof used
grep -cFon a multi-line needle, which splits into per-line patterns and counts lines. The legs were re-run with a python-side occurrence count, and the void reading is reported rather than quietly replaced.No permanent ablation artefact is left behind; the worktree is clean and every restored blob matches
HEAD.Verification
pnpm --filter @objectstack/service-automation test— 136 files / 1615 tests passed.pnpm --filter @objectstack/service-automation typecheck— clean (tsc --noEmit+check:test-typecheck, 0 files / 0 errors in the test-layer ledger).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackfrom the merge-base change set, reconciled back with--rancarrying each command's own exit code captured before any pipe: "92 derived famil(ies) accounted for — 92 run, 0 NOT-MEASURED (a DERIVED zero — all 92 recorded an exit code and none of them is 3)." Three of them first answered PREREQUISITE NOT MET (check:skill-examples,check:dual-build-cjs-loadsexit 3,check:type-check-debtexit 3) — ⛔ not passes; the workspace build closure was built and all three then exited 0.eslint . --no-inline-configover the whole repo population — 6764 files, 0 errors, 0 warnings. Not a narrowed run, so no narrowing needs declaring.check:nul-bytesis also in the 92).Clause-②: no — derived, not predicted
Derived by reachability from the published entry, with controls, ⛔ never from the word
exportand ⛔ never from a grep indist/index.js. The package'sexportsdeclares one entry (.→dist/index.js/dist/index.d.ts) andfilesshipsdist; the named-export set of that entry was read from the published type surface and cross-checked against the barrel chain insrc/index.ts.summarizeRun,formatRunSummaryLine,AutomationEngine,registerLogicNodes,installBuiltinNodes: REACHABLE, so the derivation can answer yes.registerSubflowNodeandregisterMapNodeareexport functionin their own modules and re-exported bysrc/builtin/index.ts, yet the root barrel's explicit list names six builtins and not those two ⇒ unreachable by name;creditChildRun(aprivatemember) and a nonexistent identifier: also unreachable. So the derivation can answer no, and it is not a grep forexport.metrics.failures,nodes[].failuresandfailedare all keyspackages/specalready declares.failedchanges from "this run's own node failures" to "what this run caused". That is conformance to a declaration already onmain, not a widened surface or a relaxed acceptance set.Acceptance notes
packages/specuntouched — the contract said everything needed, including the failed-child boundary and thestatus-judged-on-own-executions rule, both of which this implements verbatim.packages/services/service-automation/src/subflow-node.ts; the file lives atsrc/builtin/subflow-node.ts. Path drift only — the premise holds. Noted, not filed.loop { subflow(failing child) }unguarded answersfailed = 2for one lost row: the loop node's own failed execution plus the subflow step's. Literally correct under "total node executions that failed", and unchanged by this diff. Noted, not filed.subflowchild that endsrefusedis rolled up by the parent as an ordinary success — the refusal reaches nobody #18110's, left exactly as found. Noted, not filed here.Authored by Claude Code in session
session_01URLHobLUJB9K1ABV6ofdjj.Generated by Claude Code