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:
@@ -457,6 +457,38 @@ db.exec(`
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lane_features_lane ON lane_features(lane_id);
|
||||
|
||||
-- Append-only log of every stage a lane has ever entered.
|
||||
--
|
||||
-- lanes.stages cannot answer "how often is this stage passed": it is a
|
||||
-- snapshot of the CURRENT pass, overwritten when a node is re-entered and
|
||||
-- truncated when the lane moves backwards. Pass rates read from it counted
|
||||
-- one run per lane row no matter how many features that lane shipped.
|
||||
--
|
||||
-- run_seq groups events into passes: it increments when the lane moves
|
||||
-- backwards or leaves the pipeline's last node, which is what starting the
|
||||
-- next feature looks like from here.
|
||||
--
|
||||
-- source separates 'declared' (an agent ran the stage command) from
|
||||
-- 'detected' (inferred from the hook stream). Both are recorded; only the
|
||||
-- reader decides what a claim is worth.
|
||||
CREATE TABLE IF NOT EXISTS lane_stage_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lane_id INTEGER NOT NULL,
|
||||
pipeline TEXT NOT NULL,
|
||||
run_seq INTEGER NOT NULL DEFAULT 1,
|
||||
node_id TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'declared',
|
||||
result TEXT,
|
||||
evidence INTEGER NOT NULL DEFAULT 0,
|
||||
at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
FOREIGN KEY (lane_id) REFERENCES lanes(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lane_stage_events_lane
|
||||
ON lane_stage_events(lane_id, run_seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_lane_stage_events_pipeline
|
||||
ON lane_stage_events(pipeline, node_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lanes_session ON lanes(session_id);
|
||||
`);
|
||||
|
||||
@@ -653,6 +685,52 @@ try {
|
||||
}
|
||||
db.prepare("CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_cwd_tool ON lanes(cwd, tool)").run();
|
||||
|
||||
// Seed lane_stage_events from the snapshots that predate it, so upgrading does
|
||||
// not read as "every pipeline has never run". Each lane's live `stages` blob
|
||||
// becomes one run; each archived feature becomes one more. That is all the
|
||||
// history those blobs ever held — the events table is what starts keeping more.
|
||||
//
|
||||
// Guarded on the table being empty rather than a migration flag: re-seeding a
|
||||
// table someone truncated re-adds at most one run per lane, and a fresh install
|
||||
// has nothing to copy.
|
||||
if (db.prepare("SELECT count(*) AS c FROM lane_stage_events").get().c === 0) {
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO lane_stage_events (lane_id, pipeline, run_seq, node_id, source, result, evidence, at)
|
||||
VALUES (?, ?, ?, ?, 'declared', ?, ?, ?)`
|
||||
);
|
||||
const seed = db.transaction(() => {
|
||||
const rows = [
|
||||
...db.prepare("SELECT id AS lane_id, pipeline, stages FROM lanes").all(),
|
||||
...db.prepare("SELECT lane_id, pipeline, stages FROM lane_features").all(),
|
||||
];
|
||||
const runByLane = 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 migration
|
||||
}
|
||||
const entries = Object.entries(stages);
|
||||
if (!entries.length) continue;
|
||||
const runSeq = (runByLane.get(row.lane_id) || 0) + 1;
|
||||
runByLane.set(row.lane_id, runSeq);
|
||||
for (const [nodeId, rec] of entries) {
|
||||
insert.run(
|
||||
row.lane_id,
|
||||
row.pipeline || "default",
|
||||
runSeq,
|
||||
nodeId,
|
||||
rec && rec.result ? rec.result : null,
|
||||
rec && rec.evidence ? 1 : 0,
|
||||
(rec && rec.enteredAt) || new Date().toISOString()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
seed();
|
||||
}
|
||||
|
||||
// Migrate: link agent rows to a workflow run. Workflow inner-agents are already
|
||||
// ingested as subagents (same subagents/ dir); these columns add the grouping +
|
||||
// phase that the run journal provides. Additive, safe on existing DBs.
|
||||
|
||||
Reference in New Issue
Block a user