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:
@@ -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-"));
|
||||
|
||||
Reference in New Issue
Block a user