fix(lanes): auto-detect and report stage for codex lanes (issue 11)

A lane on the 16-node `ship-feature` template showed an untouched pipeline
map and never declared a stage. Four independent causes, fixed at their
shared choke points:

1. Codex emits no hooks, so `touchLaneFromHook` — the only caller of
   `stage-detect.detect()`, and correctly pinned to `claude` — could never
   see a `codex` lane. New `server/lib/codex-ingest.js` tails Codex's own
   rollout journals (`$CODEX_HOME/sessions/**/rollout-*.jsonl`), maps its
   tool calls onto the hook-shaped `{tool_name, tool_input}` the matcher
   already understands, and feeds the existing detect path plus an
   idle/running status mirror under the same guard hooks use. Polled every
   `DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables).
2. `resolveLaneArg` matched on cwd alone, so with a claude and a codex lane
   at one directory (the composite `idx_lanes_cwd_tool`) a `ccam stage`
   call could drive the sibling lane's card. It now resolves by
   `--lane` → `CCAM_LANE_ID` → `--tool`/harness marker → owning lane, and
   errors naming both candidates rather than picking one. Every pane the
   dashboard starts carries `CCAM_LANE_ID` (`tmux new-session -e`, or an
   assignment prefix when typing into a live shell pane).
3. `recordDetection`'s declared-wins veto had no exit: a lane left at the
   pipeline's last node rejected every detection for the rest of its life.
   A declaration on the LAST node no longer vetoes — the pipeline is over
   there, so a later detection is new work. Nothing else is relaxed, and
   detection still never writes `stage` nor renders `done`.
4. The run console pre-filled the pipeline's Claude slash command into
   codex panes, which Codex reads as prose — so the skill that would have
   called `ccam stage` never ran. Claude lanes only now.

A codex lane's *declared* stage still depends on the session calling
`ccam stage`; `AGENTS.md` is the only lever CCAM has, so it now says so.

Verified: server 1345/1345, client 441/441, and end-to-end against a real
codex lane row (detected_stage/detected_signal set, declared stage
untouched, no node rendered done).
This commit is contained in:
2026-08-27 16:38:23 +07:00
parent b1734a492f
commit fd7f57929c
22 changed files with 2343 additions and 35 deletions
+524
View File
@@ -0,0 +1,524 @@
/**
* @file Unit tests for Codex stage ingestion: the payload→hook-event mapping,
* and one full pass over a fixture rollout journal (detection + status mirror +
* broadcast + the no-new-bytes no-op).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const SUITE_DB_PATH = path.join(
os.tmpdir(),
`dashboard-codex-ingest-${Date.now()}-${process.pid}.db`
);
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const codex = require("../lib/codex-ingest");
const lanes = require("../lib/lanes");
/** One rollout line in the real on-disk shape: top-level envelope + payload. */
function line(payload, type = "response_item") {
return JSON.stringify({ timestamp: new Date().toISOString(), ordinal: 1, type, payload });
}
describe("codex payload mapping", () => {
it("maps a function_call exec_command to a Bash event", () => {
const events = codex.eventsFromPayload({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: "/repo" }),
});
assert.deepEqual(events, [
{ tool_name: "Bash", tool_input: { command: "npm run test:server" } },
]);
});
it("maps a custom_tool_call exec to a Bash event carrying the raw script", () => {
const [event] = codex.eventsFromPayload({
type: "custom_tool_call",
name: "exec",
input: 'const r = await tools.exec_command({cmd:"git push origin feat/x"}); text(r.output);',
});
assert.equal(event.tool_name, "Bash");
assert.match(event.tool_input.command, /git push origin feat\/x/);
});
it("maps an apply_patch script to one event per touched file", () => {
const events = codex.eventsFromPayload({
type: "custom_tool_call",
name: "exec",
input:
'const patch = "*** Begin Patch\n*** Update File: server/lib/lanes.js\n+x\n' +
'*** Add File: docs/NEW.md\n+y\n*** End Patch";',
});
assert.deepEqual(events, [
{ tool_name: "Edit", tool_input: { file_path: "server/lib/lanes.js" } },
{ tool_name: "Write", tool_input: { file_path: "docs/NEW.md" } },
]);
});
it("maps an apply_patch script whose newlines are still escaped", () => {
// Verbatim from a real rollout: the patch heredoc lives inside a JS string
// literal, so after JSON.parse the line breaks are a literal backslash-n,
// not newlines. Matching only real newlines silently sees no files at all.
const events = codex.eventsFromPayload({
type: "custom_tool_call",
name: "exec",
input:
'const patch = "*** Begin Patch\\n*** Add File: config/demo.yml\\n+# demo\\n*** End Patch";',
});
assert.deepEqual(events, [
{ tool_name: "Write", tool_input: { file_path: "config/demo.yml" } },
]);
});
it("maps update_plan to TaskCreate and spawn_agent to Agent", () => {
assert.deepEqual(
codex.eventsFromPayload({ type: "function_call", name: "update_plan", arguments: "{}" }),
[{ tool_name: "TaskCreate", tool_input: {} }]
);
assert.deepEqual(
codex.eventsFromPayload({
type: "function_call",
name: "spawn_agent",
arguments: JSON.stringify({ task_name: "code_reviewer", message: "…" }),
}),
[{ tool_name: "Agent", tool_input: { subagent_type: "code_reviewer" } }]
);
});
it("ignores anything that is not a tool call", () => {
assert.deepEqual(codex.eventsFromPayload({ type: "reasoning" }), []);
assert.deepEqual(
codex.eventsFromPayload({ type: "custom_tool_call_output", name: "exec" }),
[]
);
assert.deepEqual(codex.eventsFromPayload({ type: "function_call", name: "web_search" }), []);
assert.deepEqual(codex.eventsFromPayload(null), []);
});
});
describe("codex ingest pass", () => {
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-"));
const DIR = path.join(HOME, "sessions", "2026", "08", "27");
let prevHome;
before(() => {
prevHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = HOME;
fs.mkdirSync(DIR, { recursive: true });
});
after(() => {
if (prevHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevHome;
codex.__resetOffsets();
try {
fs.rmSync(HOME, { recursive: true, force: true });
} catch {
/* best-effort */
}
});
/** A rollout journal for `cwd` with one `npm run test:server` shell call. */
function writeRollout(name, cwd) {
const file = path.join(DIR, name);
fs.writeFileSync(
file,
[
line({ cwd, session_id: "abc" }, "session_meta"),
line({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
}),
].join("\n") + "\n"
);
return file;
}
it("detects a stage for a codex lane and mirrors its status", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
writeRollout("rollout-2026-08-27T10-00-00-aaa.jsonl", cwd);
codex.__resetOffsets();
const seen = [];
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
const after = lanes.getLane(lane.id);
assert.equal(after.detected_stage, "tests"); // `npm run test:server` → default template's tests node
assert.match(after.detected_signal, /test:server/);
assert.equal(after.status, "running"); // a journal written just now means work is happening
assert.deepEqual(seen, [lane.id]);
lanes.deleteLane(lane.id);
});
it("a second pass with no appended bytes changes nothing", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
writeRollout("rollout-2026-08-27T10-00-01-bbb.jsonl", cwd);
codex.__resetOffsets();
codex.ingestCodexOnce({});
const first = lanes.getLane(lane.id).detected_at;
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).detected_at, first);
lanes.deleteLane(lane.id);
});
it("never touches a claude lane, even at the journal's own cwd", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "claude-lane-"));
const lane = lanes.createLane({ title: "cc", cwd, tool: "claude" });
writeRollout("rollout-2026-08-27T10-00-02-ccc.jsonl", cwd);
codex.__resetOffsets();
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).detected_stage, null);
lanes.deleteLane(lane.id);
});
it("ignores a journal whose cwd belongs to no lane", () => {
const orphan = fs.mkdtempSync(path.join(os.tmpdir(), "codex-orphan-"));
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
writeRollout("rollout-2026-08-27T10-00-03-ddd.jsonl", orphan);
codex.__resetOffsets();
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).detected_stage, null);
lanes.deleteLane(lane.id);
});
it("keeps byte offsets exact when a tail read starts mid multi-byte character", () => {
// The first sight of a >64 KiB journal reads only its tail, and that offset
// can land inside a 3-byte UTF-8 sequence. Splitting the decoded STRING and
// measuring it back with Buffer.byteLength then counts the decoder's U+FFFD
// replacement instead of the bytes actually consumed, so the next pass
// starts 1-2 bytes off and every later record is unparseable.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = path.join(DIR, "rollout-2026-08-27T10-00-06-utf8.jsonl");
const record = (cmd) =>
line({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd, workdir: cwd }),
});
// Grow an all-multi-byte filler line until the tail boundary (size - 64 KiB)
// falls on a UTF-8 continuation byte.
const head = line({ cwd, session_id: "utf8" }, "session_meta") + "\n";
const tailRecord = record("npm run test:server") + "\n";
let filler = "→".repeat(30_000);
for (let pad = 0; pad < 8; pad += 1) {
const body = head + filler + "\n" + tailRecord;
const buf = Buffer.from(body, "utf8");
const boundary = buf.length - 64 * 1024;
if (boundary > 0 && (buf[boundary] & 0xc0) === 0x80) break;
filler += "x";
}
fs.writeFileSync(file, head + filler + "\n" + tailRecord);
const buf = Buffer.from(fs.readFileSync(file));
assert.equal(buf[buf.length - 64 * 1024] & 0xc0, 0x80, "fixture must straddle a UTF-8 char");
codex.__resetOffsets();
assert.equal(codex.ingestCodexOnce({}), 1);
assert.equal(lanes.getLane(lane.id).detected_stage, "tests");
// The real proof: the next append must still parse, which it only does if
// the stored offset is exactly on the previous line boundary.
fs.appendFileSync(file, record("git push origin feat/x") + "\n");
assert.equal(codex.ingestCodexOnce({}), 1);
assert.equal(lanes.getLane(lane.id).detected_stage, "ship");
lanes.deleteLane(lane.id);
});
it("ingests a line appended exactly at the previous read's boundary", () => {
// The offset after a pass lands on a line boundary, so the next chunk's
// FIRST line is a whole record — dropping it as torn loses every append
// that arrives one line at a time, which is how a journal actually grows.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = path.join(DIR, "rollout-2026-08-27T10-00-04-fff.jsonl");
fs.writeFileSync(file, line({ cwd, session_id: "boundary" }, "session_meta") + "\n");
codex.__resetOffsets();
codex.ingestCodexOnce({}); // consumes the header, offset now at EOF
assert.equal(lanes.getLane(lane.id).detected_stage, null);
fs.appendFileSync(
file,
line({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
}) + "\n"
);
assert.equal(codex.ingestCodexOnce({}), 1);
assert.equal(lanes.getLane(lane.id).detected_stage, "tests");
lanes.deleteLane(lane.id);
});
it("re-reads a torn line whole on the next pass", () => {
// A pass that catches the writer mid-line must not consume that fragment:
// the record it belongs to would be lost forever.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = path.join(DIR, "rollout-2026-08-27T10-00-05-ggg.jsonl");
const record = line({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
});
fs.writeFileSync(
file,
line({ cwd, session_id: "torn" }, "session_meta") + "\n" + record.slice(0, 40)
);
codex.__resetOffsets();
codex.ingestCodexOnce({});
assert.equal(lanes.getLane(lane.id).detected_stage, null);
fs.appendFileSync(file, record.slice(40) + "\n"); // the writer finishes the line
assert.equal(codex.ingestCodexOnce({}), 1);
assert.equal(lanes.getLane(lane.id).detected_stage, "tests");
lanes.deleteLane(lane.id);
});
it("does not replay a journal it sees for the first time already idle", () => {
// A thread that finished days ago must not paint a stage now — the first
// sight of a stale file only records where it ends.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = writeRollout("rollout-2026-08-20T10-00-00-eee.jsonl", cwd);
const old = Date.now() - 6 * 60 * 60 * 1000;
fs.utimesSync(file, old / 1000, old / 1000);
codex.__resetOffsets();
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).detected_stage, null);
lanes.deleteLane(lane.id);
});
it("ignores a torn/partial JSONL line at the end of a file", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-torn-line-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = path.join(DIR, "rollout-torn.jsonl");
fs.writeFileSync(
file,
[
line({ cwd, session_id: "torn" }, "session_meta"),
line({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
}),
// A torn last line: not valid JSON, so it will be skipped on next pass
'{"timestamp":"2026-08-27T10:00:00.000Z","type":"response_item","paylo',
].join("\n")
);
codex.__resetOffsets();
const seen = [];
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
const after = lanes.getLane(lane.id);
assert.equal(after.detected_stage, "tests"); // the valid line was processed
assert.deepEqual(seen, [lane.id]);
lanes.deleteLane(lane.id);
});
it("handles a journal whose session_meta is unparseable gracefully", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-bad-meta-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = path.join(DIR, "rollout-bad-meta.jsonl");
// Write an invalid JSON line as the first line
fs.writeFileSync(file, "not valid json at all\n");
codex.__resetOffsets();
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).detected_stage, null);
lanes.deleteLane(lane.id);
});
it("picks the deepest lane when a journal's cwd matches nested lanes", () => {
const outer = fs.mkdtempSync(path.join(os.tmpdir(), "codex-outer-"));
const inner = fs.mkdtempSync(path.join(outer, "inner"));
const outerLane = lanes.createLane({ title: "outer", cwd: outer, tool: "codex" });
const innerLane = lanes.createLane({ title: "inner", cwd: inner, tool: "codex" });
const file = path.join(DIR, "rollout-nested.jsonl");
fs.writeFileSync(
file,
[
line({ cwd: inner, session_id: "nested" }, "session_meta"),
line({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: inner }),
}),
].join("\n") + "\n"
);
codex.__resetOffsets();
const seen = [];
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
// Inner lane should be the one detected, not outer
assert.equal(lanes.getLane(innerLane.id).detected_stage, "tests");
assert.equal(lanes.getLane(outerLane.id).detected_stage, null);
assert.deepEqual(seen, [innerLane.id]);
lanes.deleteLane(innerLane.id);
lanes.deleteLane(outerLane.id);
});
it("does not touch status of a lane with an active run_id", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-run-id-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
lanes.updateLane(lane.id, { run_id: "active-run" });
writeRollout("rollout-2026-08-27T10-00-10-rrr.jsonl", cwd);
codex.__resetOffsets();
codex.ingestCodexOnce({});
const after = lanes.getLane(lane.id);
// Status should not change because run_id is present
assert.equal(after.status, "idle");
lanes.deleteLane(lane.id);
});
it("does not change status from provisioning or failed", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-status-protected-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
lanes.updateLane(lane.id, { status: "provisioning" });
writeRollout("rollout-2026-08-27T10-00-11-ppp.jsonl", cwd);
codex.__resetOffsets();
codex.ingestCodexOnce({});
assert.equal(lanes.getLane(lane.id).status, "provisioning");
lanes.updateLane(lane.id, { status: "failed" });
codex.ingestCodexOnce({});
assert.equal(lanes.getLane(lane.id).status, "failed");
lanes.deleteLane(lane.id);
});
it("respects DASHBOARD_CODEX_POLL_MS=0 to disable polling", () => {
const oldMs = process.env.DASHBOARD_CODEX_POLL_MS;
try {
process.env.DASHBOARD_CODEX_POLL_MS = "0";
// startCodexPoll should return early and not set up an interval
const timer = codex.startCodexPoll(() => {});
assert.equal(timer, undefined);
} finally {
if (oldMs === undefined) delete process.env.DASHBOARD_CODEX_POLL_MS;
else process.env.DASHBOARD_CODEX_POLL_MS = oldMs;
}
});
it("handles a shell call with command as an array in arguments", () => {
const events = codex.eventsFromPayload({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ command: ["npm", "run", "test:server"], workdir: "/repo" }),
});
// Should join array elements
assert.deepEqual(events, [
{ tool_name: "Bash", tool_input: { command: "npm run test:server" } },
]);
});
it("ignores a payload with a non-string name", () => {
// A payload with name as null or number should not match any tool
const events1 = codex.eventsFromPayload({
type: "function_call",
name: null,
arguments: "{}",
});
assert.deepEqual(events1, []);
const events2 = codex.eventsFromPayload({
type: "custom_tool_call",
name: 123,
input: "some script",
});
assert.deepEqual(events2, []);
});
it("uses the LAST detection in a batch to move the lane forward", () => {
// Within one ingest pass, if multiple tool calls map to different stages,
// recordDetection is called in order. Only the LAST one that actually writes
// should move the lane forward (forward-only). Simulate two tool calls:
// first impl call (implement), then tests call (tests).
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-batch-last-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = path.join(DIR, "rollout-batch-last.jsonl");
fs.writeFileSync(
file,
[
line({ cwd, session_id: "batch" }, "session_meta"),
// Edit event (implement node)
line({
type: "custom_tool_call",
name: "exec",
input: 'await tools.edit("src/main.js", "add x");',
}),
// Bash event (tests node)
line({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
}),
].join("\n") + "\n"
);
codex.__resetOffsets();
const seen = [];
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
const after = lanes.getLane(lane.id);
// Should detect the tests node (later in batch), not implement
assert.equal(after.detected_stage, "tests");
lanes.deleteLane(lane.id);
});
it("limits file scan to MAX_FILES newest files", () => {
// Create more than MAX_FILES journal files and verify only newest are scanned
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-max-files-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
// Create 50 old files (more than MAX_FILES=40)
const oldTime = Date.now() - 2 * 60 * 60 * 1000;
for (let i = 0; i < 50; i++) {
const file = path.join(DIR, `rollout-old-${String(i).padStart(2, "0")}.jsonl`);
fs.writeFileSync(file, line({ cwd, session_id: `old-${i}` }, "session_meta") + "\n");
fs.utimesSync(file, oldTime / 1000, oldTime / 1000);
}
codex.__resetOffsets();
// The scan should not stall and should return (no detection expected since all files are stale)
const result = codex.ingestCodexOnce({});
// All files are stale and not active, so no broadcast
assert.equal(result, 0);
lanes.deleteLane(lane.id);
});
it("detects correctly with Delete File in patch heredoc", () => {
const events = codex.eventsFromPayload({
type: "custom_tool_call",
name: "exec",
input: 'const patch = "*** Begin Patch\\n*** Delete File: old/removed.js\\n*** End Patch";',
});
// Delete creates an Edit event (not Write, not ignored)
assert.deepEqual(events, [{ tool_name: "Edit", tool_input: { file_path: "old/removed.js" } }]);
});
it("handles file paths with whitespace in patch patches", () => {
const events = codex.eventsFromPayload({
type: "custom_tool_call",
name: "exec",
input:
'const patch = "*** Begin Patch\\n*** Update File: src/my file.js \\n*** End Patch";',
});
// File path should be trimmed
assert.deepEqual(events, [{ tool_name: "Edit", tool_input: { file_path: "src/my file.js" } }]);
});
});
+123
View File
@@ -229,6 +229,129 @@ describe("ccam stage", () => {
});
});
// A cwd can back one `claude` lane AND one `codex` lane (idx_lanes_cwd_tool),
// so cwd alone no longer identifies a lane. Picking the first of the tie means
// a codex session's `ccam stage` silently drives the claude lane's card.
describe("ccam stage in a directory shared by two lanes", () => {
const DUAL_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-dual-lane-"));
let claudeLane;
let codexLane;
// The suite itself runs under Claude Code, whose CLAUDECODE marker would
// leak into every CLI child and answer the question under test.
const NO_HARNESS = { CLAUDECODE: "", CLAUDE_CODE_ENTRYPOINT: "" };
before(async () => {
claudeLane = (await post("/api/lanes", { cwd: DUAL_DIR, title: "dual claude" })).body.lane;
codexLane = (await post("/api/lanes", { cwd: DUAL_DIR, title: "dual codex", tool: "codex" }))
.body.lane;
assert.notEqual(claudeLane.id, codexLane.id);
});
async function stageOf(id) {
return (await get(`/api/lanes/${id}`)).body.lane.stage;
}
it("CCAM_LANE_ID wins — a dashboard-started pane always knows its own lane", async () => {
const r = await cli(["stage", "gate"], DUAL_DIR, {
...NO_HARNESS,
CCAM_LANE_ID: String(codexLane.id),
});
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(codexLane.id), "gate");
assert.notEqual(await stageOf(claudeLane.id), "gate");
});
it("--tool picks the lane when there is no env marker", async () => {
const r = await cli(["stage", "tests", "--tool", "codex"], DUAL_DIR, NO_HARNESS);
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(codexLane.id), "tests");
assert.notEqual(await stageOf(claudeLane.id), "tests");
});
it("CLAUDECODE in the environment resolves to the claude lane", async () => {
const r = await cli(["stage", "implement"], DUAL_DIR, { CLAUDECODE: "1" });
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(claudeLane.id), "implement");
assert.notEqual(await stageOf(codexLane.id), "implement");
});
it("fails loudly, naming both candidates, when nothing identifies the tool", async () => {
const before = [await stageOf(claudeLane.id), await stageOf(codexLane.id)];
const r = await cli(["stage", "done"], DUAL_DIR, NO_HARNESS);
assert.notEqual(r.status, 0);
const out = `${r.stdout}${r.stderr}`;
assert.match(out, /--tool/);
assert.match(out, new RegExp(`#${claudeLane.id}\\b`));
assert.match(out, new RegExp(`#${codexLane.id}\\b`));
assert.deepEqual([await stageOf(claudeLane.id), await stageOf(codexLane.id)], before);
});
it("refuses when the requested tool owns no lane here", async () => {
const solo = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-solo-lane-"));
await post("/api/lanes", { cwd: solo, title: "solo claude" });
const r = await cli(["stage", "plan", "--tool", "codex"], solo, NO_HARNESS);
assert.notEqual(r.status, 0);
assert.match(`${r.stdout}${r.stderr}`, /no codex lane/i);
});
it("--lane flag with explicit ID beats cwd resolution and tool ambiguity", async () => {
// Even with two lanes at the same cwd, --lane <id> bypasses all resolution
const r = await cli(["stage", "review", "--lane", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(codexLane.id), "review");
assert.notEqual(await stageOf(claudeLane.id), "review");
});
it("positional numeric ID acts like --lane for commands that support it (e.g. ccam lanes pipeline)", async () => {
// Commands like `ccam lanes pipeline` treat a leading numeric arg as the lane ID.
// Other commands like `ccam stage` have their own positional argument and don't support it.
const r = await cli(["lanes", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`lane #${codexLane.id}`));
});
it("non-numeric CCAM_LANE_ID is ignored", async () => {
// If CCAM_LANE_ID is garbage (not a number), it should be skipped and
// fall through to --tool or env markers
const r = await cli(["stage", "ship"], DUAL_DIR, {
...NO_HARNESS,
CCAM_LANE_ID: "not-a-number",
CLAUDECODE: "1", // This env marker should pick claude lane
});
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(claudeLane.id), "ship");
assert.notEqual(await stageOf(codexLane.id), "ship");
});
it("a CCAM_LANE_ID naming no lane falls through instead of addressing it", async () => {
// A pane outlives the lane it was started for, and lane ids are reused
// after a delete. Trusting the number blind would either 404 or, worse,
// write this session's stage onto whatever lane inherited that id.
const r = await cli(["stage", "gate"], DUAL_DIR, {
...NO_HARNESS,
CCAM_LANE_ID: "999999",
CLAUDECODE: "1",
});
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(claudeLane.id), "gate");
});
it("a CCAM_LANE_ID for a lane outside this directory is not used", async () => {
// The pane's lane is the truth only while the command runs inside it; an
// agent that cd'd into a different repo must resolve by that directory.
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-elsewhere-"));
const other = (await post("/api/lanes", { cwd: elsewhere, title: "elsewhere" })).body.lane;
const r = await cli(["stage", "review"], DUAL_DIR, {
...NO_HARNESS,
CCAM_LANE_ID: String(other.id),
CLAUDECODE: "1",
});
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(claudeLane.id), "review");
assert.notEqual(await stageOf(other.id), "review");
});
});
describe("ccam lanes add", () => {
it("creates a lane and it appears in lanes list", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-add-"));
+116
View File
@@ -551,6 +551,28 @@ describe("stage detection", () => {
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" });
@@ -973,4 +995,98 @@ describe("the forward-only hold is short enough for one work session", () => {
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);
});
});
+41 -4
View File
@@ -82,7 +82,7 @@ describe("pty-run", () => {
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
const literal = calls.find((c) => c[0] === "send-keys" && c[3] === "-l");
assert.ok(literal, "expected a literal send-keys with the command line");
assert.match(literal[4], /^'claude' .*'--resume' 'abc12345'$/);
assert.match(literal[4], /^CCAM_LANE_ID='8' 'claude' .*'--resume' 'abc12345'$/);
assert.ok(
calls.some((c) => c[0] === "send-keys" && c[3] === "Enter"),
"expected the command to be submitted"
@@ -100,6 +100,38 @@ describe("pty-run", () => {
assert.ok(literal.endsWith(`'don'\\''t; rm -rf /'`), literal);
});
it("spawnRun puts CCAM_LANE_ID in the new session's environment", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
pty.spawnRun({ laneId: 33, cwd: "/tmp/repo", tool: "codex" });
const at = newSessionArgv.indexOf("-e");
assert.ok(at > -1, "expected a tmux -e environment flag");
assert.equal(newSessionArgv[at + 1], "CCAM_LANE_ID=33");
// Must stay ahead of the `--` that starts the pane's own argv.
assert.ok(at < newSessionArgv.indexOf("--"));
});
it("spawnRun prefixes CCAM_LANE_ID when typing into an existing shell pane", () => {
let literal = null;
tmux.__setExecImpl((args) => {
if (args[0] === "display-message") return "bash\n";
if (args[0] === "send-keys" && args[3] === "-l") literal = args[4];
return "";
});
pty.spawnRun({ laneId: 12, cwd: "/tmp/repo", tool: "codex" });
// An assignment prefix on the same line: tmux's -e only applies at session
// creation, and a live shell takes its env from the command line.
assert.match(literal, /^CCAM_LANE_ID='12' 'codex'/);
});
it("spawnRun with resumeSessionId passes --resume in argv", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
@@ -230,6 +262,11 @@ describe("pty-run", () => {
return calls.find((c) => c[0] === "new-session");
}
/** The pane's own command line — everything tmux passes after the `--`. */
function paneArgv(newSessionCall) {
return newSessionCall.slice(newSessionCall.indexOf("--") + 1);
}
it("spawnRun builds a codex argv for a codex lane", () => {
const call = captureNewSession(() =>
pty.spawnRun({
@@ -243,7 +280,7 @@ describe("pty-run", () => {
initialPrompt: "hello",
})
);
const argv = call.slice(7);
const argv = paneArgv(call);
assert.deepEqual(argv, [
"codex",
"-m",
@@ -267,7 +304,7 @@ describe("pty-run", () => {
resumeSessionId: "0199aaaa-bbbb-cccc-dddd-eeeeffff0000",
})
);
assert.deepEqual(withId.slice(7, 10), [
assert.deepEqual(paneArgv(withId).slice(0, 3), [
"codex",
"resume",
"0199aaaa-bbbb-cccc-dddd-eeeeffff0000",
@@ -277,7 +314,7 @@ describe("pty-run", () => {
const last = captureNewSession(() =>
pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", tool: "codex", resumeLast: true })
);
assert.deepEqual(last.slice(7, 10), ["codex", "resume", "--last"]);
assert.deepEqual(paneArgv(last).slice(0, 3), ["codex", "resume", "--last"]);
});
it("spawnRun rejects a codex sandbox/approval outside the allowlist", () => {