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
+70 -9
View File
@@ -1774,9 +1774,28 @@ async function cmdLanes() {
}
/**
* Which lane a command is about: an explicit id, or the lane owning the working
* directory (longest path-boundary match, the same rule the server uses). A
* session running inside a lane never has to know its own id.
* Which CLI this process is running under. Claude Code exports CLAUDECODE and
* CLAUDE_CODE_ENTRYPOINT into every command it runs; Codex has no equivalent
* marker (it only READS CODEX_HOME, and sets CODEX_SANDBOX* under a sandbox
* policy), so sniffing a CODEX_ prefix would be a guess. A codex session says
* so with `--tool codex`, or needs nothing at all inside a pane the dashboard
* started — that pane carries CCAM_LANE_ID.
*/
function toolFromEnv() {
return process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT ? "claude" : null;
}
/**
* Which lane a command is about, in order: an explicit id, the CCAM_LANE_ID of
* a dashboard-started pane, then the lane owning the working directory (longest
* path-boundary match, the same rule the server uses). A session running inside
* a lane never has to know its own id.
*
* The TOOL matters at the last step: one directory can back a `claude` lane AND
* a `codex` lane (the composite idx_lanes_cwd_tool), so cwd alone does not
* identify a lane. With two candidates and no signal — no `--tool`, no harness
* marker — this errors instead of picking one, because writing a stage onto the
* sibling tool's card is a silent lie about which agent did the work.
*
* Only the FIRST positional counts as an id, and only when it is all digits.
* Scanning the whole argv for a number would swallow flag values —
@@ -1795,17 +1814,55 @@ async function resolveLaneArg(args) {
if (explicit) return { laneId: explicit, rest: args };
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
const tool = flagValue("tool") || toolFromEnv();
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
const { lanes } = await get("/api/lanes");
const match = lanes
// A pane's CCAM_LANE_ID beats cwd matching, but only after it is checked
// against the live lane list AND the directory: a pane outlives the lane it
// was started for, ids are reused after a delete, and an agent that cd'd into
// another repo is no longer working in its pane's lane. Trusting the number
// blind would write this session's stage onto whatever lane inherited the id.
const fromPane = process.env.CCAM_LANE_ID;
if (fromPane && /^\d+$/.test(fromPane)) {
const paneLane = lanes.find((l) => String(l.id) === fromPane);
if (paneLane && (cwd === paneLane.cwd || cwd.startsWith(`${paneLane.cwd}/`))) {
return { laneId: paneLane.id, rest: args };
}
}
const owning = lanes
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
if (!match) {
.sort((a, b) => b.cwd.length - a.cwd.length);
if (!owning.length) {
console.error(`no lane owns ${cwd} — create one with: ccam lanes add --cwd ${cwd}`);
process.exitCode = 1;
return null;
}
return { laneId: match.id, rest: args };
// Longest path still wins first; only lanes at that same directory compete,
// and among those the tool decides.
const nearest = owning.filter((l) => l.cwd === owning[0].cwd);
const candidates = tool ? nearest.filter((l) => (l.tool || "claude") === tool) : nearest;
if (!candidates.length) {
console.error(
`no ${tool} lane owns ${owning[0].cwd} — lanes here: ${nearest
.map((l) => `#${l.id} (${l.tool || "claude"})`)
.join(", ")}`
);
process.exitCode = 1;
return null;
}
if (candidates.length > 1) {
console.error(
`${candidates.length} lanes own ${owning[0].cwd} — pass --tool <${candidates
.map((l) => l.tool || "claude")
.join("|")}> or --lane <id>: ${candidates
.map((l) => `#${l.id} (${l.tool || "claude"})`)
.join(", ")}`
);
process.exitCode = 1;
return null;
}
return { laneId: candidates[0].id, rest: args };
}
/** One line per declared port: name, number, listening, and any base drift. */
@@ -2469,7 +2526,7 @@ async function cmdStage(args) {
const stage = args[0];
if (!stage || stage.startsWith("--")) {
console.error(
"usage: ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
"usage: ccam stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
);
process.exitCode = 1;
return;
@@ -2697,7 +2754,11 @@ const COMMAND_GROUPS = [
"[<name>] [--holder X] [--timeout N]",
"Cross-lane named lock (serialize builds/e2e across all lanes; holder defaults to the calling lane)",
],
["stage <stage> [flags]", "", "Report the current pipeline stage for a lane"],
[
"stage <stage> [flags]",
"",
"Report the current pipeline stage for a lane (--tool claude|codex when one cwd has both)",
],
["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
[
"feature activate",