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
+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-"));