fix(lanes): count every pipeline run, not one per lane

Lane Pipeline Pass Rates read `lanes.stages`, which is a snapshot of the
pass in flight: re-entering a node overwrites its record, and moving
backwards deletes every record ahead of the new stage. The report treated
one lane row as one run, so shipping ten features through a lane still
counted as one, and no node could ever exceed 1/1. History survived only
via `lane_features`, which is written when a feature is activated — a
path most users never take. The panel was not wrong, it was structurally
incapable of counting.

Add `lane_stage_events`, an append-only log written by every declaration
and every accepted detection, and aggregate from that. A run is one pass
through the template: the log starts a new one when the lane moves
backwards or leaves the template's last node. Existing snapshots are
seeded as one run each on first boot, so upgrading does not read as
"never run". Templates a lane is configured with are still listed at
zero — a pipeline going unused has to be visible as such.

Count inferred reaches too, in their own column and their own colour.
Detection stays out of the pass rate ("inference is never evidence"), but
reporting a stage the hooks demonstrably saw as "never reached" is what
made the panel look broken to anyone who works without running the
pipeline commands.

Also reject a stage naming no node of the lane's pipeline (400 EBADSTAGE,
listing the ones that exist) instead of storing it with a warning nobody
reads. A real lane had accumulated five such keys, one of them `test` on
a ship-feature lane whose CI node is `gates` — which is why that row read
never-reached while the stage had in fact been declared. Aliases still
resolve, so shorthand is unaffected.
This commit is contained in:
2026-09-09 15:32:09 +07:00
parent 3549000fa9
commit 876c6de6d2
15 changed files with 520 additions and 121 deletions
+21 -7
View File
@@ -611,14 +611,23 @@ Converge the clone-root `proof/` directory (if it exists at `<lane.cwd>/proof`)
GET /api/lanes/pipeline-stats
```
How often each pipeline node was actually passed, aggregated over every lane
**and** every archived feature (an archived feature is a finished run of a
pipeline, so it carries most of the history once a lane starts its next
feature). Grouped by pipeline template, busiest first.
How often each pipeline node was actually passed, aggregated over every recorded
run and grouped by pipeline template, busiest first.
Read from `lane_stage_events`, the append-only log every stage declaration
writes — **not** from `lanes.stages`. That blob describes the pass in flight: it
is overwritten when a node is re-entered and truncated when the lane moves
backwards, so reading it counted one run per lane row no matter how many
features that lane shipped, and no amount of use ever moved the numbers. A run
is one pass through the template: the log starts a new one when the lane moves
backwards or leaves the template's last node.
Templates configured on a lane are always listed, at zero, even before that lane
records anything — a pipeline going unused has to be visible as such.
**Query parameters:**
- **`lane`** — lane id; narrows the aggregation to that lane and its own
archived features. Omit (or pass `all`) for every lane.
- **`lane`** — lane id; narrows the aggregation to that lane's own runs. Omit
(or pass `all`) for every lane.
```json
{
@@ -629,7 +638,7 @@ feature). Grouped by pipeline template, busiest first.
"runs": 4,
"nodes": [
{ "id": "plan", "label": "plan", "icon": "🧭", "gate": false,
"entered": 4, "passed": 4, "withEvidence": 4, "failed": 0 }
"entered": 4, "passed": 4, "withEvidence": 4, "failed": 0, "detected": 0 }
]
}
],
@@ -637,6 +646,11 @@ feature). Grouped by pipeline template, busiest first.
}
```
- **`detected`** — runs that reached the node by inference from the hook stream
with no declaration. Counted apart from `entered`: it says the work happened,
not that the gate was met. A node the hooks saw is reported as such rather
than as "never reached", which is what a session that never runs the pipeline
commands used to look like.
- **`lanes`** — every lane, for the picker. Never narrowed by `lane`, so the
response always offers the way back to the other lanes.
- **`runs`** — lanes plus archived features recorded against this template.
+12 -2
View File
@@ -687,8 +687,18 @@ things beyond the ordinary transition:
The node the declaration lands on keeps its own record (its evidence and
`result` survive), and `stage_since` restarts, so time-on-phase measures this
pass. Records stored under a key matching **no** node in the pipeline are left
untouched — their position is unknown, so they cannot be proven to be ahead;
for the same reason, declaring a stage that matches no node prunes nothing.
untouched — their position is unknown, so they cannot be proven to be ahead.
Such keys only exist on lanes that predate validation: `POST
/api/lanes/:id/stage` now rejects a stage naming no node of the lane's pipeline
with **400 `EBADSTAGE`** and lists the ones that exist. Aliases still resolve,
so this catches typos and names borrowed from another template — `agenthub
stage test` on a `ship-feature` lane, whose CI node is `gates` — which used to
be stored, warned about on stderr, and then counted nowhere.
Every declaration is also appended to `lane_stage_events`, the append-only log
the pass-rate report reads. `lanes.stages` describes the pass in flight and is
overwritten and truncated by the rules above; the log is what remembers that
the previous pass happened at all.
Going back this way is a *declaration*, not a reset: the lane, its worktree, and
its archived features are untouched. To start a genuinely separate piece of work