feat(lanes): auto-advance a codex lane's stage from its rollout detection

A codex session emits no hooks and rarely calls `ccam stage` on its own, so
its card sat at `idle` forever while the amber "detected" badge was the only
thing that ever moved. `codex-ingest.js` now advances the declared `stage`
from its own written detection — the one place in CCAM where an inference
moves the blue `current` ring, and only for `codex` lanes.

It stays honest: gated on `recordDetection` having written (so forward-only,
declared-wins and the detection TTL are inherited unchanged), passes NO
evidence (the node renders `passed-no-evidence` amber, never `done`), and no
template's last node carries detect rules, so a lane can never be
auto-declared `done`. `setStage` grows a `keepDetection` option — its sole
caller — so the detection columns survive the advance and the card keeps
showing which command moved it instead of looking like an agent's own claim.

Two levers then get the evidence auto-advance cannot supply:

- `codex-nudge.js` now nudges on a stage held 2+ min with no evidence,
  instead of on a detection sitting ahead of the declared stage — after
  auto-advance the old condition can never fire, since the two now match.
- `ccam lanes agents install` on a codex lane writes the marker-delimited
  `<!-- ccam:stage:start -->` block into the lane's own AGENTS.md — the one
  instruction channel Codex reads — instead of copying agent templates into
  `.claude/agents/`, which it never loads. Appended, user prose preserved,
  reinstall replaces exactly that block.

Verified with a real `codex exec` run: stage moved to `watching-pr` with
`evidence: null` and the detection signal intact. 1364 server tests pass.
This commit is contained in:
2026-09-03 16:51:02 +07:00
parent 8a812a174b
commit e109d2804c
13 changed files with 318 additions and 106 deletions
+27 -26
View File
@@ -1,35 +1,34 @@
/**
* @file Safety-net reminder for codex lanes whose auto-detected stage has
* clearly moved past the last stage the session actually *declared* (see
* `server/lib/stage-detect.js` / `server/lib/codex-ingest.js`). A codex
* session only declares a stage when it calls `ccam stage` on its own —
* there is no hook or wrapper that does it for it — so a lane can sit with a
* stale declared stage indefinitely even while its pipeline map keeps
* painting fresh amber "detected" badges.
* @file Safety-net reminder for codex lanes sitting at a stage nobody has
* backed with evidence. `codex-ingest.js` auto-advances a codex lane's stage
* from its rollout journal — that keeps the pipeline map moving, but an
* inferred stage carries no evidence, so its node renders
* `passed-no-evidence` (amber) rather than `done`. Only the session's own
* `ccam stage <node> --evidence "..."` call can supply that, and a codex
* session has no hook or wrapper that does it for it.
*
* This module never writes `stage` or `detected_stage` itself — the
* declared-wins / inferred-never-`done` invariants in lanes.js are untouched.
* It only types a plain-text reminder into the lane's own live tmux pane
* This module never writes `stage`, `stages` or `detected_stage` itself. It
* only types a plain-text reminder into the lane's own live tmux pane
* (`tmux.sendText`), the same way a human watching the dashboard would nudge
* the agent — so any stage change that results is still a REAL declaration
* the codex session made itself.
* the agent — so the evidence that results is still a REAL declaration the
* codex session made itself.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const NUDGE_DELAY_MS = 120_000; // give the session 2 min to declare on its own first
// Per-lane memory of the last detected_stage already nudged for, so the 60s
// sweep doesn't retype the same reminder every pass. Cleared implicitly: once
// the session declares (setStage nulls detected_stage) or detection advances
// again, the value here no longer matches and a fresh nudge is due.
// Per-lane memory of the last stage already nudged for, so the 60s sweep
// doesn't retype the same reminder every pass. Cleared implicitly: once the
// session declares with evidence, or the lane moves to another node, the value
// here no longer matches and a fresh nudge is due.
// ponytail: in-memory, resets on server restart — worst case one duplicate
// nudge after a restart, not worth a DB column for.
const nudgedFor = new Map();
/**
* One sweep pass: nudge every codex lane whose fresh detection sits ahead of
* its declared stage, has held for at least NUDGE_DELAY_MS, and hasn't
* already been nudged for that exact detected stage.
* One sweep pass: nudge every codex lane whose CURRENT stage has been held for
* at least NUDGE_DELAY_MS with no evidence recorded, and hasn't already been
* nudged for that exact stage.
* @returns {number} How many lanes were nudged.
*/
function sweepCodexNudges() {
@@ -47,14 +46,16 @@ function sweepCodexNudges() {
let nudged = 0;
for (const lane of codexLanes) {
try {
if (!lane.detected_stage || lane.detected_stage === lane.stage) {
// No record under the current stage means the lane never entered a
// pipeline node (a fresh lane sits at `idle`) — nothing to ask about.
const record = (lane.stages || {})[lane.stage];
if (!record || record.evidence) {
nudgedFor.delete(lane.id);
continue;
}
if (lanes.detectionIsStale(lane.detected_at)) continue;
const detectedAt = Date.parse(lane.detected_at);
if (!Number.isFinite(detectedAt) || Date.now() - detectedAt < NUDGE_DELAY_MS) continue;
if (nudgedFor.get(lane.id) === lane.detected_stage) continue;
const enteredAt = Date.parse(record.enteredAt);
if (!Number.isFinite(enteredAt) || Date.now() - enteredAt < NUDGE_DELAY_MS) continue;
if (nudgedFor.get(lane.id) === lane.stage) continue;
const runId = runIdForLane(lane.id);
if (!tmux.hasSession(runId)) continue;
@@ -64,9 +65,9 @@ function sweepCodexNudges() {
tmux.sendText(
runId,
`[ccam] You appear to have moved on to "${lane.detected_stage}" — run: ccam stage ${lane.detected_stage} --tool codex --evidence "..."`
`[ccam] Stage "${lane.stage}" was inferred and has no evidence — run: ccam stage ${lane.stage} --tool codex --evidence "..."`
);
nudgedFor.set(lane.id, lane.detected_stage);
nudgedFor.set(lane.id, lane.stage);
nudged += 1;
} catch {
/* one lane's failure must not abort the sweep */