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
+108 -50
View File
@@ -19,7 +19,7 @@ const {
getPipeline,
nodeStates,
progressPct,
stageRecords,
phaseIdx,
} = require("../lib/pipelines");
const laneFeatures = require("../lib/lane-features");
const proofLib = require("../lib/proof");
@@ -161,63 +161,100 @@ router.get("/pipeline-stats", (req, res) => {
}
}
const rows =
const events =
laneId === null
? [
...db.prepare("SELECT pipeline, stages FROM lanes").all(),
...db.prepare("SELECT pipeline, stages FROM lane_features").all(),
]
: [
...db.prepare("SELECT pipeline, stages FROM lanes WHERE id = ?").all(laneId),
...db.prepare("SELECT pipeline, stages FROM lane_features WHERE lane_id = ?").all(laneId),
];
? db
.prepare(
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events ORDER BY id"
)
.all()
: db
.prepare(
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE lane_id = ? ORDER BY id"
)
.all(laneId);
const acc = new Map();
for (const row of rows) {
let stages;
try {
stages = JSON.parse(row.stages);
} catch {
continue; // a corrupt blob costs one row, never the whole report
}
const pipeline = getPipeline(row.pipeline);
const entryFor = (pipelineId) => {
const pipeline = getPipeline(pipelineId);
let entry = acc.get(pipeline.id);
if (!entry) {
entry = {
id: pipeline.id,
name: pipeline.name || pipeline.id,
runs: 0,
nodes: pipeline.nodes.map((n) => ({
id: n.id,
label: n.label,
icon: n.icon,
gate: n.gate,
command: n.command,
entered: 0,
passed: 0,
withEvidence: 0,
failed: 0,
})),
};
entry = { id: pipeline.id, runs: new Set(), nodes: new Map(), template: pipeline };
acc.set(pipeline.id, entry);
}
entry.runs += 1;
const records = stageRecords(pipeline, stages);
for (const [idx, rec] of records) {
const node = entry.nodes[idx];
if (!node) continue;
node.entered += 1;
if (rec && rec.result === "fail") node.failed += 1;
else {
node.passed += 1;
if (rec && rec.evidence) node.withEvidence += 1;
}
}
return entry;
};
// Seed the templates the lanes in scope are configured with, before folding
// any events in. A lane that has recorded nothing still has to render its map
// — the picker is how you find out a pipeline is going unused, so a pipeline
// with zero runs must appear, at zero, rather than vanish.
const scopedLanes =
laneId === null
? db.prepare("SELECT pipeline FROM lanes").all()
: db.prepare("SELECT pipeline FROM lanes WHERE id = ?").all(laneId);
for (const row of scopedLanes) entryFor(row.pipeline);
// Fold the event stream down to one verdict per (run, node): a run that
// entered a node twice is still one pass of it, and a declaration always
// beats a detection of the same node — the agent's own claim is the stronger
// statement, so a node it declared must never be reported as merely inferred.
for (const ev of events) {
const entry = entryFor(ev.pipeline);
const pipeline = entry.template;
entry.runs.add(`${ev.lane_id}#${ev.run_seq}`);
const idx = phaseIdx(pipeline, ev.node_id);
if (idx === -1) continue; // a stage nothing in this pipeline is named after
const key = `${ev.lane_id}#${ev.run_seq}#${idx}`;
const prev = entry.nodes.get(key);
const declared = ev.source !== "detected";
if (prev && prev.declared && !declared) continue;
entry.nodes.set(key, {
idx,
declared: declared || (prev ? prev.declared : false),
failed: declared ? ev.result === "fail" : prev ? prev.failed : false,
evidence: (declared && !!ev.evidence) || (prev ? prev.evidence : false),
});
}
// Busiest pipeline first: the one with the most recorded runs is the one the
// reader most likely came to look at.
const pipelines = [...acc.values()].sort((a, b) => b.runs - a.runs);
const pipelines = [...acc.values()]
.map((entry) => {
const nodes = entry.template.nodes.map((n) => ({
id: n.id,
label: n.label,
icon: n.icon,
gate: n.gate,
command: n.command,
entered: 0,
passed: 0,
withEvidence: 0,
failed: 0,
detected: 0,
}));
for (const v of entry.nodes.values()) {
const node = nodes[v.idx];
if (!node) continue;
if (!v.declared) {
// Reached, but only inferred from the hook stream. Counted apart from
// the pass rate: it says the work happened, not that a gate was met.
node.detected += 1;
continue;
}
node.entered += 1;
if (v.failed) node.failed += 1;
else {
node.passed += 1;
if (v.evidence) node.withEvidence += 1;
}
}
const name = entry.template.name || entry.id;
return { id: entry.id, name, runs: entry.runs.size, nodes };
})
// Busiest pipeline first: the one with the most recorded runs is the one
// the reader most likely came to look at.
.sort((a, b) => b.runs - a.runs);
const lanes = db.prepare("SELECT id, title, cwd FROM lanes ORDER BY id").all();
res.json({ pipelines, lanes });
});
@@ -407,9 +444,30 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
});
router.post("/:id/stage", sameOriginGuard, (req, res) => {
if (!lanesLib.getLane(req.params.id)) {
const current = lanesLib.getLane(req.params.id);
if (!current) {
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
}
// A stage naming no node of this lane's pipeline used to be stored and then
// silently ignored by every reader — `agenthub stage test` on a ship-feature
// lane (whose node is `gates`) left the CI row reading "never reached" with
// no hint anything was wrong. Aliases still resolve, so this rejects typos
// and wrong-pipeline names, not shorthand.
const requested = req.body && req.body.stage;
if (requested !== undefined && requested !== null && requested !== "") {
const pipeline = getPipeline(current.pipeline);
if (phaseIdx(pipeline, requested) === -1) {
return res.status(400).json({
error: {
code: "EBADSTAGE",
message:
`"${requested}" is not a stage of the "${pipeline.id}" pipeline. ` +
`Nodes: ${pipeline.nodes.map((n) => n.id).join(", ")}`,
stages: pipeline.nodes.map((n) => n.id),
},
});
}
}
const lane = lanesLib.setStage(req.params.id, req.body || {});
broadcastLane(lane.id);
res.json({ lane: payload(lane) });