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