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
+96 -3
View File
@@ -50,7 +50,7 @@ Both modes' path field has a **Browse** button next to it, opening a small folde
A lane is driven by a tool: **Claude** (default) or **Codex**, chosen in the "+ Add lane" modal and immutable once the lane exists. A lane's tool determines which CLI is spawned when a run starts.
- **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude lanes render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session.
- **`codex` (experimental)** — `codex` binary is invoked instead. The Run Setup panel shows fields for model, sandbox, and approval mode instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses `codex resume --last`.
- **`codex` (experimental)** — `codex` binary is invoked instead. The Run Setup panel shows fields for model, sandbox, and approval mode instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses `codex resume --last`. Codex lanes render stage-detection badges too, fed from Codex's own rollout journals rather than hooks (see "Stage detection"), but no Sessions/Analytics/cost — and the console never pre-fills the pipeline's Claude slash command into a codex pane.
### How Codex arguments map
@@ -69,10 +69,16 @@ Codex does not emit Claude Code hooks (sessions, agent events, tool-execution tr
- **Sessions** and **Analytics** tabs — no session history or aggregated statistics
- **Cost** surface — no per-run or per-model pricing data
- **Stage detection** — automatic stage inference from tool events (`stage-detect`) never fires
**These are not bugs; they reflect Codex's architecture.** The stage you declare via `ccam stage` (or that the driving agent calls itself) still works; liveness is tracked by the lane's tmux pane (if the stack is up) and `dashboard_runs` table (recording every invocation). Cost tracking for Codex runs would require parsing Codex's own logs, a separate design not yet implemented.
**Stage detection is not in that list.** It does work for a codex lane, from
Codex's own on-disk rollout journals rather than from hooks — see "Stage
detection" below. What a codex lane cannot get for free is a *declared* stage:
the run console never pre-fills a Claude slash command into a codex pane (Codex
would read `/ccam:ship-feature-lane` as prose), so a codex session declares
stages only when something in the repository — an `AGENTS.md` — tells it to.
## Destructive lane actions
Reset a managed worktree, remove one, or purge the lane's eligible session history with the CLI:
@@ -559,7 +565,7 @@ This command:
### Full command signature
```bash
ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]
ccam stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]
```
**Parameters:**
@@ -592,6 +598,8 @@ ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text
- `--cwd <path>` (optional): working directory to match against a lane's cwd. If omitted, uses the current working directory. Useful when calling from outside the repo.
- `--tool claude|codex` (optional): which tool's lane to address when one directory backs both (see "Which lane a CLI command addresses" below).
- `--status <s>` (optional): the lane's overall status (`running`, `idle`, `provisioning`). Allows bundling a status update with the stage report.
- `--evidence <text>` (optional): a short description of what was completed at this stage. Moves the node from `passed-no-evidence` to `done`.
@@ -600,6 +608,38 @@ ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text
- `--result pass|fail` (optional): explicitly mark the stage as passed or failed. Ignored if `result` was already set; set once, never overwritten by a re-report.
### Which lane a CLI command addresses
Every cwd-resolving `ccam` command (`stage`, `lanes pipeline`, `feature
activate`, `lanes up/down/logs`, …) shares one resolver, `resolveLaneArg` in
`bin/ccam.js`. It answers in this order:
1. `--lane <id>`, or a leading numeric positional.
2. `CCAM_LANE_ID` in the environment — every tmux pane the dashboard starts
carries it (`tmux new-session -e`, or an assignment prefix when the argv is
typed into a pane already at a shell prompt). A session inside a
dashboard-started pane therefore never has to guess.
3. `--tool claude|codex`, then the harness's own marker: Claude Code exports
`CLAUDECODE`/`CLAUDE_CODE_ENTRYPOINT`, so a Claude session identifies itself
for free. **Codex has no equivalent marker** (it only reads `CODEX_HOME`, and
sets `CODEX_SANDBOX*` under a sandbox policy), so a codex session outside a
dashboard pane must pass `--tool codex`.
4. The lane owning the directory — longest path-boundary match, the same rule
the server uses.
The tool matters because **one directory can back a `claude` lane AND a `codex`
lane** (the composite `idx_lanes_cwd_tool`). With two candidates at the same
directory and no signal from steps 1-3, the command **fails** and names both
lanes rather than picking one:
```
2 lanes own /home/me/repo — pass --tool <claude|codex> or --lane <id>: #23 (claude), #33 (codex)
```
That is deliberate. Silently taking the first would write a codex session's
stage onto the claude lane's card — a lie about which agent did the work, and
exactly the failure this resolver exists to prevent.
### Example: a skill reporting progress
A `build` skill might call:
@@ -730,6 +770,52 @@ also **infers** a stage from the tool events every hook already delivers
un-instrumented sessions that never call `ccam stage` — it fills in a lane's
pipeline map without requiring every skill to be rewritten.
Two event sources feed the identical matcher, one per tool:
| Lane `tool` | Source | Cadence |
| --- | --- | --- |
| `claude` | hooks — `touchLaneFromHook` in `server/routes/hooks.js` | every hook, live |
| `codex` | Codex's own rollout journals on disk — `server/lib/codex-ingest.js` | poll, `DASHBOARD_CODEX_POLL_MS` (default 15000; `0` disables) |
Codex has no hook mechanism at all, so `touchLaneFromHook` is pinned to
`"claude"` — a hook must never touch a `codex` lane sharing the same directory.
`codex-ingest.js` is the codex-side equivalent: it tails
`$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl` (`$CODEX_HOME` defaults
to `~/.codex`), reads the thread's `cwd` from the `session_meta` on each file's
first line, and maps Codex's tool calls onto the same `{tool_name, tool_input}`
shape a Claude hook delivers:
| Codex tool call | Mapped to |
| --- | --- |
| `exec` / `exec_command` / `shell` / `local_shell` | `Bash` with the extracted `cmd` |
| any of the above carrying an `apply_patch` heredoc | one `Write` (for `*** Add File:`) or `Edit` (`Update`/`Delete`) per touched file |
| `update_plan` | `TaskCreate` |
| `spawn_agent` | `Agent` with the task name as `subagent_type` |
| anything else (messages, reasoning, web search, tool OUTPUT) | ignored |
Everything past that point is shared: the same templates, the same
forward-only/declared-wins rules, the same amber-never-green boundary. Two
codex-only details:
- Only bytes appended since the last pass are read. On the first sight of a
journal only its recent tail is read, and nothing at all when that file is
already idle — replaying a thread that finished days ago would paint stages
for work nobody is doing.
- The same pass mirrors the lane's `status` between `idle` and `running` from
the journal's mtime, under the identical guard hook ingestion uses (only a
lane with no `run_id`, and only from `idle`/`running` — never from
`provisioning` or `failed`). It writes nothing else: not `stage`, not
`notes`, not `slot`/`ports`.
**A codex lane's *declared* stage is a separate matter.** CCAM cannot inject an
instruction into a CLI it merely spawns, so a codex session declares a stage
only if something tells it to — an `AGENTS.md` in the repository (this repo has
one) is the only lever. A codex lane in a repo without one shows detected stages
and no declarations. Note that from inside a codex session `ccam stage` needs to
know which lane it is: a pane the dashboard started carries `CCAM_LANE_ID`, and
anywhere else `--tool codex` disambiguates (see "Which lane a CLI command
addresses" below).
### The evidence boundary
**An inferred stage is never evidence and never renders as done.** Green
@@ -963,6 +1049,13 @@ Detection never rewinds a lane and never fights a declaration:
- **Declared beats detected.** A detection is written only when the lane's
*declared* stage index is strictly less than the detection's. A lane
already declared at `review` ignores an `implement` detection outright.
**One exception: a declaration on the pipeline's LAST node** (`done`, in both
built-in templates) stops vetoing. The pipeline is finished there, so a later
detection is new work rather than a stale rerun of what the claim covers —
without the exception a lane left at `done` rejects every detection for the
rest of its life, which reads as "auto-detect is broken". The exception
changes nothing else: the detection still only paints the amber badge, and
`lanes.stage` still says `done` until an agent declares otherwise.
- **Write-on-change only.** The database write (and the `lane_update`
WebSocket broadcast) happens only when `recordDetection` reports an actual
change. A real install saw 29,470 `Bash` events alone from one project —