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