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:
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user