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
+59
View File
@@ -1512,6 +1512,65 @@ changes.
---
## Codex Lane Ingestion (no hooks)
A `codex` lane has no hook stream at all — Codex does not implement Claude
Code's hook protocol — so `touchLaneFromHook` (the only caller of
`stage-detect.detect()`) can never see one, and is deliberately pinned to
`"claude"` so a Claude hook cannot touch a codex lane sharing the same
directory.
`server/lib/codex-ingest.js` is the codex-side source. Codex appends a rollout
journal per thread at
`$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<threadId>.jsonl`
(`$CODEX_HOME` defaults to `~/.codex`); the first line is a `session_meta`
payload carrying the thread's `cwd`, and each tool call is a `custom_tool_call`
or `function_call` payload. One pass:
1. `rolloutFiles()` walks `sessions/` (depth ≤ 4), newest mtime first, capped at
40 files.
2. `rolloutCwd()` reads the thread's cwd from the file's first line (memoized —
it is written once, at thread creation) and matches it to a `codex` lane by
deepest path-boundary prefix.
3. Only bytes appended since the last pass are read. On the FIRST sight of a
file only its recent 64 KiB 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. `completeLines()` does the framing **on the
buffer, never on a decoded string** (a tail offset can land inside a
multi-byte UTF-8 sequence, where the decoder's U+FFFD would throw the byte
math off): a tail read's leading fragment is dropped, the stored offset
advances only past the last newline, and a trailing fragment is re-read whole
next pass. A read is capped at 4 MiB, and the two tracking maps are pruned to
the current scan once they exceed 4 × the 40-file cap.
4. `eventsFromPayload()` maps each payload onto the hook-shaped
`{tool_name, tool_input}` the matcher already understands: shell calls
(`exec`/`exec_command`/`shell`/`local_shell`) → `Bash`, an embedded
`apply_patch` heredoc → one `Write`/`Edit` per touched file, `update_plan` →
`TaskCreate`, `spawn_agent` → `Agent`. Everything else is ignored. Newlines
inside a patch heredoc arrive escaped (it rides inside a JS string literal),
so they are unescaped before the file list is scanned.
5. The last matching event of the batch goes through the same
`recordDetection()` as a hook — same forward-only, declared-wins,
amber-never-green rules — and the same pass mirrors the lane's `status`
between `idle` and `running` from the journal's mtime, under the identical
guard hook ingestion uses (no `run_id`, only from `idle`/`running`). Nothing
else is written: not `stage`, not `notes`, not `slot`/`ports`.
`startCodexPoll(broadcastLane)` runs it on an unref'd 15 s interval from
`server/index.js`, tunable via `DASHBOARD_CODEX_POLL_MS` (`0` disables). Every
step is fail-safe: an absent `~/.codex`, a malformed journal, a lane deleted
mid-pass or a busy SQLite is swallowed locally, and a pass never throws into the
event loop. No schema change, no new route, no new WebSocket message type — the
existing `lane_update` broadcast carries it.
Codex 0.149 also mirrors threads into version-suffixed SQLite files
(`state_5.sqlite`, `thread_history_1.sqlite`), but every thread row still points
at a live rollout file, so reading the JSONL avoids coupling to a schema the
next Codex release renames. `rolloutFiles()` is the single function to re-point
if that changes.
---
## Agent Extension Layer
The repository includes a triple extension strategy: