Skip to content

service-automation: subflow and map roll a child's contained failures into the parent's failed through the ExecutionStepMetrics failure slot ruled on #15617 #16314

Closed

Description

@os-zhuang

Services half of the #15617 ruling (director seat, decision batch #55, maintainer 「同意」 on option 1, 2026-09-06). Blocked-by: #15617 — the spec half must land first (the failure slot on ExecutionStepMetricsSchema and the reconciled FlowRunSummary paragraphs).

What this card lands

Re-check before claiming

grep -n "unmeasuredEffect\|ExecutionStepMetrics" packages/services/service-automation/src/subflow-node.ts
grep -n "failures" packages/spec/src/automation/execution.zod.ts   # the slot must exist on main before this claim

Activity

  1. added
    bugSomething isn't working
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Sep 6, 2026
  2. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Sep 13, 2026
  3. claude commented on Sep 15, 2026

    @claude
    Contributor

    Claim: domain:services execution PM seat dispatching this card.

    Session: session_01URLHobLUJB9K1ABV6ofdjj
    Branch: claude/issue-16314-contained-failure-rollup-services-half
    Clause-②: no

    Why this card left pm:blocked — the blocker's DELIVERY measured on the tree

    This card's own condition is 「Blocked-by: #15617 — the spec half must land first」. ⛔ The test is the delivery, not the card's state, so both were read on origin/main:

    1. 68d5dfd0f — "feat(spec): ExecutionStepMetrics gains an optional failures slot; FlowRunSummary.failed is declared as the fold INCLUDING what a delegating node rolled up from its child (spec: FlowRunSummary's two paragraphs disagree for a subflow parent — failed is declared a node fold, while the summary is declared to answer "what did this run cause" and roll a child's totals up #15617, spec half) (feat(spec): ExecutionStepMetrics gains an optional failures slot; FlowRunSummary.failed is declared as the fold INCLUDING what a delegating node rolled up from its child (#15617, spec half) #16635)" — is an ancestor of origin/main.
    2. ⛔ A commit subject is not the artifact, so the slot itself was read: packages/spec/src/automation/execution.zod.ts declares failures: z.number().int().min(0).optional() on ExecutionStepMetricsSchema, carrying the rollup semantics in its own .describe(). Negative control on the same file scored 0, so the read can fail.

    ⇒ the spec half has landed. The services half — this card — is dispatchable.

    ⚠️ Fence the dispatch carries: a SIBLING card names the same file in the OPPOSITE direction

    #18110 (filed by this seat on 2026-09-14, still ungraded and ⛔ therefore undispatchable) is about subflow-node.ts rolling a refused child up as an ordinary success — the refusal reaching nobody. This card is the rollup of a completed child's contained failures INTO the parent's fold.

    Same file, opposite failure mode, two cards. ⛔ The dev is instructed not to fold #18110's half in: it is not this card's scope, and an ungraded card is not dispatchable through the side door of another card's diff. If the two turn out to be mechanically inseparable, that is a STOP-and-report, not a licence to widen.

    Bounds passed to the dev


    Generated by Claude Code

  4. claude commented on Sep 15, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 16314,
      "status": "done",
      "branch": "claude/issue-16314-contained-failure-rollup-services-half",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/18261",
      "premise_still_valid": true,
      "summary": "ExecutionStepMetrics.failures is now populated by every producer that delegates to a child run, so `failed = the sum over nodes of failures` answers 'what did this run cause', subflows included. subflow-node.ts rolls a COMPLETED child's summary.failed on its non-failed exit only; map-node.ts accumulates the contained failures of the items that completed during each entry (MEASURED: `map` does NOT share subflow's roll-up path — it keeps its own accumulator — so it needed its own code and its own pin); engine.ts creditChildRun credits a child that PAUSED, both call sites being completion paths, which is what puts them inside the declared rule; summarizeRun folds metrics.failures into nodes[].failures. The failed-child boundary is implemented as declared and NOT symmetrically: nothing of a failed child's own `failed` rides up (its writes still do, via `acted`). Node status stays judged on the node's OWN executions — the fold takes the status verdict BEFORE adding the roll-up, and that ordering has its own ablation leg. PR #15609's narrowed wording is widened back in formatRunSummaryLine's comment and in content/docs/automation/flows.mdx. packages/spec untouched: the contract said everything needed. Premise valid with one path drift — the card's re-check grep names src/subflow-node.ts and the file lives at src/builtin/subflow-node.ts.",
      "tests": "NEW SUITE src/builtin/contained-failure-rollup.test.ts, 16 tests, all green, driving the card's measured target on the real AutomationEngine: parent `loop { subflow(child) }`, one child failing per five rows answers selected=5 acted=4 skipped=0 failed=1 (was failed=0), children failed=[0,0,1,0,0] unchanged, call node {status:'success', runs:5, failures:1}, FlowRunSummarySchema.safeParse green (a full parse, because what moved is a VALUE), `map` over the same five answers failed=1, and both durable-pause directions (resume the CHILD and bubble up / resume the PARENT and delegate down) answer failed=1. CONTROL, pinned unchanged: a child that FAILED gives call {runs:5, failures:1, status:'failure'} and parent failed=1, with the failed child's own failed staying on its row while its writes still ride up. FIXTURE CORRECTION, measured not assumed: a `loop` body is fail-fast, so an UNGUARDED call to a failing child stops at row 3 (call runs=3) and the loop node adds a failure of its own, giving failed=2; the control's declared numbers are the CONTAINED parent, so the fixture guards the body. || 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). || GATES: 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 and reconciled with --ran carrying each command's own exit code captured BEFORE any pipe; its verdict line: '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 first answered PREREQUISITE NOT MET and were NOT counted as passes: check:skill-examples (client-react/client unbuilt), check:dual-build-cjs-loads exit 3, check:type-check-debt exit 3; the workspace build closure was then built (turbo run build over ./packages/* and ./packages/*/*, 72/72 tasks) and all three exited 0. || eslint . --no-inline-config over the WHOLE repo population: 6764 files, 0 errors, 0 warnings - a full run, so there is no narrowing to declare. Control-character self-scan over the diff's seven files: no match. || ABLATION - six legs, each on COMMITTED source, each proving the mutation reached disk by anchored-text occurrence counts AND a blob-hash inequality against HEAD (never an editor's exit code), each restored with `git checkout HEAD -- path` and proven restored by blob-hash equality plus an empty `git diff HEAD`; a trap on EXIT/INT/TERM restores every touched path by ABSOLUTE path. Baseline 16/16 green, post-restore 16/16 green, worktree clean. A: 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 + node status). D: asymmetry removed, a FAILED child's failed rides up -> 2 failed / 14 passed, exactly the two control tests. E: map stops accumulating -> 1 failed / 15 passed. F: creditChildRun stops crediting a paused child -> 2 failed / 14 passed, both resume directions. REPORTED HONESTLY: a first pass declared legs B/D/E VOID rather than green because 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. || Not measured here, declared to CI: the required contexts on the merge group's affected set. || CLAUSE-2 = no, DERIVED by reachability from the published entry (exports declares one entry, `.` -> dist/index.js + dist/index.d.ts; files ships dist), read from that entry's named-export set and cross-checked against the barrel chain in src/index.ts. Positive controls REACHABLE: summarizeRun, formatRunSummaryLine, AutomationEngine, registerLogicNodes, installBuiltinNodes. Negative controls unreachable: registerSubflowNode and registerMapNode are `export function` and re-exported by src/builtin/index.ts, yet the root barrel names six builtins and not those two; creditChildRun is a private member; plus a nonexistent identifier. So the derivation can answer both ways and is not a grep for the word export, nor a grep in dist/index.js. The delivered diff adds ZERO export statements outside the test file and ZERO keys to any published payload. Declared honestly: the VALUE on an already-published payload moves (a delegating parent's failed), which is conformance to a declaration already on main, not a widened surface.",
      "mcp_calls": "4 - four MCP GitHub reads (issue_read get + get_comments on #16314 and on #15617); the branch push, the PR creation and the report comment all went over REST in-container, and no MCP search was needed because no new card is filed.",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: 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, premise holds. Carrier: this PR itself, recorded in its Acceptance notes.",
        "noted, not filed: an UNGUARDED loop { subflow(failing child) } 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', unchanged by this diff, and not a defect. Carrier: none identified.",
        "noted, not filed: the refused-child roll-up is #18110's and is left exactly as found. NOT mechanically inseparable from this card, and the reason is mechanical rather than a judgement: 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. Carrier: #18110."
      ]
    }

    Generated by Claude Code

  5. claude commented on Sep 15, 2026

    @claude
    Contributor

    os-dev-report

    Amended — supersedes comment 5674942228; the PR BODY carried a heading-only Clause-② declaration and has been corrected. The diff is unchanged.

    {
      "issue": 16314,
      "status": "done",
      "branch": "claude/issue-16314-contained-failure-rollup-services-half",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/18261",
      "premise_still_valid": true,
      "summary": "ExecutionStepMetrics.failures is now populated by every producer that delegates to a child run, so `failed = the sum over nodes of failures` answers 'what did this run cause', subflows included. subflow-node.ts rolls a COMPLETED child's summary.failed on its non-failed exit only; map-node.ts accumulates the contained failures of the items that completed during each entry (MEASURED: `map` does NOT share subflow's roll-up path — it keeps its own accumulator — so it needed its own code and its own pin); engine.ts creditChildRun credits a child that PAUSED, both call sites being completion paths, which is what puts them inside the declared rule; summarizeRun folds metrics.failures into nodes[].failures. The failed-child boundary is implemented as declared and NOT symmetrically: nothing of a failed child's own `failed` rides up (its writes still do, via `acted`). Node status stays judged on the node's OWN executions — the fold takes the status verdict BEFORE adding the roll-up, and that ordering has its own ablation leg. PR #15609's narrowed wording is widened back in formatRunSummaryLine's comment and in content/docs/automation/flows.mdx. packages/spec untouched: the contract said everything needed. Premise valid with one path drift — the card's re-check grep names src/subflow-node.ts and the file lives at src/builtin/subflow-node.ts. POST-DELIVERY CORRECTION (coordinator-flagged, body only — the diff is untouched): the PR body carried its Clause-2 declaration only as a section HEADING, which scripts/pm/check-clause2-carriers.mjs readClause2Line answers near-miss/spelling, and check-changeset-no-major.mjs reads the declaration through that same reader out of the PR body, so Check Changeset was RED on it. Fixed by PATCHing the body to carry a STANDALONE bare line, placed immediately after Fixes #16314 and ahead of the heading so the reader returns on the first declaration attempt it meets. No commit pushed, nothing re-opened, no diff touched — pr-automation.yml carries an edited trigger for exactly this.",
      "tests": "NEW SUITE src/builtin/contained-failure-rollup.test.ts, 16 tests, all green, driving the card's measured target on the real AutomationEngine: parent `loop { subflow(child) }`, one child failing per five rows answers selected=5 acted=4 skipped=0 failed=1 (was failed=0), children failed=[0,0,1,0,0] unchanged, call node {status:'success', runs:5, failures:1}, FlowRunSummarySchema.safeParse green (a full parse, because what moved is a VALUE), `map` over the same five answers failed=1, and both durable-pause directions (resume the CHILD and bubble up / resume the PARENT and delegate down) answer failed=1. CONTROL, pinned unchanged: a child that FAILED gives call {runs:5, failures:1, status:'failure'} and parent failed=1, with the failed child's own failed staying on its row while its writes still ride up. FIXTURE CORRECTION, measured not assumed: a `loop` body is fail-fast, so an UNGUARDED call to a failing child stops at row 3 (call runs=3) and the loop node adds a failure of its own, giving failed=2; the control's declared numbers are the CONTAINED parent, so the fixture guards the body. || 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). || GATES: 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 and reconciled with --ran carrying each command's own exit code captured BEFORE any pipe; its verdict line: '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 first answered PREREQUISITE NOT MET and were NOT counted as passes: check:skill-examples (client-react/client unbuilt), check:dual-build-cjs-loads exit 3, check:type-check-debt exit 3; the workspace build closure was then built (turbo run build over ./packages/* and ./packages/*/*, 72/72 tasks) and all three exited 0. || eslint . --no-inline-config over the WHOLE repo population: 6764 files, 0 errors, 0 warnings - a full run, so there is no narrowing to declare. Control-character self-scan over the diff's seven files: no match. || ABLATION - six legs, each on COMMITTED source, each proving the mutation reached disk by anchored-text occurrence counts AND a blob-hash inequality against HEAD (never an editor's exit code), each restored with `git checkout HEAD -- path` and proven restored by blob-hash equality plus an empty `git diff HEAD`; a trap on EXIT/INT/TERM restores every touched path by ABSOLUTE path. Baseline 16/16 green, post-restore 16/16 green, worktree clean. A: 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 + node status). D: asymmetry removed, a FAILED child's failed rides up -> 2 failed / 14 passed, exactly the two control tests. E: map stops accumulating -> 1 failed / 15 passed. F: creditChildRun stops crediting a paused child -> 2 failed / 14 passed, both resume directions. REPORTED HONESTLY: a first pass declared legs B/D/E VOID rather than green because 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. || Not measured here, declared to CI: the required contexts on the merge group's affected set. || CLAUSE-2 = no, DERIVED by reachability from the published entry (exports declares one entry, `.` -> dist/index.js + dist/index.d.ts; files ships dist), read from that entry's named-export set and cross-checked against the barrel chain in src/index.ts. Positive controls REACHABLE: summarizeRun, formatRunSummaryLine, AutomationEngine, registerLogicNodes, installBuiltinNodes. Negative controls unreachable: registerSubflowNode and registerMapNode are `export function` and re-exported by src/builtin/index.ts, yet the root barrel names six builtins and not those two; creditChildRun is a private member; plus a nonexistent identifier. So the derivation can answer both ways and is not a grep for the word export, nor a grep in dist/index.js. The delivered diff adds ZERO export statements outside the test file and ZERO keys to any published payload. Declared honestly: the VALUE on an already-published payload moves (a delegating parent's failed), which is conformance to a declaration already on main, not a widened surface. || CLAUSE-2 CARRIER READBACK (post-PATCH, read from the STORED body, not from what was sent): readClause2Line answered {\"kind\":\"near-miss\",\"reason\":\"spelling\",\"line\":\"## Clause-②: no — derived, not predicted\"} BEFORE and {\"kind\":\"declared\",\"value\":\"no\",\"line\":\"Clause-②: no\"} AFTER. Platform trap handled as warned: a REST PATCH appends its own bare footer block, so the existing footer was stripped and attribution moved to ordinary prose before re-sending; the stored body is byte-exactly what was sent plus that one appended block (58 bytes), giving exactly ONE footer block and ONE rule line, zero less-than fragments, and Fixes #16314 as the only closing pair. The edited trigger re-fired Check Changeset: a new run started 04:56:34Z and is in_progress at report time (the 04:53:57Z run is the failure that was measured); ⛔ not polled, recorded as in_progress.",
      "mcp_calls": "4 - four MCP GitHub reads (issue_read get + get_comments on #16314 and on #15617); the branch push, the PR creation and the report comment all went over REST in-container, and no MCP search was needed because no new card is filed.",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: 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, premise holds. Carrier: this PR itself, recorded in its Acceptance notes.",
        "noted, not filed: an UNGUARDED loop { subflow(failing child) } 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', unchanged by this diff, and not a defect. Carrier: none identified.",
        "noted, not filed: the refused-child roll-up is #18110's and is left exactly as found. NOT mechanically inseparable from this card, and the reason is mechanical rather than a judgement: 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. Carrier: #18110."
      ]
    }

    Generated by Claude Code

  6. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions