/** * @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 * `agenthub stage --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ĩ */ 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, `[agenthub] Stage "${lane.stage}" was inferred and has no evidence — run: agenthub 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, };