Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/16314-contained-failure-rollup-services-half.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/service-automation': patch
---

fix(service-automation): a delegating node rolls its COMPLETED child's contained failures into the run-level `failed` (#16314)

The services half of #15617's ruling (maintainer 「同意」 on option 1, decision batch #55). The spec half landed the slot: `ExecutionStepMetrics.failures`, 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 `FlowRunSummary.failed`. Until this, nothing populated it — the engine's fold could not see a child's losses, so a parent that delegated its rows reported `failed: 0` while its children lost them. `acted` had rolled up since #4354; the failure count had not, and the two paragraphs of the declaration disagreed for exactly that shape.

**What moves on the wire.** For a run whose `subflow` or `map` child COMPLETED while containing failures, the delegating node's `nodes[].failures` and the run-level `failed` grow by the child's own `failed` — and the summary line prints it. The measured target from #15617, driven on the real engine:

```
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 boundary, unchanged and pinned as the control.** A child that **failed** rather than contained is the delegating step's own failure, counted once through `nodes[].failures` exactly as it always was: `call: {runs: 5, failures: 1}`, parent `failed = 1`, with nothing of the child's own `failed` riding up. That is the one place this rule parts from `acted`'s, which does carry a failed child's writes. Implementing the symmetric-looking version would count one loss twice, and the control test is red on it.

**A delegating node's `status` is unaffected.** `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`, as the field declares. The fold takes the status verdict before it adds the roll-up.

Three producers, each measured rather than assumed: `subflow-node.ts` (synchronous child), `map-node.ts` (per-item children — it does **not** share `subflow`'s roll-up path and needed its own), and `AutomationEngine.creditChildRun` (a child that PAUSED, whose parent step was written at suspend time; both the child-resume up-bubble and the parent-resume down-delegation are completion paths, which is what puts them inside the declared rule).

`failed` keeps its convention: absent is "not tracked", never zero — an absent `metrics.failures` means the execution delegated nothing or the child tracked no count, and nothing writes a `0` that would claim a measurement.

PR #15609's narrowed wording — *"no node execution **of this run** failed"* — was true only while the paragraphs disagreed, and is widened back here in the summary-line comment and in `content/docs/automation/flows.mdx`: `failed=0` now reads *"nothing this run caused failed, subflows included"*.

No API moves: no new export, no new key on any published payload, and the node executors' `NodeExecutionResult.metrics` shape is the spec's already-published one.
4 changes: 2 additions & 2 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1124,8 +1124,8 @@ run in `listRuns` / `getRun`, and in the log:
| `acted` | Records **created / updated / deleted**, plus effects dispatched (notifications delivered) |
| `skipped` | Node executions a **closed gate** prevented — one per loop iteration whose conditional edge evaluated false |
| `unmeasured` | Executions that reached something the platform **cannot count** — see below |
| `failed` | Node executions **of this run** that failed — on a completed run every one of them was contained, caught by a `try_catch` or routed down a `fault` edge, so the run went on; the sum of `nodes[].failures`. `failed=0` therefore reads "no node execution of this run failed", which is narrower than "nothing failed anywhere": a `subflow` child's own contained failures are counted on the CHILD's summary, not folded up here the way `acted` is (that inconsistency in the declaration is [#15617](https://github.com/objectstack-ai/objectstack/issues/15617)). Absent on a run that did not track it, which is not zero |
| `nodes[]` | Per-node terminal status with `runs` / `failures` / `skipped` and its own selected/acted |
| `failed` | Node executions that failed — on a completed run every one of them was contained, caught by a `try_catch` or routed down a `fault` edge, so the run went on; the sum of `nodes[].failures`. The fold **includes what a delegating node rolled up from its child**: a `subflow` child, or a `map` item, that COMPLETED while containing failures reports them on the delegating step, exactly as `acted` already rode up, so a parent whose child lost a row does not read `failed=0`. A child that **failed** rather than contained is the delegating step's own failure, counted once there, and its own `failed` stays on the child's run row. `failed=0` therefore reads "nothing this run caused failed, subflows included". Absent on a run that did not track it, which is not zero |
| `nodes[]` | Per-node terminal status with `runs` / `failures` / `skipped` and its own selected/acted. `status` is judged on the node's **own** executions, so a delegating node that ran fine and rolled a child's contained failures up reads `success` with `failures > 0` — and on such a node `failures` may exceed `runs` |
| `gates[]` | Which gates closed and how often, most-skipped first |

The counts come from the node executors themselves — `get_record` reports what
Expand Down
Loading
Loading