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
+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) {