feat(lanes): prime and nudge codex lanes into declaring their own stage
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.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @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ĩ <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.
|
||||
// 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,
|
||||
};
|
||||
@@ -633,6 +633,7 @@ function purgeLaneSessions(laneId) {
|
||||
module.exports = {
|
||||
DEAD_SEC,
|
||||
VALID_TOOLS,
|
||||
detectionIsStale,
|
||||
createLane,
|
||||
listLanes,
|
||||
getLane,
|
||||
|
||||
@@ -288,4 +288,5 @@ module.exports = {
|
||||
listRuns,
|
||||
laneIdFromRunId,
|
||||
runIdForLane,
|
||||
SHELL_COMMANDS,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* The one place a command line is composed is `sendCommand`, which types into
|
||||
* an existing pane's shell: there the shell IS the consumer, so every argument
|
||||
* is POSIX single-quoted first and sent with `send-keys -l` (literal).
|
||||
* `sendText` is the same primitive for the opposite case — a pane running an
|
||||
* interactive chat program that reads stdin as a message, not a shell.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -104,6 +106,20 @@ function sendCommand(name, argv, env) {
|
||||
execImpl(["send-keys", "-t", name, "Enter"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type a literal line of plain text into an existing pane and press Enter —
|
||||
* for a program that reads stdin as a message rather than a shell command
|
||||
* (an interactive `claude`/`codex` chat pane), where `sendCommand`'s POSIX
|
||||
* quoting and `KEY=value ` env prefix would show up as literal punctuation in
|
||||
* the message instead of being interpreted. `text` must be single-line: `-l`
|
||||
* sends it as one literal keystroke burst, so an embedded newline would submit
|
||||
* only the first line and leave the rest sitting unsent in the input box.
|
||||
*/
|
||||
function sendText(name, text) {
|
||||
execImpl(["send-keys", "-t", name, "-l", text]);
|
||||
execImpl(["send-keys", "-t", name, "Enter"]);
|
||||
}
|
||||
|
||||
/** Idempotent — a session that's already gone is not an error. */
|
||||
function killSession(name) {
|
||||
try {
|
||||
@@ -142,6 +158,7 @@ module.exports = {
|
||||
enableMouse,
|
||||
paneCommand,
|
||||
sendCommand,
|
||||
sendText,
|
||||
killSession,
|
||||
listSessions,
|
||||
isTmuxAvailable,
|
||||
|
||||
Reference in New Issue
Block a user