diff --git a/client/src/components/lanes/LaneStripCard.tsx b/client/src/components/lanes/LaneStripCard.tsx index da14024b..1bc0753d 100644 --- a/client/src/components/lanes/LaneStripCard.tsx +++ b/client/src/components/lanes/LaneStripCard.tsx @@ -25,12 +25,18 @@ const LIVENESS_DOT: Record = { * 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") ); } diff --git a/server/__tests__/lanes-lib.test.js b/server/__tests__/lanes-lib.test.js index 36aa9429..aefa6cc7 100644 --- a/server/__tests__/lanes-lib.test.js +++ b/server/__tests__/lanes-lib.test.js @@ -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); }); }); diff --git a/server/lib/lanes.js b/server/lib/lanes.js index 425908e4..cce377ae 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -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)), diff --git a/server/lib/tmux.js b/server/lib/tmux.js index 32aec13e..14518ef2 100644 --- a/server/lib/tmux.js +++ b/server/lib/tmux.js @@ -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) {