From d279af85d5d9056c411437a0ffb11a0106c4b236 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 24 Aug 2026 10:43:43 +0700 Subject: [PATCH] fix(lanes): expire stale stage detections, cover raw test runs in ship-feature gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withDetected() rendered lane.detected_stage forever once written — the TTL in recordDetection only stops future writes from advancing past a stale floor, it never clears the row, so a lane gone quiet kept painting amber nodes from activity that was no longer current. Also add the shared test-runner regex to the ship-feature pipeline's `gates` node: Stage 1b's independent-test subagent runs the repo's test command directly, not through `ccam`, and had no detection fallback if `ccam stage tests` was skipped. --- server/__tests__/lanes-lib.test.js | 20 ++++++++++++++++++++ server/__tests__/stage-detect.test.js | 4 ++++ server/data/pipelines/ship-feature.json | 4 ++++ server/lib/lanes.js | 7 +++++++ 4 files changed, 35 insertions(+) diff --git a/server/__tests__/lanes-lib.test.js b/server/__tests__/lanes-lib.test.js index f917f854..ee440042 100644 --- a/server/__tests__/lanes-lib.test.js +++ b/server/__tests__/lanes-lib.test.js @@ -901,6 +901,26 @@ describe("stage detection", () => { } } }); + + it("a stale detection stops rendering as `detected` in pipeline_nodes, even though the DB row still holds it", () => { + const { db } = require("../db"); + const l = lanes.createLane({ cwd: "/tmp/wt-detect-stale-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); + + const lane = lanes.getLane(l.id); + // The TTL only gates future writes; the stale row is still there until a + // fresh detection or a real stage transition overwrites it. + assert.equal(lane.detected_stage, "tests"); + + const nodes = lanes.lanePayload(lane).pipeline_nodes; + assert.ok( + !nodes.some((n) => n.detected), + "a detection older than the TTL must not paint any node amber — it is no longer current information" + ); + lanes.deleteLane(l.id); + }); }); describe("the forward-only hold is short enough for one work session", () => { diff --git a/server/__tests__/stage-detect.test.js b/server/__tests__/stage-detect.test.js index 538930e1..7652fea0 100644 --- a/server/__tests__/stage-detect.test.js +++ b/server/__tests__/stage-detect.test.js @@ -521,6 +521,10 @@ test.describe("ship-feature pipeline template", () => { ["implementing", "Write", { file_path: "server/lib/app.js" }], ["gates", "Bash", { command: "ccam lanes hook ci-gate" }], ["gates", "Bash", { command: "ccam lanes sync-base --check feat/foo" }], + // Stage 1b's independent-test subagent runs the repo's raw test command + // directly, not through `ccam` — without this rule that pass has no + // detection fallback if the agent skips `ccam stage tests`. + ["gates", "Bash", { command: "npm run test:server" }], ["e2e-feature", "Bash", { command: "ccam lanes up --qc" }], ["e2e-feature", "Bash", { command: "ccam lanes hook e2e" }], ["review", "Skill", { skill: "code-review" }], diff --git a/server/data/pipelines/ship-feature.json b/server/data/pipelines/ship-feature.json index 369dbdd0..9f537c1b 100644 --- a/server/data/pipelines/ship-feature.json +++ b/server/data/pipelines/ship-feature.json @@ -96,6 +96,10 @@ { "tool": "Bash", "match": "\\bccam\\b[^;&|]*(\\bhook ci-gate\\b|\\bsync-base --check\\b)" + }, + { + "tool": "Bash", + "match": "\\b(npm (run )?test|pytest|vitest|jest|go test|cargo test|node\\s+--test|rspec|phpunit|dotnet test|mvn test|gradle test)\\b" } ] }, diff --git a/server/lib/lanes.js b/server/lib/lanes.js index 07a09b4f..ca9be498 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -466,8 +466,15 @@ function classifyLiveness({ status, stage, ageSec }, deadSec = DEAD_SEC) { * would otherwise render as an inference instead of the blue `current` ring. * Past nodes go through `stageRecords`, which resolves those alias keys — a * node the agent DECLARED must never be painted as merely detected. + * + * A detection older than the TTL is treated the same as no detection: the TTL + * in recordDetection only stops it from holding the forward-only floor for + * FUTURE writes, it never clears the row, so without this check a lane that + * went quiet would keep painting amber nodes from activity that is no longer + * current — the exact stale-progress illusion detection exists to avoid. */ function withDetected(states, pipeline, lane) { + if (detectionIsStale(lane.detected_at)) return states.map((n) => ({ ...n, detected: false })); const detectedIdx = phaseIdx(pipeline, lane.detected_stage); if (detectedIdx === -1) return states.map((n) => ({ ...n, detected: false })); const records = stageRecords(pipeline, lane.stages);