8bd74a57e6
A codex lane's declared stage has always depended entirely on the session reading AGENTS.md and calling `ccam stage` on its own — no hook, no wrapper, no enforcement. Two additions make that more reliable without touching the declared-wins / inferred-never-done invariants: - LaneConsolePane pre-fills a codex pane's opening prompt with a plain-text stage-declaration checklist built from the lane's own pipeline_nodes — the same up-front priming a Claude lane gets from its pre-filled /ccam:ship-feature-lane skill command, just as prose since Codex has no slash commands. - server/lib/codex-nudge.js sweeps every 60s and types a one-line reminder into a lane's live tmux pane (tmux.sendText, a new literal-text primitive alongside sendCommand) when its fresh detected_stage has sat undeclared for over two minutes. It never writes stage/detected_stage itself — any resulting declaration is still the codex session's own real ccam stage call, just server-prompted instead of relying on memory alone. server 1354/1354, client 449/449, tsc clean.
129 lines
4.5 KiB
JavaScript
129 lines
4.5 KiB
JavaScript
/**
|
|
* @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.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
|
|
const SUITE_DB_PATH = path.join(
|
|
os.tmpdir(),
|
|
`dashboard-codex-nudge-${Date.now()}-${process.pid}.db`
|
|
);
|
|
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
|
|
|
|
const { describe, it, beforeEach, afterEach } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const { db } = require("../db");
|
|
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);
|
|
}
|
|
|
|
describe("codex-nudge sweep", () => {
|
|
let calls;
|
|
|
|
beforeEach(() => {
|
|
calls = [];
|
|
tmux.__setExecImpl((args) => {
|
|
calls.push(args);
|
|
if (args[0] === "display-message") return "codex"; // pane running the codex CLI
|
|
return "";
|
|
});
|
|
nudge.__resetNudgeState();
|
|
});
|
|
|
|
afterEach(() => {
|
|
tmux.__reset();
|
|
});
|
|
|
|
function makeCodexLane({ cwd }) {
|
|
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" });
|
|
return lane;
|
|
}
|
|
|
|
it("nudges once a fresh detection has sat ahead of the declared 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
|
|
|
|
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/);
|
|
|
|
// A second pass for the same still-undeclared detection must not repeat it.
|
|
assert.equal(nudge.sweepCodexNudges(), 0);
|
|
lanes.deleteLane(lane.id);
|
|
});
|
|
|
|
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.
|
|
assert.equal(nudge.sweepCodexNudges(), 0);
|
|
lanes.deleteLane(lane.id);
|
|
});
|
|
|
|
it("never nudges a stale detection (past the detection TTL)", () => {
|
|
const cwd = os.tmpdir();
|
|
const lane = makeCodexLane({ cwd: `${cwd}/nudge-c` });
|
|
backdateDetection(lane.id, 10); // past the default 5 min TTL
|
|
assert.equal(nudge.sweepCodexNudges(), 0);
|
|
lanes.deleteLane(lane.id);
|
|
});
|
|
|
|
it("never nudges a claude lane", () => {
|
|
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}` });
|
|
assert.equal(nudge.sweepCodexNudges(), 0);
|
|
lanes.deleteLane(lane.id);
|
|
});
|
|
|
|
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);
|
|
assert.equal(nudge.sweepCodexNudges(), 0);
|
|
lanes.deleteLane(lane.id);
|
|
});
|
|
|
|
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);
|
|
tmux.__setExecImpl((args) => {
|
|
calls.push(args);
|
|
if (args[0] === "display-message") return "bash";
|
|
return "";
|
|
});
|
|
assert.equal(nudge.sweepCodexNudges(), 0);
|
|
lanes.deleteLane(lane.id);
|
|
});
|
|
|
|
it("nudges again once the target detected stage changes, after a prior nudge", () => {
|
|
const cwd = os.tmpdir();
|
|
const lane = makeCodexLane({ cwd: `${cwd}/nudge-g` });
|
|
backdateDetection(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);
|
|
assert.equal(nudge.sweepCodexNudges(), 1);
|
|
lanes.deleteLane(lane.id);
|
|
});
|
|
});
|