e109d2804c
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.
104 lines
3.8 KiB
JavaScript
104 lines
3.8 KiB
JavaScript
/**
|
|
* @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`, `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 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 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 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() {
|
|
const lanes = require("./lanes");
|
|
const tmux = require("./tmux");
|
|
const { SHELL_COMMANDS, runIdForLane } = require("./pty-run");
|
|
|
|
let codexLanes = [];
|
|
try {
|
|
codexLanes = lanes.listLanes().filter((l) => l.tool === "codex" && l.run_id);
|
|
} catch {
|
|
return 0;
|
|
}
|
|
|
|
let nudged = 0;
|
|
for (const lane of codexLanes) {
|
|
try {
|
|
// 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;
|
|
}
|
|
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;
|
|
// A bare shell prompt has no chat turn to submit into — pressing Enter
|
|
// there would run the reminder text as a shell command instead.
|
|
if (SHELL_COMMANDS.has(tmux.paneCommand(runId) || "")) continue;
|
|
|
|
tmux.sendText(
|
|
runId,
|
|
`[ccam] Stage "${lane.stage}" was inferred and has no evidence — run: ccam stage ${lane.stage} --tool codex --evidence "..."`
|
|
);
|
|
nudgedFor.set(lane.id, lane.stage);
|
|
nudged += 1;
|
|
} catch {
|
|
/* one lane's failure must not abort the sweep */
|
|
}
|
|
}
|
|
return nudged;
|
|
}
|
|
|
|
/** Unref'd so it never blocks shutdown; disable with DASHBOARD_CODEX_NUDGE_MS=0. */
|
|
function startCodexNudge() {
|
|
const POLL_MS = process.env.DASHBOARD_CODEX_NUDGE_MS
|
|
? Number(process.env.DASHBOARD_CODEX_NUDGE_MS)
|
|
: 60_000;
|
|
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
|
const timer = setInterval(() => {
|
|
try {
|
|
sweepCodexNudges();
|
|
} catch {
|
|
/* a sweep pass never throws into the event loop */
|
|
}
|
|
}, POLL_MS);
|
|
if (timer.unref) timer.unref();
|
|
}
|
|
|
|
function __resetNudgeState() {
|
|
nudgedFor.clear();
|
|
}
|
|
|
|
module.exports = {
|
|
sweepCodexNudges,
|
|
startCodexNudge,
|
|
__resetNudgeState,
|
|
};
|