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