Skip to content

fix(service-automation): subflow and map roll a COMPLETED child's contained failures into the run-level failed (#16314) - #18261

Merged
claude[bot] merged 3 commits into
mainfrom
claude/issue-16314-contained-failure-rollup-services-half
Sep 15, 2026
Merged

claude[bot] merged 3 commits into
mainfrom
claude/issue-16314-contained-failure-rollup-services-half

Conversation

@claude

@claude claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

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.failures is declared as "node executions that failed inside a child run this execution delegated to and went on from", folding into nodes[].failures and so into the run-level failed. Nothing wrote it, so the engine's fold could not see a child's losses: a parent that delegated its rows reported failed: 0 while acted had rolled up since #4354. Four producers, each read rather than assumed:

site what it now reports
builtin/subflow-node.ts a synchronous child's summary.failed, on the non-failed exit only
builtin/map-node.ts the accumulated failed of the items that COMPLETED during this entry — map does not share subflow's roll-up path, so it needed its own
engine.ts · creditChildRun a child that PAUSED, whose parent step was written at suspend time; both call sites are completion paths, which is what puts them inside the declared rule
run-summary.ts · summarizeRun folds metrics.failures into nodes[].failures and so into failed

The measured target, driven on the real engine

The card's shape from #15617 — parent loop { subflow(child) }, one child failing per five rows:

before   status=completed selected=5 acted=4 skipped=0 failed=0
after    status=completed selected=5 acted=4 skipped=0 failed=1
children failed = [0, 0, 1, 0, 0]          (unchanged — the child keeps its own row)

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}, parent failed = 1, with nothing of the child's own failed riding up. That is the one place the rule parts from acted's, which does carry a failed child's writes — asserted beside it so neither direction can be made symmetric without a red test.

⚠️ One correction to the control's fixture, measured rather than assumed: a loop body 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, giving failed = 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 status does not move. FlowRunNodeSummary.status is declared judged on the node's OWN executions, so a subflow step that ran fine and rolled a child's losses up reads success with failures > 0, and on such a node failures may exceed runs. 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 in content/docs/automation/flows.mdx.

⚠️ Fence — #18110 is NOT folded in, and the two are separable

#18110 is a separate, still-ungraded card on the same file, in the opposite direction: a refused child (the run-OUTCOME sense — an end node 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 / unmeasuredEffect already ride the exact exit a refused child takes today, and failures was 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 with git checkout HEAD -- path and proves restoration by blob hash equality with git diff HEAD empty. A trap on EXIT/INT/TERM restores every touched path by absolute path. Baseline and post-restore runs are both 16/16 green.

leg mutation result
A the fold stops adding the roll-up 9 failed / 7 passed
B roll-up added before the status verdict 6 failed / 10 passed — incl. "reads success with failures > 0" and "failures may exceed runs"
C subflow stops rolling a completed child up 2 failed / 14 passed — the measured target and the node status
D the asymmetry removed: a FAILED child's failed rides up too 2 failed / 14 passed — exactly the two control tests
E map stops accumulating its completed items' failures 1 failed / 15 passed — the map test
F creditChildRun stops crediting a PAUSED child 2 failed / 14 passed — both resume directions

A first pass declared B/D/E VOID rather than green: its landing proof used grep -cF on 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 test136 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).
  • Derived gate families: 92 derived / 92 run / 0 NOT MEASURED / 0 UNRUN. Derived by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack from the merge-base change set, reconciled back with --ran carrying 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-loads exit 3, check:type-check-debt exit 3) — ⛔ not passes; the workspace build closure was built and all three then exited 0.
  • eslint . --no-inline-config over the whole repo population — 6764 files, 0 errors, 0 warnings. Not a narrowed run, so no narrowing needs declaring.
  • Control-character self-scan over the diff's seven files: no match (check:nul-bytes is also in the 92).
  • Not measured here, declared to CI: the required contexts on the merge group's affected set.

Clause-②: no — derived, not predicted

Derived by reachability from the published entry, with controls, ⛔ never from the word export and ⛔ never from a grep in dist/index.js. The package's exports declares one entry (.dist/index.js / dist/index.d.ts) and files ships dist; the named-export set of that entry was read from the published type surface and cross-checked against the barrel chain in src/index.ts.

  • Positive controlssummarizeRun, formatRunSummaryLine, AutomationEngine, registerLogicNodes, installBuiltinNodes: REACHABLE, so the derivation can answer yes.
  • Negative controlsregisterSubflowNode and registerMapNode are export function in their own modules and re-exported by src/builtin/index.ts, yet the root barrel's explicit list names six builtins and not those two ⇒ unreachable by name; creditChildRun (a private member) and a nonexistent identifier: also unreachable. So the derivation can answer no, and it is not a grep for export.
  • The delivered diff adds zero export statements outside the test file, and zero keys to any published payload — metrics.failures, nodes[].failures and failed are all keys packages/spec already declares.

⚠️ Declared honestly: what moves is the value on an already-published payload — a delegating parent's failed changes from "this run's own node failures" to "what this run caused". That is conformance to a declaration already on main, not a widened surface or a relaxed acceptance set.

Acceptance notes

  • packages/spec untouched — the contract said everything needed, including the failed-child boundary and the status-judged-on-own-executions rule, both of which this implements verbatim.
  • The card's re-check grep names packages/services/service-automation/src/subflow-node.ts; the file lives at src/builtin/subflow-node.ts. Path drift only — the premise holds. Noted, not filed.
  • loop { subflow(failing child) } unguarded answers failed = 2 for 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.
  • The refused-child roll-up is [finding] service-automation: a subflow child that ends refused is 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

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 6 documentable anchor(s).

2 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json fd1247142a4beac65f9016de78f70caf7bb976b3packageMentionDocs.

Which tree this was computed on

This run read content/docs from fcdae9f590e8904f9d9fdd5497be5014f269a4da — the merge of head 6938723d7d5684419bf09bfb7a9750e1c356a20a into base fd1247142a4beac65f9016de78f70caf7bb976b3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fd1247142a4beac65f9016de78f70caf7bb976b3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant