fix(lanes): render undeclared lanes at their detected stage, quiet tmux polling noise

A lane whose sessions never call `ccam stage` had no declaration at all, so
its pipeline map stayed 100% pending even though detection was tracking real
progress. inferredDisplayStage renders such a lane AT its last detection
instead (display only — the DB's declared `stage` and detection-as-evidence
rules are untouched). Also stops tmux's own "no session" stderr noise from
spamming the journal on every poll.
This commit is contained in:
2026-09-03 11:28:06 +07:00
parent b27e679eac
commit 8711917d44
4 changed files with 78 additions and 11 deletions
@@ -25,12 +25,18 @@ const LIVENESS_DOT: Record<Lane["liveness"], string> = {
* ALIAS (`tests`, `booting`, `ship` — most of what the ship-feature skill
* declares) is not a node id, so indexOf answers -1 and a detection sitting
* well BEHIND the declaration would still be badged as the live one.
*
* `>=`, not `>`: an undeclared lane is rendered AT its detected node by the
* server (see `inferredDisplayStage`), so the detection and the "current" node
* are the same one — and that lane is precisely the one whose stage the badge
* is the only readout for. A declared lane can never tie, because
* recordDetection refuses any detection that isn't strictly ahead.
*/
function detectionLeads(lane: Lane): boolean {
if (!lane.detected_stage) return false;
const nodes = lane.pipeline_nodes;
return (
nodes.findIndex((n) => n.id === lane.detected_stage) >
nodes.findIndex((n) => n.id === lane.detected_stage) >=
nodes.findIndex((n) => n.state === "current")
);
}
+30 -6
View File
@@ -589,12 +589,12 @@ describe("stage detection", () => {
!payload.pipeline_nodes.some((n) => n.state === "done"),
"inference must never render a node done — only a declared stage carrying evidence may"
);
// lane.stage is still the default "idle", which is not itself a pipeline
// node/alias, so nodeStates() (unaware of detection) reports "pending" for
// every node here — detection only ever adds the `detected` flag alongside
// whatever nodeStates() already computed from the declared stage.
// lane.stage is still the default "idle" and `stages` is empty, so the lane
// has no declaration of its own to render and is displayed AT its detected
// node instead (inferredDisplayStage) — `current`, and flagged `detected`
// so it paints amber rather than as a declaration.
const review = payload.pipeline_nodes.find((n) => n.id === "review");
assert.equal(review.state, "pending");
assert.equal(review.state, "current");
assert.equal(review.detected, true);
const implement = payload.pipeline_nodes.find((n) => n.id === "implement");
assert.equal(implement.detected, true);
@@ -948,9 +948,10 @@ describe("stage detection", () => {
}
});
it("a stale detection stops rendering as `detected` in pipeline_nodes, even though the DB row still holds it", () => {
it("a stale detection stops rendering as `detected` on a lane that HAS declared a stage", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-detect-stale-render" });
lanes.setStage(l.id, { stage: "plan" });
lanes.recordDetection(l.id, { nodeId: "tests" });
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(oldTime, l.id);
@@ -965,6 +966,29 @@ describe("stage detection", () => {
!nodes.some((n) => n.detected),
"a detection older than the TTL must not paint any node amber — it is no longer current information"
);
// The declaration is what the lane still renders from.
assert.equal(nodes.find((n) => n.id === "plan").state, "current");
lanes.deleteLane(l.id);
});
it("a lane that never declared a stage keeps rendering its LAST detection, TTL or not", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-detect-undeclared-render" });
lanes.recordDetection(l.id, { nodeId: "tests" });
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(oldTime, l.id);
// With nothing declared, the last detection IS the lane's last known
// progress — blanking it left every non-CCAM repo's card 100% pending
// forever. Still amber (`detected`), still never `done`.
const payload = lanes.lanePayload(lanes.getLane(l.id));
const tests = payload.pipeline_nodes.find((n) => n.id === "tests");
assert.equal(tests.state, "current");
assert.equal(tests.detected, true);
assert.ok(!payload.pipeline_nodes.some((n) => n.state === "done"));
assert.ok(payload.progress > 0, "an inferred stage advances the progress bar");
// The declared stage the DB holds is reported untouched.
assert.equal(payload.stage, "idle");
lanes.deleteLane(l.id);
});
});
+34 -2
View File
@@ -517,15 +517,47 @@ function withDetected(states, pipeline, lane) {
}));
}
/**
* The inferred stage a lane should be RENDERED at, or null when the lane has a
* declaration of its own to render.
*
* A lane whose sessions never call `ccam stage` — any repo that isn't wired to
* CCAM — has no declaration at all: `stage` sits on the DB sentinel `idle` and
* `stages` stays `{}` forever, so its pipeline map drew 100% pending while
* detection was quietly tracking every node the agent reached. Such a lane is
* rendered from its detection instead.
*
* DISPLAY only, and inference still never becomes evidence: `stage` in the DB
* is untouched (recordDetection remains the only writer of `detected_*`), the
* payload's own `stage` field still reports the real sentinel, and every node
* this paints is flagged `detected` — amber — so none of them can read as
* `done`. The TTL is deliberately not applied here: for a lane with nothing
* else to show, the last thing detected IS its last known progress, exactly
* like a declared stage that also never expires.
*/
function inferredDisplayStage(lane) {
if (!lane.detected_stage) return null;
if (lane.stage && lane.stage !== "idle") return null;
if (Object.keys(lane.stages || {}).length) return null;
return lane.detected_stage;
}
function lanePayload(lane, ageSec = null) {
const pipeline = getPipeline(lane.pipeline);
const since = lane.stage_since ? Date.parse(lane.stage_since) : NaN;
const inferred = inferredDisplayStage(lane);
const inferredIdx = inferred ? phaseIdx(pipeline, inferred) : -1;
const view = inferredIdx === -1 ? lane : { ...lane, stage: inferred };
const nodes = nodeStates(pipeline, view);
return {
...lane,
pipeline_name: pipeline.name,
pipeline_skill: pipeline.skill || "",
pipeline_nodes: withDetected(nodeStates(pipeline, lane), pipeline, lane),
progress: progressPct(pipeline, lane),
pipeline_nodes:
inferredIdx === -1
? withDetected(nodes, pipeline, lane)
: nodes.map((n, i) => ({ ...n, detected: i <= inferredIdx })),
progress: progressPct(pipeline, view),
stage_seconds: Number.isNaN(since)
? null
: Math.max(0, Math.round((Date.now() - since) / 1000)),
+7 -2
View File
@@ -18,13 +18,18 @@ const { execFileSync } = require("node:child_process");
// Test seam: swap the exec implementation so unit tests never invoke a real
// tmux binary. Mirrors run-spawner.js's __injectChildForTest/__reset style.
let execImpl = (args) => execFileSync("tmux", args, { encoding: "utf8" });
// stderr is ignored, not inherited: every caller already wraps these in
// try/catch and treats failure as "no session" — piping tmux's own
// "error connecting to ..." noise to the process's stderr just spams the
// journal on every poll while no tmux server is running yet.
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] };
let execImpl = (args) => execFileSync("tmux", args, EXEC_OPTS);
function __setExecImpl(fn) {
execImpl = fn;
}
function __reset() {
execImpl = (args) => execFileSync("tmux", args, { encoding: "utf8" });
execImpl = (args) => execFileSync("tmux", args, EXEC_OPTS);
}
function hasSession(name) {