diff --git a/client/package.json b/client/package.json index 0ecb3ab..62adb1d 100644 --- a/client/package.json +++ b/client/package.json @@ -7,8 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "test": "vitest run", - "test:watch": "vitest" + "test": "NODE_OPTIONS=--no-experimental-webstorage vitest run", + "test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest" }, "dependencies": { "@fontsource/inter": "^5.2.8", diff --git a/server/__tests__/lanes-lib.test.js b/server/__tests__/lanes-lib.test.js index dc8c75d..893b599 100644 --- a/server/__tests__/lanes-lib.test.js +++ b/server/__tests__/lanes-lib.test.js @@ -587,6 +587,39 @@ describe("stage detection", () => { lanes.deleteLane(l.id); }); + it("a real stage transition clears a stale detection and unblocks a fresh one behind it", () => { + const l = lanes.createLane({ cwd: "/tmp/wt-detect-stale-cross-task" }); + // A prior, unrelated task's tool calls left `tests` as the detected stage. + lanes.recordDetection(l.id, { nodeId: "tests", signal: "`npm test`" }); + assert.equal(lanes.getLane(l.id).detected_stage, "tests"); + + // A new task starts and the agent declares an EARLIER stage. Without the + // fix, the leftover `tests` detection both misrepresents the new task's + // progress and (via forward-only) rejects every real detection for it + // until `tests` ages past DETECTION_TTL_MS. + const after = lanes.setStage(l.id, { stage: "plan" }); + assert.equal(after.detected_stage, null); + assert.equal(after.detected_signal, null); + assert.equal(after.detected_at, null); + + assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), { + written: true, + }); + assert.equal(lanes.getLane(l.id).detected_stage, "implement"); + lanes.deleteLane(l.id); + }); + + it("re-declaring the SAME stage (a heartbeat) leaves a live detection alone", () => { + const l = lanes.createLane({ cwd: "/tmp/wt-detect-heartbeat-noop" }); + lanes.setStage(l.id, { stage: "plan" }); + assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), { + written: true, + }); + lanes.setStage(l.id, { stage: "plan", note: "still planning" }); + assert.equal(lanes.getLane(l.id).detected_stage, "implement"); + lanes.deleteLane(l.id); + }); + it("migration: detection columns are added to a database holding an old-schema lanes row", () => { const tmpPath = pathMod.join( os.tmpdir(), @@ -744,12 +777,18 @@ describe("stage detection", () => { it("detection expiry: stale detection behind declared stage still rejects", () => { const { db } = require("../db"); const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-declared" }); - // Stand up the detection BEFORE declaring, otherwise declared-wins refuses - // it and there is no standing detection left to age. - lanes.recordDetection(l.id, { nodeId: "tests" }); lanes.setStage(l.id, { stage: "review" }); + // setStage now clears detected_* on every real transition (a fresh + // declaration supersedes older inference), so a standing detection is + // written directly here, bypassing that, to isolate the rule this test + // is actually about: declared-wins is never relaxed by staleness, no + // matter how the stale detection got there. const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); - db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(oldTime, l.id); + db.prepare("UPDATE lanes SET detected_stage = ?, detected_at = ? WHERE id = ?").run( + "tests", + oldTime, + l.id + ); // Try backward detection to implement - should still be rejected because declared stage is ahead const result = lanes.recordDetection(l.id, { nodeId: "implement" }); diff --git a/server/data/pipelines/default.json b/server/data/pipelines/default.json index 27daa5c..a18226d 100644 --- a/server/data/pipelines/default.json +++ b/server/data/pipelines/default.json @@ -50,7 +50,7 @@ }, { "tool": "Write", - "match": "^(?!.*(?:^|/)docs/)" + "match": "^(?!.*(?:^|/)(?:docs|\\.superpowers)/)" } ] }, diff --git a/server/lib/lanes.js b/server/lib/lanes.js index 91ac42f..d19abbe 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -189,24 +189,36 @@ function deleteLane(id) { * Record a stage transition. `stage_since` moves ONLY when the stage value * actually changes, so the UI's time-on-phase is real; a re-report of the same * stage (a heartbeat, an added note) leaves it alone. + * + * A real transition also clears `detected_stage`/`detected_signal`/`detected_at`. + * Inference tracks progress relative to whatever the agent last declared; once + * the agent declares again, any older detection is either stale (a prior task's + * leftover, e.g. `tests` from earlier work bleeding into a fresh `plan`) or + * redundant (the agent's own claim now covers it). Left in place it would both + * paint stale progress in the UI AND — because recordDetection is forward-only + * — silently reject every real detection for the new stage until the old one + * ages past DETECTION_TTL_MS. */ function setStage(id, { stage, status, evidence, note, result } = {}) { const lane = getLane(id); if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" }); const next = stage || lane.stage; + const changed = next !== lane.stage; const stages = { ...lane.stages }; const prev = stages[next] || {}; stages[next] = { - enteredAt: next === lane.stage && prev.enteredAt ? prev.enteredAt : nowIso(), + enteredAt: !changed && prev.enteredAt ? prev.enteredAt : nowIso(), evidence: evidence !== undefined ? evidence : prev.evidence || null, result: result !== undefined ? result : prev.result || null, }; db.prepare( - `UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ? + `UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?${ + changed ? ", detected_stage = NULL, detected_signal = NULL, detected_at = NULL" : "" + } WHERE id = ?` ).run( next, - next === lane.stage ? lane.stage_since || nowIso() : nowIso(), + changed ? nowIso() : lane.stage_since || nowIso(), status || lane.status, JSON.stringify(stages), note !== undefined ? note : lane.notes,