Files
AgentHub/server/__tests__/lanes-lib.test.js
T
nntrivi2001 3223c8edac feat(lanes): run ONE pipeline node, from the terminal or a slash command
A pipeline was all-or-nothing: `ccam ship-full` / `ship-quick` hand the whole
thing to a skill, and a node's own `command` — added so the map would stop
hiding the driver behind its detect regexes — was display-only. Re-running just
the CI gate, or redoing the brainstorm, meant clicking copy on the node and
pasting the string into a session by hand.

`ccam lanes node [<id>] [extra…] [--print]` runs it, in the lane's own cwd. A
slash-command node spawns `claude` with it as the prompt (same shape as
`ccam ship-full`); anything else is spawned directly. No id lists every node
and its command, which is also what the two error paths print.

The node is matched by id OR alias, the same way `ccam stage` matches one:
`default` calls the step `implement` and `ship-feature` calls it `implementing`,
and one command name must not depend on which template the lane happens to be
on. That needs the template's `aliases`, which the lane payload's
`pipeline_nodes` does not carry — so the two are merged, template for the
definition, lane for the `current` marker.

Both shipped templates now fill EVERY node's `command`. The seven that were
empty are the nodes whose only work IS the declaration — a terminal `done`, a
gate another agent declares — so they get `ccam stage <id>`, which keeps all 16
(and all 8) runnable instead of leaving holes a caller has to know about.

Slash commands for the same thing, because a session should not shell out to a
nested `claude`: `/intake`, `/plan`, `/implement`, `/tests`, `/e2e`,
`/e2e-passed`, `/review`, `/qc-plan`, `/qc`, `/gate`, `/publishing`, `/pr-open`,
`/reported`, `/watching-pr`, `/merged`, `/done`, plus `/ship-full` and
`/ship-quick`. Each resolves its node through `ccam lanes node <id> --print` and
runs the result IN the current session — invoking the slash command itself when
that is what the node names, otherwise running the shell argv. Resolving at call
time rather than baking the string into the file is what stops the 18 command
files from drifting when a template changes.

Two limits are deliberate:
  - The command is split on whitespace and spawned WITHOUT a shell. A template
    is data — anyone can drop a file into DASHBOARD_PIPELINES_DIR — so its
    `command` must never be interpreted as shell. A node needing a pipe names a
    script that contains one.
  - Running a node does not declare its stage. A command executing is not proof
    the node passed, same boundary as the console never writing a lane's stage.

Verified: npm run test:server (1403, +3 covering alias matching, full command
coverage of both templates, and every command file being listed in the
manifest), npm run test:client (459), prettier and check-headers clean. Docs:
docs/LANES.md § Running ONE node, docs/PLUGINS.md commands table, README +
README.vi CLI blocks, CLAUDE.md marker.
2026-09-04 15:14:53 +07:00

1218 lines
51 KiB
JavaScript

