/** * @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. * * 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 * (`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. * @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 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. // 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. * @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 { if (!lane.detected_stage || lane.detected_stage === lane.stage) { 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 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] You appear to have moved on to "${lane.detected_stage}" — run: ccam stage ${lane.detected_stage} --tool codex --evidence "..."` ); nudgedFor.set(lane.id, lane.detected_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, };