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
+51
View File
@@ -305,6 +305,51 @@ function reservedPorts(excludeLaneId = null) {
* has to keep saying so, or an inferred stage becomes indistinguishable from
* one the agent actually claimed.
*/
/**
* Append one row to the stage log — the history `lanes.stages` cannot keep,
* because that blob is the current pass and gets overwritten and truncated.
*
* The run a row belongs to is decided here rather than stored on the lane, so
* there is one rule and no column to fall out of sync. A new run starts when
* the lane moves BACKWARDS (the next feature begins at plan) or when the last
* event sat on the pipeline's final node (the previous feature finished). Any
* other move continues the run in flight.
*
* Never throws: a lane's stage must still advance if this log cannot be
* written — the dashboard losing a statistic beats an agent's pipeline dying.
*/
function appendStageEvent(lane, nodeId, { source, result, evidence } = {}) {
try {
const pipeline = getPipeline(lane.pipeline);
const last = db
.prepare("SELECT node_id, run_seq FROM lane_stage_events WHERE lane_id = ? ORDER BY id DESC")
.get(lane.id);
let runSeq = 1;
if (last) {
const lastIdx = phaseIdx(pipeline, last.node_id);
const nextIdx = phaseIdx(pipeline, nodeId);
const wentBack = lastIdx > -1 && nextIdx > -1 && nextIdx < lastIdx;
const finished = lastIdx > -1 && lastIdx === pipeline.nodes.length - 1 && nextIdx !== lastIdx;
runSeq = last.run_seq + (wentBack || finished ? 1 : 0);
}
db.prepare(
`INSERT INTO lane_stage_events (lane_id, pipeline, run_seq, node_id, source, result, evidence, at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(
lane.id,
lane.pipeline,
runSeq,
nodeId,
source,
result || null,
evidence ? 1 : 0,
nowIso()
);
} catch {
/* the log is a report, never a gate on the lane's own state */
}
}
function setStage(id, { stage, status, evidence, note, result, keepDetection } = {}) {
const lane = getLane(id);
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
@@ -342,6 +387,11 @@ function setStage(id, { stage, status, evidence, note, result, keepDetection } =
nowIso(),
id
);
appendStageEvent(lane, next, {
source: "declared",
result: stages[next].result,
evidence: stages[next].evidence,
});
return getLane(id);
}
@@ -396,6 +446,7 @@ function recordDetection(id, { nodeId, signal } = {}) {
db.prepare(
"UPDATE lanes SET detected_stage = ?, detected_signal = ?, detected_at = ? WHERE id = ?"
).run(nodeId, signal || null, nowIso(), id);
appendStageEvent(lane, nodeId, { source: "detected" });
return { written: true };
}