/**
* @file Unit tests for pipeline templates and durable lane storage helpers,
* including lifecycle transitions, recovery, liveness, and worktree metadata.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const pathMod = require("node:path");
// Every test in this file must hit THIS database. Migration tests below
// re-point DASHBOARD_DB_PATH at a throwaway file; their cleanup must restore
// this value rather than delete it - an unset DASHBOARD_DB_PATH resolves to the
// operator's REAL dashboard DB, so a later test would read and write live data.
const SUITE_DB_PATH = pathMod.join(
os.tmpdir(),
`dashboard-lanes-lib-${Date.now()}-${process.pid}.db`
);
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
DEFAULT_PIPELINE_ID,
getPipeline,
listPipelines,
phaseIdx,
nodeStates,
progressPct,
} = require("../lib/pipelines");
describe("pipelines", () => {
it("exposes a default template and falls back to it for unknown ids", () => {
const def = getPipeline(DEFAULT_PIPELINE_ID);
assert.ok(def.nodes.length > 3);
assert.equal(getPipeline("does-not-exist").id, DEFAULT_PIPELINE_ID);
assert.ok(listPipelines().some((p) => p.id === DEFAULT_PIPELINE_ID));
});
it("resolves a stage through node id and through aliases", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
assert.equal(
phaseIdx(p, "plan"),
p.nodes.findIndex((n) => n.id === "plan")
);
assert.equal(phaseIdx(p, "planning"), phaseIdx(p, "plan"));
assert.equal(phaseIdx(p, "totally-unknown-stage"), -1);
});
it("marks the current stage current, recorded-with-evidence done, recorded-without amber", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = {
stage: "review",
stages: {
plan: { enteredAt: "2026-07-27T00:00:00Z", evidence: "docs/plan.md" },
implement: { enteredAt: "2026-07-27T01:00:00Z", evidence: null },
review: { enteredAt: "2026-07-27T02:00:00Z", evidence: null },
},
};
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "done");
assert.equal(byId.implement, "passed-no-evidence");
assert.equal(byId.review, "current");
assert.equal(byId.done, "pending");
});
it("marks a failed stage failed even when it is the current stage", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = { stage: "gate", stages: { gate: { enteredAt: "x", result: "fail" } } };
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.gate, "failed");
});
it("treats skipped earlier nodes as passed-without-evidence, not done", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = { stage: "review", stages: { review: { enteredAt: "x" } } };
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "passed-no-evidence");
});
it("keeps the evidence of a stage declared under an alias", () => {
// `lane.stages` is keyed by the raw declared string, so `ccam stage
// planning --evidence x` files the record under "planning" while the node
// is "plan". Looking it up by node id alone loses the evidence and paints
// a `done` node amber — silently, and only for agents that use an alias.
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = {
stage: "review",
stages: { planning: { enteredAt: "2026-07-27T00:00:00Z", evidence: "docs/plan.md" } },
};
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "done");
});
it("prefers a node-id record over an alias record for the same node", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = {
stage: "review",
stages: {
planning: { enteredAt: "2026-07-27T00:00:00Z", evidence: null },
plan: { enteredAt: "2026-07-27T00:30:00Z", evidence: "docs/plan.md" },
},
};
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "done");
});
it("ignores a recorded stage matching no node instead of shifting the others", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = {
stage: "review",
stages: { "totally-unknown": { enteredAt: "x", evidence: "y" } },
};
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "passed-no-evidence");
assert.equal(byId.review, "current");
});
it("computes progress from node position, 0 for an unknown stage", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
assert.equal(progressPct(p, { stage: p.nodes[0].id, stages: {} }), 0);
assert.equal(progressPct(p, { stage: p.nodes[p.nodes.length - 1].id, stages: {} }), 100);
assert.equal(progressPct(p, { stage: "nope", stages: {} }), 0);
});
});
const lanes = require("../lib/lanes");
const features = require("../lib/lane-features");
describe("per-node driver command", () => {
it("gives every built-in template's nodes a command field, and carries it into the render states", () => {
for (const pipeline of listPipelines()) {
for (const node of pipeline.nodes) {
assert.equal(
typeof node.command,
"string",
`${pipeline.id}/${node.id} must declare a command string`
);
}
const states = nodeStates(pipeline, { stage: pipeline.nodes[0].id, stages: {} });
assert.deepEqual(
states.map((n) => n.command),
pipeline.nodes.map((n) => n.command),
`${pipeline.id}: nodeStates must pass each node's command through`
);
}
});
it("names a real driver for the stages an agent actually drives", () => {
const ship = getPipeline("ship-feature");
const byId = Object.fromEntries(ship.nodes.map((n) => [n.id, n.command]));
assert.equal(byId.intake, "/superpowers:brainstorming");
assert.equal(byId.implementing, "/superpowers:test-driven-development");
assert.equal(byId.gates, "ccam lanes hook ci-gate");
// A terminal or gate node has no work of its own to run: reaching it IS
// the declaration, so its command is the declaration - which keeps
// `ccam lanes node <id>` runnable for every node in the template.
assert.equal(byId.done, "ccam stage done");
assert.equal(byId["e2e-feature-passed"], "ccam stage e2e-feature-passed");
});
it("defaults a template node that declares no command to the empty string", () => {
// A custom template written before this field existed must keep loading -
// an undefined command would render as "undefined" in the UI.
const fs = require("node:fs");
const dir = fs.mkdtempSync(pathMod.join(os.tmpdir(), "pipelines-nocmd-"));
fs.writeFileSync(
pathMod.join(dir, "legacy.json"),
JSON.stringify({
id: "legacy-nocmd",
name: "Legacy",
nodes: [
{ id: "start", label: "Start" },
{ id: "done", command: 42 },
],
})
);
const prev = process.env.DASHBOARD_PIPELINES_DIR;
process.env.DASHBOARD_PIPELINES_DIR = dir;
const { reload } = require("../lib/pipelines");
reload();
try {
const p = getPipeline("legacy-nocmd");
assert.deepEqual(
p.nodes.map((n) => n.command),
["", ""],
"a missing or non-string command must normalize to empty, never leak through"
);
} finally {
if (prev === undefined) delete process.env.DASHBOARD_PIPELINES_DIR;
else process.env.DASHBOARD_PIPELINES_DIR = prev;
reload();
}
});
});
describe("lanes lib", () => {
it("creates, lists, updates and deletes a lane", () => {
const l = lanes.createLane({ title: "Feature A", cwd: "/tmp/wt/a", branch: "feat/a" });
assert.equal(l.title, "Feature A");
assert.equal(l.stage, "idle");
assert.equal(lanes.getLane(l.id).cwd, "/tmp/wt/a");
assert.ok(lanes.listLanes().length >= 1);
assert.equal(lanes.updateLane(l.id, { ci_status: "green" }).ci_status, "green");
assert.equal(lanes.deleteLane(l.id), true);
assert.equal(lanes.getLane(l.id), null);
});
it("recovers only provisioning lanes interrupted by a restart", () => {
const provisioning = lanes.createLane({
cwd: "/tmp/wt/provisioning-recovery",
branch: "feat/provisioning-recovery",
kind: "managed",
});
lanes.updateLane(provisioning.id, { status: "provisioning" });
const idle = lanes.createLane({ cwd: "/tmp/wt/idle-recovery", branch: "feat/idle-recovery" });
const failed = lanes.createLane({
cwd: "/tmp/wt/failed-recovery",
branch: "feat/failed-recovery",
});
lanes.updateLane(failed.id, { status: "failed", notes: "existing failure" });
assert.equal(lanes.recoverInterruptedProvisioning(), 1);
const recovered = lanes.getLane(provisioning.id);
assert.equal(recovered.status, "failed");
assert.equal(recovered.notes, "Provisioning was interrupted by a server restart.");
assert.equal(recovered.kind, "managed");
assert.equal(recovered.cwd, "/tmp/wt/provisioning-recovery");
assert.equal(recovered.branch, "feat/provisioning-recovery");
assert.equal(lanes.getLane(idle.id).status, "idle");
assert.equal(lanes.getLane(failed.id).status, "failed");
assert.equal(lanes.getLane(failed.id).notes, "existing failure");
lanes.deleteLane(provisioning.id);
lanes.deleteLane(idle.id);
lanes.deleteLane(failed.id);
});
it("bumps stage_since only when the stage actually changes", async () => {
const l = lanes.createLane({ cwd: "/tmp/wt/b" });
const a = lanes.setStage(l.id, { stage: "plan" });
await new Promise((r) => setTimeout(r, 1100));
const b = lanes.setStage(l.id, { stage: "plan", note: "still planning" });
assert.equal(a.stage_since, b.stage_since);
const c = lanes.setStage(l.id, { stage: "implement" });
assert.notEqual(c.stage_since, b.stage_since);
lanes.deleteLane(l.id);
});
it("records evidence per stage so the map can tell done from amber", () => {
const l = lanes.createLane({ cwd: "/tmp/wt/c" });
lanes.setStage(l.id, { stage: "plan", evidence: "docs/plan.md" });
const after = lanes.setStage(l.id, { stage: "implement" });
assert.equal(after.stages.plan.evidence, "docs/plan.md");
assert.ok(after.stages.plan.enteredAt);
lanes.deleteLane(l.id);
});
it("declaring an EARLIER stage drops the records of every node ahead of it", () => {
const l = lanes.createLane({ cwd: "/tmp/wt/rollback" });
lanes.setStage(l.id, { stage: "plan", evidence: "docs/plan.md" });
lanes.setStage(l.id, { stage: "tests", evidence: "npm test" });
lanes.setStage(l.id, { stage: "done", evidence: "shipped" });
// A new request arrives and the session declares back at `plan`. The old
// pass's tests/done records must not keep rendering `done` ahead of it.
const after = lanes.setStage(l.id, { stage: "plan" });
assert.equal(after.stages.tests, undefined);
assert.equal(after.stages.done, undefined);
assert.equal(after.stages.plan.evidence, "docs/plan.md"); // its own record survives
const nodes = lanes.lanePayload(after).pipeline_nodes;
assert.equal(nodes.find((n) => n.id === "plan").state, "current");
assert.equal(nodes.find((n) => n.id === "tests").state, "pending");
assert.equal(nodes.find((n) => n.id === "done").state, "pending");
lanes.deleteLane(l.id);
});
it("moving FORWARD keeps every earlier record, and an unplaceable stage prunes nothing", () => {
const l = lanes.createLane({ cwd: "/tmp/wt/rollback-fwd" });
lanes.setStage(l.id, { stage: "plan", evidence: "docs/plan.md" });
const fwd = lanes.setStage(l.id, { stage: "tests", evidence: "npm test" });
assert.equal(fwd.stages.plan.evidence, "docs/plan.md");
// A stage matching no node has no position, so direction is unknown.
const typo = lanes.setStage(l.id, { stage: "implemnt" });
assert.equal(typo.stages.plan.evidence, "docs/plan.md");
assert.equal(typo.stages.tests.evidence, "npm test");
lanes.deleteLane(l.id);
});
it("resolves a lane from a session cwd by longest path-boundary prefix", () => {
const outer = lanes.createLane({ cwd: "/tmp/wt" });
const inner = lanes.createLane({ cwd: "/tmp/wt/inner" });
assert.equal(lanes.resolveLaneByCwd("/tmp/wt/inner/src").id, inner.id);
assert.equal(lanes.resolveLaneByCwd("/tmp/wt/other").id, outer.id);
assert.equal(lanes.resolveLaneByCwd("/tmp/wt-sibling"), null);
assert.equal(lanes.resolveLaneByCwd(null), null);
lanes.deleteLane(inner.id);
lanes.deleteLane(outer.id);
});
it("classifies liveness: silent watcher is dead, silent idle lane is not", () => {
const d = 300;
assert.equal(
lanes.classifyLiveness({ status: "running", stage: "implement", ageSec: 10 }, d),
"active"
);
assert.equal(
lanes.classifyLiveness({ status: "running", stage: "implement", ageSec: 999 }, d),
"dead"
);
assert.equal(
lanes.classifyLiveness({ status: "idle", stage: "watching-pr", ageSec: 999 }, d),
"dead"
);
assert.equal(
lanes.classifyLiveness({ status: "idle", stage: "done", ageSec: 99999 }, d),
"idle"
);
assert.equal(
lanes.classifyLiveness({ status: "idle", stage: "done", ageSec: null }, d),
"idle"
);
});
it("payload carries node states and progress", () => {
const l = lanes.createLane({ cwd: "/tmp/wt/d" });
lanes.setStage(l.id, { stage: "review" });
const p = lanes.lanePayload(lanes.getLane(l.id), 5);
assert.equal(p.pipeline_nodes.find((n) => n.id === "review").state, "current");
assert.ok(p.progress > 0 && p.progress < 100);
assert.equal(p.liveness, "idle");
// The template's driving slash command rides along so the run console can
// pre-fill its prompt with it.
assert.equal(p.pipeline_skill, "/ccam:ship-feature");
lanes.deleteLane(l.id);
});
it("createLane defaults tool to claude and accepts codex", () => {
const a = lanes.createLane({ cwd: "/tmp/tool-default" });
assert.equal(a.tool, "claude");
const b = lanes.createLane({ cwd: "/tmp/tool-codex", tool: "codex" });
assert.equal(b.tool, "codex");
});
it("createLane rejects an unknown tool", () => {
assert.throws(
() => lanes.createLane({ cwd: "/tmp/tool-bad", tool: "cursor" }),
(err) => err.code === "EBADTOOL"
);
});
it("updateLane never writes tool (immutable per lane)", () => {
const lane = lanes.createLane({ cwd: "/tmp/tool-immutable", tool: "codex" });
const after = lanes.updateLane(lane.id, { tool: "claude", title: "x" });
assert.equal(after.tool, "codex");
assert.equal(after.title, "x");
});
});
const { withLaneLock } = require("../lib/lane-lock");
describe("lane kind, worktree fields and purge", () => {
it("defaults to adopted and stores worktree fields when given", () => {
const a = lanes.createLane({ cwd: "/tmp/wt-kind-a" });
assert.equal(a.kind, "adopted");
const m = lanes.createLane({
cwd: "/tmp/wt-kind-b",
kind: "managed",
source_repo: "/tmp/src",
base_branch: "main",
slug: "b",
});
assert.equal(m.kind, "managed");
assert.equal(m.source_repo, "/tmp/src");
assert.equal(m.base_branch, "main");
assert.equal(m.slug, "b");
lanes.deleteLane(a.id);
lanes.deleteLane(m.id);
});
it("rejects an unknown kind", () => {
assert.throws(
() => lanes.createLane({ cwd: "/tmp/wt-kind-c", kind: "gremlin" }),
(e) => e.code === "EBADKIND"
);
});
it("purges a lane's sessions, their events and orphaned token rows, sparing the live one", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-purge" });
const { db } = require("../db");
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'completed')").run(
"purge-1",
"/tmp/wt-purge/sub"
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'active')").run(
"purge-live",
"/tmp/wt-purge"
);
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, 'PostToolUse')").run(
"purge-1"
);
db.prepare("INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, 'm', 5)").run(
"purge-1"
);
lanes.updateLane(l.id, { session_id: "purge-live" });
const counts = lanes.purgeLaneSessions(l.id);
assert.equal(counts.sessions, 1);
assert.equal(counts.events, 1);
assert.equal(counts.tokenRows, 1);
assert.equal(db.prepare("SELECT COUNT(*) c FROM sessions WHERE id='purge-live'").get().c, 1);
assert.equal(db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='purge-1'").get().c, 0);
assert.equal(
db.prepare("SELECT COUNT(*) c FROM token_usage WHERE session_id='purge-1'").get().c,
0
);
lanes.deleteLane(l.id);
});
it("never counts or deletes a sibling directory whose name differs only at an underscore", () => {
// Every managed lane directory is named `<repo>__<slug>` — two literal
// underscores, each of which LIKE treats as "any single character". Without
// ESCAPE, a lane at /root/myrepo__feat-foo purged /root/myrepoXXfeat-foo's
// sessions too, and the preflight count reported the victims, so the
// confirmation was consistently wrong rather than detectably wrong.
const { db } = require("../db");
const laneCwd = "/root/myrepo__feat-foo";
const siblingCwd = "/root/myrepoXXfeat-foo";
const l = lanes.createLane({ cwd: laneCwd, kind: "managed" });
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'completed')").run(
"like-mine",
`${laneCwd}/sub`
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'completed')").run(
"like-victim",
`${siblingCwd}/sub`
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'active')").run(
"like-victim-active",
`${siblingCwd}/other`
);
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, 'Stop')").run("like-victim");
// The counter sees only this lane's own session.
const candidates = lanes.purgeCandidateSessions(lanes.getLane(l.id));
assert.deepEqual(
candidates.map((s) => s.id),
["like-mine"]
);
// ...and does not attribute the sibling's live session to this lane either.
assert.equal(lanes.hasActiveLaneSession(lanes.getLane(l.id)), false);
// The deleter agrees with the counter, exactly.
const counts = lanes.purgeLaneSessions(l.id);
assert.equal(counts.sessions, 1);
assert.equal(counts.events, 0);
assert.equal(db.prepare("SELECT COUNT(*) c FROM sessions WHERE id='like-victim'").get().c, 1);
assert.equal(
db.prepare("SELECT COUNT(*) c FROM sessions WHERE id='like-victim-active'").get().c,
1
);
assert.equal(
db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='like-victim'").get().c,
1
);
lanes.deleteLane(l.id);
});
it("a PATCH-style updateLane cannot change kind, source_repo, slug or base_branch", () => {
// kind is check 1 of the destroy guard: a client that could flip it to
// "managed" could point the guard at a directory the user owns.
const l = lanes.createLane({
cwd: "/tmp/wt-patch-guard",
kind: "adopted",
source_repo: "/tmp/original-src",
base_branch: "main",
slug: "original",
});
const after = lanes.updateLane(l.id, {
kind: "managed",
source_repo: "/tmp/attacker-src",
base_branch: "attacker",
slug: "attacker",
title: "this one IS patchable",
});
assert.equal(after.kind, "adopted");
assert.equal(after.source_repo, "/tmp/original-src");
assert.equal(after.base_branch, "main");
assert.equal(after.slug, "original");
assert.equal(after.title, "this one IS patchable");
// Provisioning has its own internal writer for those facts.
const provisioned = lanes.setProvisioningFacts(l.id, { base_branch: "resolved-base" });
assert.equal(provisioned.base_branch, "resolved-base");
assert.equal(provisioned.kind, "adopted");
lanes.deleteLane(l.id);
});
it("serialises work per lane and releases the lock when the body throws", async () => {
const order = [];
const slow = withLaneLock(7, async () => {
order.push("a-start");
await new Promise((r) => setTimeout(r, 50));
order.push("a-end");
});
const fast = withLaneLock(7, async () => {
order.push("b");
});
await Promise.all([slow, fast]);
assert.deepEqual(order, ["a-start", "a-end", "b"]);
await assert.rejects(() =>
withLaneLock(7, async () => {
throw new Error("boom");
})
);
await withLaneLock(7, async () => order.push("c"));
assert.equal(order[order.length - 1], "c");
});
it("updateLane rejects an unknown kind with EBADKIND and leaves stored value unchanged", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-kind-update", kind: "adopted" });
assert.equal(l.kind, "adopted");
assert.throws(
() => lanes.updateLane(l.id, { kind: "MANAGED" }),
(e) => e.code === "EBADKIND"
);
const after = lanes.getLane(l.id);
assert.equal(after.kind, "adopted");
lanes.deleteLane(l.id);
});
it("different lane ids have separate locks and run in parallel", async () => {
const order = [];
const lane1Slow = withLaneLock(10, async () => {
order.push("lane1-start");
await new Promise((r) => setTimeout(r, 50));
order.push("lane1-end");
});
const lane2 = withLaneLock(11, async () => {
order.push("lane2");
});
await Promise.all([lane1Slow, lane2]);
assert.ok(order.includes("lane2"), "lane 2 should run without waiting for lane 1");
assert.notDeepEqual(order, ["lane1-start", "lane1-end", "lane2"]);
});
it("migration: old schema database gains all four columns idempotently", () => {
const tmpPath = pathMod.join(
os.tmpdir(),
`test-lanes-migration-${Date.now()}-${process.pid}.db`
);
try {
// Create an old-schema lanes table with only id, cwd, title columns
const OldDatabase = require("better-sqlite3");
const oldDb = new OldDatabase(tmpPath);
oldDb.exec(`
CREATE TABLE lanes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
cwd TEXT NOT NULL UNIQUE,
branch TEXT,
pipeline TEXT NOT NULL DEFAULT 'default',
session_id TEXT,
run_id TEXT,
stage TEXT NOT NULL DEFAULT 'idle',
stage_since TEXT,
status TEXT NOT NULL DEFAULT 'idle',
gate_decision TEXT,
ci_status TEXT,
needs_action TEXT,
links TEXT NOT NULL DEFAULT '{}',
stages TEXT NOT NULL DEFAULT '{}',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)
`);
oldDb
.prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, stage_since) VALUES (?, ?, ?, ?, ?)"
)
.run("old-lane", "/tmp/old-lane-path", "main", "default", "2026-07-27T00:00:00Z");
oldDb.close();
// Load db.js against it, which should add the four columns
process.env.DASHBOARD_DB_PATH = tmpPath;
delete require.cache[require.resolve("../db")];
const { db: newDb } = require("../db");
// Verify all four columns exist
const checkCols = [
"SELECT kind FROM lanes LIMIT 1",
"SELECT source_repo FROM lanes LIMIT 1",
"SELECT base_branch FROM lanes LIMIT 1",
"SELECT slug FROM lanes LIMIT 1",
];
checkCols.forEach((sql) => {
assert.doesNotThrow(() => newDb.prepare(sql).get());
});
// Verify existing row has kind='adopted' and others NULL
const row = newDb.prepare("SELECT * FROM lanes WHERE cwd = ?").get("/tmp/old-lane-path");
assert.equal(row.kind, "adopted");
assert.equal(row.source_repo, null);
assert.equal(row.base_branch, null);
assert.equal(row.slug, null);
} finally {
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
delete require.cache[require.resolve("../db")];
try {
require("fs").rmSync(tmpPath, { force: true });
} catch {
/* cleanup best effort */
}
}
});
});
describe("stage detection", () => {
it("writes a forward detection", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-forward" });
const result = lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" });
assert.deepEqual(result, { written: true });
const after = lanes.getLane(l.id);
assert.equal(after.detected_stage, "implement");
assert.equal(after.detected_signal, "`Edit`");
assert.ok(after.detected_at);
lanes.deleteLane(l.id);
});
it("drops a detection that does not advance past the current detected stage", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-backward" });
lanes.recordDetection(l.id, { nodeId: "tests" });
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: false, reason: "behind-detected" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
it("drops a detection at or behind the declared stage", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-declared" });
lanes.setStage(l.id, { stage: "review" });
const atDeclared = lanes.recordDetection(l.id, { nodeId: "review" });
assert.deepEqual(atDeclared, { written: false, reason: "behind-declared" });
const behindDeclared = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(behindDeclared, { written: false, reason: "behind-declared" });
assert.equal(lanes.getLane(l.id).detected_stage, null);
lanes.deleteLane(l.id);
});
it("a declaration on the pipeline's LAST node stops vetoing detection", () => {
// The pipeline is over, so a later detection is new work — not a stale
// rerun of work the declaration already covers. Without this a lane left
// at `done` can never be detected again for the rest of its life.
const l = lanes.createLane({ cwd: "/tmp/wt-detect-terminal" });
lanes.setStage(l.id, { stage: "done" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), {
written: true,
});
const after = lanes.getLane(l.id);
assert.equal(after.detected_stage, "implement");
assert.equal(after.stage, "done"); // detection never moves the declaration
lanes.deleteLane(l.id);
});
it("a declaration on the last node's ALIAS also stops vetoing", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-terminal-alias" });
lanes.setStage(l.id, { stage: "completed" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement" }), { written: true });
lanes.deleteLane(l.id);
});
it("reports unknown-node for a node id the pipeline does not have", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-unknown" });
const result = lanes.recordDetection(l.id, { nodeId: "not-a-real-node" });
assert.deepEqual(result, { written: false, reason: "unknown-node" });
assert.equal(lanes.getLane(l.id).detected_stage, null);
lanes.deleteLane(l.id);
});
it("PREMISE GUARD: a lane with only detections and no declarations has no done node", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-no-done" });
lanes.recordDetection(l.id, { nodeId: "review", signal: "`git diff`" });
const payload = lanes.lanePayload(lanes.getLane(l.id));
assert.ok(
!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" 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, "current");
assert.equal(review.detected, true);
const implement = payload.pipeline_nodes.find((n) => n.id === "implement");
assert.equal(implement.detected, true);
const done = payload.pipeline_nodes.find((n) => n.id === "done");
assert.equal(done.detected, false);
lanes.deleteLane(l.id);
});
it("PREMISE GUARD: a node declared WITH evidence stays done and unflagged under a detection ahead of it", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-done-guard" });
// implement declared with evidence, then the lane declares tests; a review
// detection lands ahead of both. `implement` is the adversarial node: it is
// `done` (declared + evidence) and it sits before the detected index.
lanes.setStage(l.id, { stage: "implement", evidence: "server/lib/x.js" });
lanes.setStage(l.id, { stage: "tests" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "review", signal: "`git diff`" }), {
written: true,
});
const nodes = lanes.lanePayload(lanes.getLane(l.id)).pipeline_nodes;
const implement = nodes.find((n) => n.id === "implement");
assert.equal(implement.state, "done");
assert.equal(implement.detected, false);
lanes.deleteLane(l.id);
});
it("a stage declared by ALIAS keeps its current ring instead of reading as an inference", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-alias-current" });
// "coding" is an alias of the `implement` node, so `stages` gets no entry
// under "implement" — the declared current node must still not be flagged.
lanes.setStage(l.id, { stage: "coding", evidence: "server/lib/x.js" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "tests", signal: "`npm test`" }), {
written: true,
});
const nodes = lanes.lanePayload(lanes.getLane(l.id)).pipeline_nodes;
const implement = nodes.find((n) => n.id === "implement");
assert.equal(implement.state, "current");
assert.equal(implement.detected, false);
lanes.deleteLane(l.id);
});
it("never writes the declared stage: stage and stage_since are byte-identical after a detection", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-no-stage-write" });
lanes.setStage(l.id, { stage: "plan" });
const before = lanes.getLane(l.id);
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "tests", signal: "`npm test`" }), {
written: true,
});
const after = lanes.getLane(l.id);
assert.equal(after.stage, before.stage);
assert.equal(after.stage_since, before.stage_since);
lanes.deleteLane(l.id);
});
it("clearLane nulls the detection columns and lets a fresh detection land immediately", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-clear" });
lanes.recordDetection(l.id, { nodeId: "ship", signal: "`git push`" });
assert.equal(lanes.getLane(l.id).detected_stage, "ship");
const cleared = lanes.clearLane(l.id);
assert.equal(cleared.detected_stage, null);
assert.equal(cleared.detected_signal, null);
assert.equal(cleared.detected_at, null);
assert.ok(
!lanes.lanePayload(cleared).pipeline_nodes.some((n) => n.detected),
"a cleared lane must claim no inferred progress"
);
// Without the reset, `ship` would forever outrank every later detection.
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);
});
describe("clearLane archives the active feature first", () => {
it("archives the active feature with its final stage before resetting the live row", () => {
const l = lanes.createLane({ title: "t", cwd: "/tmp/wt-archive-active" });
features.activateFeature(l.id, "one");
lanes.setStage(l.id, { stage: "review", evidence: "e" });
lanes.clearLane(l.id);
const archived = features.getFeature(l.id, "one");
assert.notEqual(archived.archived_at, null);
assert.equal(archived.stage, "review");
const cleared = lanes.getLane(l.id);
assert.equal(cleared.stage, "idle");
assert.equal(cleared.active_feature_id, null);
lanes.deleteLane(l.id);
});
it("is unchanged for a lane that never activated a feature (no archive row created)", () => {
const l = lanes.createLane({ title: "t2", cwd: "/tmp/wt-archive-none" });
lanes.setStage(l.id, { stage: "review" });
lanes.clearLane(l.id);
const cleared = lanes.getLane(l.id);
assert.equal(cleared.stage, "idle");
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(),
`test-lanes-detect-migration-${Date.now()}-${process.pid}.db`
);
try {
const OldDatabase = require("better-sqlite3");
const oldDb = new OldDatabase(tmpPath);
oldDb.exec(`
CREATE TABLE lanes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
cwd TEXT NOT NULL UNIQUE,
branch TEXT,
pipeline TEXT NOT NULL DEFAULT 'default',
session_id TEXT,
run_id TEXT,
stage TEXT NOT NULL DEFAULT 'idle',
stage_since TEXT,
status TEXT NOT NULL DEFAULT 'idle',
gate_decision TEXT,
ci_status TEXT,
needs_action TEXT,
links TEXT NOT NULL DEFAULT '{}',
stages TEXT NOT NULL DEFAULT '{}',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
kind TEXT NOT NULL DEFAULT 'adopted',
source_repo TEXT,
base_branch TEXT,
slug TEXT
)
`);
oldDb
.prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, stage_since) VALUES (?, ?, ?, ?, ?)"
)
.run(
"pre-detect-lane",
"/tmp/pre-detect-lane-path",
"main",
"default",
"2026-07-27T00:00:00Z"
);
oldDb.close();
process.env.DASHBOARD_DB_PATH = tmpPath;
delete require.cache[require.resolve("../db")];
const { db: newDb } = require("../db");
["detected_stage", "detected_signal", "detected_at"].forEach((col) => {
assert.doesNotThrow(() => newDb.prepare(`SELECT ${col} FROM lanes LIMIT 1`).get());
});
const row = newDb
.prepare("SELECT * FROM lanes WHERE cwd = ?")
.get("/tmp/pre-detect-lane-path");
assert.equal(row.detected_stage, null);
assert.equal(row.detected_signal, null);
assert.equal(row.detected_at, null);
} finally {
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
delete require.cache[require.resolve("../db")];
try {
require("fs").rmSync(tmpPath, { force: true });
} catch {
/* cleanup best effort */
}
}
});
it("migration: a crash after only the first detection column self-heals on the next load", () => {
const tmpPath = pathMod.join(
os.tmpdir(),
`test-lanes-detect-crash-${Date.now()}-${process.pid}.db`
);
try {
const OldDatabase = require("better-sqlite3");
const oldDb = new OldDatabase(tmpPath);
oldDb.exec(`
CREATE TABLE lanes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
cwd TEXT NOT NULL UNIQUE,
branch TEXT,
pipeline TEXT NOT NULL DEFAULT 'default',
session_id TEXT,
run_id TEXT,
stage TEXT NOT NULL DEFAULT 'idle',
stage_since TEXT,
status TEXT NOT NULL DEFAULT 'idle',
gate_decision TEXT,
ci_status TEXT,
needs_action TEXT,
links TEXT NOT NULL DEFAULT '{}',
stages TEXT NOT NULL DEFAULT '{}',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
kind TEXT NOT NULL DEFAULT 'adopted',
source_repo TEXT,
base_branch TEXT,
slug TEXT
)
`);
// Simulate a process death that landed only the first ALTER.
oldDb.exec("ALTER TABLE lanes ADD COLUMN detected_stage TEXT");
oldDb.close();
process.env.DASHBOARD_DB_PATH = tmpPath;
delete require.cache[require.resolve("../db")];
const { db: newDb } = require("../db");
["detected_stage", "detected_signal", "detected_at"].forEach((col) => {
assert.doesNotThrow(() => newDb.prepare(`SELECT ${col} FROM lanes LIMIT 1`).get());
});
} finally {
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
delete require.cache[require.resolve("../db")];
try {
require("fs").rmSync(tmpPath, { force: true });
} catch {
/* cleanup best effort */
}
}
});
it("detection expiry: backward detection inside window is rejected", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-window" });
// Set an initial forward detection
lanes.recordDetection(l.id, { nodeId: "tests" });
// Try backward detection - should still be rejected within window
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: false, reason: "behind-detected" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
it("detection expiry: backward detection beyond TTL is accepted and written", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-stale" });
// Set an initial forward detection with a stale timestamp
lanes.recordDetection(l.id, { nodeId: "tests" });
// Manually set detected_at to an old time (2 hours ago, assuming default TTL of 30 min)
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(oldTime, l.id);
// Try backward detection - should be accepted because detection is stale
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: true });
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
it("detection expiry: stale detection behind declared stage still rejects", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-declared" });
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_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" });
assert.deepEqual(result, { written: false, reason: "behind-declared" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
it("detection expiry: NULL detected_at is treated as stale", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-null" });
// Set a detection and then nullify detected_at
lanes.recordDetection(l.id, { nodeId: "tests" });
db.prepare("UPDATE lanes SET detected_at = NULL WHERE id = ?").run(l.id);
// Try backward detection - should be accepted because detected_at is NULL (unknown age)
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: true });
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
it("detection expiry: DETECTION_TTL_MS env var controls the window", () => {
const { db } = require("../db");
const oldTTL = process.env.DETECTION_TTL_MS;
try {
// Set a very short TTL (1 second)
process.env.DETECTION_TTL_MS = "1000";
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-ttl" });
// Set initial detection
lanes.recordDetection(l.id, { nodeId: "tests" });
// Set detected_at to 2 seconds ago (beyond the 1-second TTL)
const staleTime = new Date(Date.now() - 2000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(staleTime, l.id);
// Try backward detection - should be accepted because we're beyond TTL
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: true });
lanes.deleteLane(l.id);
} finally {
if (oldTTL === undefined) {
delete process.env.DETECTION_TTL_MS;
} else {
process.env.DETECTION_TTL_MS = oldTTL;
}
}
});
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);
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"
);
// 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);
});
});
describe("the forward-only hold is short enough for one work session", () => {
it("lets a newer signal move the lane back within a few minutes", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-hold-window" });
// A real session cycles implement -> tests -> ship -> implement -> tests.
// With a 30-minute hold, one push pinned the lane at `ship` for half an
// hour while the agent was demonstrably back to running tests.
lanes.recordDetection(l.id, { nodeId: "ship" });
const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(sixMinutesAgo, l.id);
const result = lanes.recordDetection(l.id, { nodeId: "tests", signal: "node --test" });
assert.deepEqual(result, { written: true });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
it("still smooths noise inside one burst of activity", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-hold-burst" });
lanes.recordDetection(l.id, { nodeId: "tests" });
// Same second: a Read after an Edit must not drag the lane backwards.
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: false, reason: "behind-detected" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
it("a terminal declaration still honors forward-only among detections", () => {
// The exception for terminal nodes is: a NEW detection can land after a
// terminal declaration. But forward-only WITHIN that detection batch still
// holds — you cannot move backwards among detections.
const l = lanes.createLane({ cwd: "/tmp/wt-terminal-forward" });
lanes.setStage(l.id, { stage: "done" });
lanes.recordDetection(l.id, { nodeId: "ship" }); // ahead of everything
const result = lanes.recordDetection(l.id, { nodeId: "review" }); // behind the just-recorded detection
assert.deepEqual(result, { written: false, reason: "behind-detected" });
assert.equal(lanes.getLane(l.id).detected_stage, "ship");
lanes.deleteLane(l.id);
});
it("the terminal-node exception works on the 16-node ship-feature template too", () => {
const l = lanes.createLane({
cwd: "/tmp/wt-terminal-16node",
pipeline: "ship-feature",
});
lanes.setStage(l.id, { stage: "done" });
// The ship-feature template's last node is also "done", so detection should work
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), {
written: true,
});
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
assert.equal(lanes.getLane(l.id).stage, "done"); // stage unchanged
lanes.deleteLane(l.id);
});
it("detection never writes stage, even after terminal declaration", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-terminal-no-stage-write" });
lanes.setStage(l.id, { stage: "done" });
const before = lanes.getLane(l.id);
lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" });
const after = lanes.getLane(l.id);
// stage and stage_since must not change
assert.equal(after.stage, before.stage);
assert.equal(after.stage_since, before.stage_since);
lanes.deleteLane(l.id);
});
it("a node with detection never renders done in the UI, even after terminal declaration", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-terminal-no-done-render" });
lanes.setStage(l.id, { stage: "done" });
lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" });
const lane = lanes.getLane(l.id);
const payload = lanes.lanePayload(lane);
// The detected implement node must be shown as pending + detected, not done
const implementNode = payload.pipeline_nodes.find((n) => n.id === "implement");
assert.equal(implementNode.detected, true);
assert.notEqual(implementNode.state, "done");
lanes.deleteLane(l.id);
});
it("behind-detected is still returned when appropriate after terminal declaration", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-terminal-behind-detected" });
lanes.setStage(l.id, { stage: "done" });
lanes.recordDetection(l.id, { nodeId: "tests" });
// Next detection is behind the current detected stage
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: false, reason: "behind-detected" });
lanes.deleteLane(l.id);
});
it("cwd-resolved single lane still resolves without flags and no env marker", () => {
// This is a basic sanity check: a lane at a cwd with no sibling lane of
// another tool should resolve with just the cwd and no --tool flag.
const cwd = "/tmp/wt-single-resolve";
const l = lanes.createLane({ cwd, title: "solo", tool: "claude" });
const result = lanes.resolveLaneByCwd(cwd);
assert.ok(result, "lane should resolve by cwd");
assert.equal(result.id, l.id);
lanes.deleteLane(l.id);
});
it("nested cwd resolution picks the deepest lane", () => {
const outer = "/tmp/wt-nested-outer";
const inner = "/tmp/wt-nested-outer/sub/deep";
const outerLane = lanes.createLane({ cwd: outer, title: "outer" });
const innerLane = lanes.createLane({ cwd: inner, title: "inner" });
// Resolving from a path deep inside inner should pick inner
const result = lanes.resolveLaneByCwd(`${inner}/src/file.js`);
assert.equal(result.id, innerLane.id);
lanes.deleteLane(innerLane.id);
lanes.deleteLane(outerLane.id);
});
it("resolveLaneByCwd with explicit tool filter returns null if no lane matches", () => {
const cwd = "/tmp/wt-resolve-tool-filter";
lanes.createLane({ cwd, title: "claude-only", tool: "claude" });
// Explicitly request a codex lane at this cwd when only claude exists
const result = lanes.resolveLaneByCwd(cwd, "codex");
assert.equal(result, null);
});
});