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
+33
View File
@@ -158,6 +158,39 @@ describe("codex ingest pass", () => {
lanes.deleteLane(lane.id);
});
it("advances the declared stage from the detection, without evidence", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
writeRollout("rollout-2026-08-27T10-00-10-adv.jsonl", cwd);
codex.__resetOffsets();
codex.ingestCodexOnce({});
const after = lanes.getLane(lane.id);
// The ring moves — a codex session has nothing else that would move it…
assert.equal(after.stage, "tests");
// …but the node stays amber: an inference sees the tool call, not its result.
assert.equal(after.stages.tests.evidence, null);
// …and the badge survives, so the card still says WHY it moved.
assert.equal(after.detected_stage, "tests");
assert.match(after.detected_signal, /test:server/);
lanes.deleteLane(lane.id);
});
it("never drags an auto-advanced stage backwards", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
lanes.setStage(lane.id, { stage: "review", evidence: "declared by the session" });
writeRollout("rollout-2026-08-27T10-00-11-back.jsonl", cwd); // detects `tests`, behind `review`
codex.__resetOffsets();
codex.ingestCodexOnce({});
const after = lanes.getLane(lane.id);
assert.equal(after.stage, "review"); // declared-wins: recordDetection refused, so no advance
assert.equal(after.detected_stage, null);
assert.equal(after.stages.review.evidence, "declared by the session");
lanes.deleteLane(lane.id);
});
it("a second pass with no appended bytes changes nothing", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
+39 -25
View File
@@ -1,7 +1,7 @@
/**
* @file Unit tests for the codex stage-declaration safety net: nudges a live
* codex pane to run `ccam stage` when detection has clearly moved past the
* declared stage and stayed there, without ever writing stage itself.
* @file Unit tests for the codex evidence safety net: nudges a live codex pane
* to run `ccam stage --evidence` when the lane has sat at an inferred (
* evidence-less) stage past the delay, without ever writing stage itself.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -22,10 +22,15 @@ const lanes = require("../lib/lanes");
const tmux = require("../lib/tmux");
const nudge = require("../lib/codex-nudge");
/** Backdate a lane's detected_at so it looks `minutes` old to the sweep. */
function backdateDetection(laneId, minutes) {
const iso = new Date(Date.now() - minutes * 60_000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(iso, laneId);
/** Backdate the current stage's enteredAt so it looks `minutes` old. */
function backdateStage(laneId, minutes) {
const lane = lanes.getLane(laneId);
const stages = { ...lane.stages };
stages[lane.stage] = {
...stages[lane.stage],
enteredAt: new Date(Date.now() - minutes * 60_000).toISOString(),
};
db.prepare("UPDATE lanes SET stages = ? WHERE id = ?").run(JSON.stringify(stages), laneId);
}
describe("codex-nudge sweep", () => {
@@ -45,24 +50,24 @@ describe("codex-nudge sweep", () => {
tmux.__reset();
});
function makeCodexLane({ cwd }) {
function makeCodexLane({ cwd, stage = "tests", evidence }) {
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` });
lanes.recordDetection(lane.id, { nodeId: "tests", signal: "npm run test:server" });
lanes.setStage(lane.id, { stage, evidence });
return lane;
}
it("nudges once a fresh detection has sat ahead of the declared stage past the delay", () => {
it("nudges once the lane has held an evidence-less stage past the delay", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-a` });
backdateDetection(lane.id, 3); // older than the 2 min delay, well inside the 5 min TTL
backdateStage(lane.id, 3); // older than the 2 min delay
assert.equal(nudge.sweepCodexNudges(), 1);
const sendKeys = calls.filter((a) => a[0] === "send-keys" && a[3] === "-l");
assert.equal(sendKeys.length, 1);
assert.match(sendKeys[0][4], /ccam stage tests --tool codex/);
assert.match(sendKeys[0][4], /ccam stage tests --tool codex --evidence/);
// A second pass for the same still-undeclared detection must not repeat it.
// A second pass for the same still-unevidenced stage must not repeat it.
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
@@ -70,15 +75,23 @@ describe("codex-nudge sweep", () => {
it("never nudges before the delay has elapsed", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-b` });
// detected_at is "now" — recordDetection just set it.
// enteredAt is "now" — setStage just wrote it.
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never nudges a stale detection (past the detection TTL)", () => {
it("never nudges a stage the session already backed with evidence", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-c` });
backdateDetection(lane.id, 10); // past the default 5 min TTL
const lane = makeCodexLane({ cwd: `${cwd}/nudge-c`, evidence: "12 tests green" });
backdateStage(lane.id, 10);
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never nudges a lane that has not entered a pipeline node", () => {
const cwd = os.tmpdir();
const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-h`, tool: "codex" });
lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` });
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
@@ -87,6 +100,8 @@ describe("codex-nudge sweep", () => {
const cwd = os.tmpdir();
const lane = lanes.createLane({ title: "cc", cwd: `${cwd}/nudge-d`, tool: "claude" });
lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` });
lanes.setStage(lane.id, { stage: "tests" });
backdateStage(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
@@ -94,8 +109,8 @@ describe("codex-nudge sweep", () => {
it("never nudges a lane with no live run", () => {
const cwd = os.tmpdir();
const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-e`, tool: "codex" });
lanes.recordDetection(lane.id, { nodeId: "tests", signal: "npm run test:server" });
backdateDetection(lane.id, 3);
lanes.setStage(lane.id, { stage: "tests" });
backdateStage(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
@@ -103,7 +118,7 @@ describe("codex-nudge sweep", () => {
it("never types into a pane sitting at a bare shell prompt", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-f` });
backdateDetection(lane.id, 3);
backdateStage(lane.id, 3);
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "display-message") return "bash";
@@ -113,15 +128,14 @@ describe("codex-nudge sweep", () => {
lanes.deleteLane(lane.id);
});
it("nudges again once the target detected stage changes, after a prior nudge", () => {
it("nudges again once the lane moves to another evidence-less stage", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-g` });
backdateDetection(lane.id, 3);
backdateStage(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 1);
// Detection advances to a later node; forward-only recordDetection accepts it.
lanes.recordDetection(lane.id, { nodeId: "review", signal: "gh pr create" });
backdateDetection(lane.id, 3);
lanes.setStage(lane.id, { stage: "review" });
backdateStage(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 1);
lanes.deleteLane(lane.id);
});
+26
View File
@@ -115,4 +115,30 @@ describe("installAgents", () => {
await installAgents(lane);
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents")));
});
it("installs the AGENTS.md stage block for a codex lane, not .claude/agents/", async () => {
const cwd = makeRepoDir();
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" });
const result = await installAgents(lane);
assert.deepEqual(result.installed, ["AGENTS.md"]);
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
const doc = fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8");
assert.match(doc, /ccam stage <stage> --tool codex/);
});
it("preserves the user's own AGENTS.md prose and replaces only the block", async () => {
const cwd = makeRepoDir();
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" });
const file = path.join(cwd, "AGENTS.md");
fs.writeFileSync(file, "# House rules\n\nNever force-push.\n");
await installAgents(lane);
await installAgents(lane); // reinstall must not duplicate the block
const doc = fs.readFileSync(file, "utf8");
assert.match(doc, /Never force-push\./);
assert.equal(doc.split("<!-- ccam:stage:start -->").length - 1, 1);
assert.equal(doc.split("<!-- ccam:stage:end -->").length - 1, 1);
});
});
+17 -1
View File
@@ -14,7 +14,11 @@
* tool-call payload onto the same `{tool_name, tool_input}` shape a Claude hook
* delivers, and feeds the EXISTING detect() + recordDetection() path — so a
* codex lane paints the same amber "detected" badge under the same rules
* (forward-only, declared-wins) and never renders `done`. Like
* (forward-only, declared-wins) and never renders `done`. A written detection
* then ALSO advances the lane's declared `stage` to that node, without
* evidence: codex lanes are the one place where nothing else can move the
* pipeline map, so the node renders `passed-no-evidence` (amber) rather than
* `done`, and codex-nudge.js asks the session for the real evidence. Like
* workflow-ingest.js, everything here is fail-safe: a malformed or partial
* journal throws only locally and is skipped, and a poll pass never throws.
*
@@ -309,6 +313,18 @@ function ingestCodexOnce({ broadcast } = {}) {
if (detection) {
try {
changed = lanesLib.recordDetection(lane.id, detection).written;
// Auto-advance: a codex session has no hook and rarely calls `ccam
// stage` on its own, so without this its card sits at `idle` forever
// while the amber badge is the only thing that ever moves. Gated on
// recordDetection having WRITTEN, so it inherits that function's
// forward-only + declared-wins + TTL guards for free. Deliberately
// passes NO evidence: the node renders `passed-no-evidence` (amber),
// never `done` — detection sees a tool call, never its outcome. The
// last node has no detect rules in any template, so this can never
// auto-declare a lane `done`.
if (changed) {
lanesLib.setStage(lane.id, { stage: detection.nodeId, keepDetection: true });
}
} catch {
/* lane deleted mid-pass, or sqlite busy — an inference is never worth throwing for */
}
+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 */
+54 -2
View File
@@ -1,5 +1,6 @@
/**
* @file Installs the ship-feature-lane pipeline's agent templates
* @file Installs a lane's driving instructions. For a `claude` lane that is
* the ship-feature-lane pipeline's agent templates
* (qc-local, senior-gate-reviewer) into a lane's own .claude/agents/, so a
* driving session can launch them by the BARE subagent_type name. Source is
* the repo's own `agents/` — the same files the ccam plugin ships, where
@@ -19,6 +20,52 @@ const TEMPLATE_DIR = path.join(__dirname, "..", "..", "agents");
const TEMPLATE_FILES = ["qc-local.md", "senior-gate-reviewer.md"];
const EXCLUDE_LINE = ".claude/agents/";
// Codex reads no `.claude/agents/`, so for a codex lane the useful thing to
// install is the one instruction it DOES read: AGENTS.md, the project doc it
// loads from the working directory. Marker-delimited so a reinstall replaces
// exactly this block and never the user's own prose, and appended rather than
// written over an existing file.
const STAGE_BLOCK_START = "<!-- ccam:stage:start -->";
const STAGE_BLOCK_END = "<!-- ccam:stage:end -->";
const STAGE_BLOCK = `${STAGE_BLOCK_START}
## Declare your lane stage as you work
This directory is watched by CCAM. Its pipeline map advances on its own by
*inferring* your stage from your tool calls, but an inference carries no
evidence — the node stays amber, never green. Only your own declaration does:
\`\`\`bash
ccam stage <stage> --tool codex --evidence "<what is actually done>"
\`\`\`
- Call it at every real transition — starting to plan, starting to implement,
running tests, opening the PR — not once at the end.
- \`--tool codex\` matters: one directory can back both a \`claude\` lane and a
\`codex\` lane, and without a tool the command fails rather than guess. Inside
a pane the dashboard started you need nothing — it carries \`CCAM_LANE_ID\`.
- \`ccam stage\` warns on stderr and names the valid nodes if a stage matches
none of this lane's pipeline template.
- If it reports that no lane owns this directory, skip the stage calls and
carry on. Stage reporting is reporting, never control flow.
- A one-line \`[ccam]\` reminder may be typed straight into this pane asking for
evidence on the current stage. That is the dashboard, not the user — treat it
as the same instruction as this section and make the real call.
${STAGE_BLOCK_END}`;
/** Append the stage block to `<cwd>/AGENTS.md`, or replace an existing one. */
function installStageDoc(cwd) {
const file = path.join(cwd, "AGENTS.md");
const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
const start = existing.indexOf(STAGE_BLOCK_START);
const end = existing.indexOf(STAGE_BLOCK_END);
const next =
start !== -1 && end > start
? existing.slice(0, start) + STAGE_BLOCK + existing.slice(end + STAGE_BLOCK_END.length)
: `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${STAGE_BLOCK}\n`;
fs.writeFileSync(file, next);
return "AGENTS.md";
}
/** The dir shared across every worktree of a repo — where info/exclude
* lives (same as info/attributes; MERGE_HEAD/HEAD/the index are the only
* per-worktree-private state, not this). */
@@ -33,7 +80,10 @@ async function commonGitDir(cwd) {
* any existing copy — this is a reinstall, not a merge) and idempotently
* git-exclude that directory in the shared common git dir.
*
* @param {{cwd: string}} lane
* A `codex` lane gets the AGENTS.md stage block instead — Codex never reads
* `.claude/agents/`, and its own working copy is the only channel it does read.
*
* @param {{cwd: string, tool?: string}} lane
* @returns {Promise<{installed: string[]}>}
*/
async function installAgents(lane) {
@@ -43,6 +93,8 @@ async function installAgents(lane) {
});
}
if (lane.tool === "codex") return { installed: [installStageDoc(lane.cwd)] };
const dest = path.join(lane.cwd, ".claude", "agents");
fs.mkdirSync(dest, { recursive: true });
const installed = [];
+11 -2
View File
@@ -297,8 +297,15 @@ function reservedPorts(excludeLaneId = null) {
* of where the agent now says it is has not been passed for this pass; it has
* to be re-earned. Records under a key matching no node in the pipeline are
* left untouched — their position is unknown, so they cannot be proven ahead.
*
* `keepDetection` is the ONE exception to that clearing, and exists for exactly
* one caller: codex-ingest.js, which advances a codex lane's stage FROM its own
* detection. There the detection is not stale evidence of something the
* declaration superseded — it is the reason for the declaration, and the card
* has to keep saying so, or an inferred stage becomes indistinguishable from
* one the agent actually claimed.
*/
function setStage(id, { stage, status, evidence, note, result } = {}) {
function setStage(id, { stage, status, evidence, note, result, keepDetection } = {}) {
const lane = getLane(id);
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
const next = stage || lane.stage;
@@ -321,7 +328,9 @@ function setStage(id, { stage, status, evidence, note, result } = {}) {
};
db.prepare(
`UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?${
changed ? ", detected_stage = NULL, detected_signal = NULL, detected_at = NULL" : ""
changed && !keepDetection
? ", detected_stage = NULL, detected_signal = NULL, detected_at = NULL"
: ""
}
WHERE id = ?`
).run(