fd7f57929c
A lane on the 16-node `ship-feature` template showed an untouched pipeline
map and never declared a stage. Four independent causes, fixed at their
shared choke points:
1. Codex emits no hooks, so `touchLaneFromHook` — the only caller of
`stage-detect.detect()`, and correctly pinned to `claude` — could never
see a `codex` lane. New `server/lib/codex-ingest.js` tails Codex's own
rollout journals (`$CODEX_HOME/sessions/**/rollout-*.jsonl`), maps its
tool calls onto the hook-shaped `{tool_name, tool_input}` the matcher
already understands, and feeds the existing detect path plus an
idle/running status mirror under the same guard hooks use. Polled every
`DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables).
2. `resolveLaneArg` matched on cwd alone, so with a claude and a codex lane
at one directory (the composite `idx_lanes_cwd_tool`) a `ccam stage`
call could drive the sibling lane's card. It now resolves by
`--lane` → `CCAM_LANE_ID` → `--tool`/harness marker → owning lane, and
errors naming both candidates rather than picking one. Every pane the
dashboard starts carries `CCAM_LANE_ID` (`tmux new-session -e`, or an
assignment prefix when typing into a live shell pane).
3. `recordDetection`'s declared-wins veto had no exit: a lane left at the
pipeline's last node rejected every detection for the rest of its life.
A declaration on the LAST node no longer vetoes — the pipeline is over
there, so a later detection is new work. Nothing else is relaxed, and
detection still never writes `stage` nor renders `done`.
4. The run console pre-filled the pipeline's Claude slash command into
codex panes, which Codex reads as prose — so the skill that would have
called `ccam stage` never ran. Claude lanes only now.
A codex lane's *declared* stage still depends on the session calling
`ccam stage`; `AGENTS.md` is the only lever CCAM has, so it now says so.
Verified: server 1345/1345, client 441/441, and end-to-end against a real
codex lane row (detected_stage/detected_signal set, declared stage
untouched, no node rendered done).
339 lines
11 KiB
JavaScript
339 lines
11 KiB
JavaScript
/**
|
|
* @file pty-run.test.js
|
|
* @description Unit tests for the tmux-backed run lifecycle. Injects a fake
|
|
* tmux exec implementation (via tmux.js's test seam) so no real tmux binary
|
|
* is invoked.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
const { describe, it, beforeEach, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("node:path");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pty-run-test-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
|
|
const tmux = require("../lib/tmux");
|
|
const pty = require("../lib/pty-run");
|
|
|
|
describe("pty-run", () => {
|
|
after(() => {
|
|
try {
|
|
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
});
|
|
|
|
beforeEach(() => {
|
|
tmux.__reset();
|
|
});
|
|
|
|
it("spawnRun creates a new tmux session named ccam-lane-<id> when none exists", () => {
|
|
const calls = [];
|
|
tmux.__setExecImpl((args) => {
|
|
calls.push(args);
|
|
if (args[0] === "has-session") {
|
|
const e = new Error("no such session");
|
|
e.status = 1;
|
|
throw e;
|
|
}
|
|
return "";
|
|
});
|
|
const handle = pty.spawnRun({ laneId: 42, cwd: "/tmp/repo", model: "opus" });
|
|
assert.equal(handle.id, "ccam-lane-42");
|
|
const newSessionCall = calls.find((c) => c[0] === "new-session");
|
|
assert.ok(newSessionCall, "expected a new-session call");
|
|
assert.deepEqual(newSessionCall.slice(0, 6), [
|
|
"new-session",
|
|
"-d",
|
|
"-s",
|
|
"ccam-lane-42",
|
|
"-c",
|
|
"/tmp/repo",
|
|
]);
|
|
assert.ok(newSessionCall.includes("claude"));
|
|
assert.ok(newSessionCall.includes("--model"));
|
|
assert.ok(newSessionCall.includes("opus"));
|
|
});
|
|
|
|
it("spawnRun is a no-op (adopts) when the existing session's pane runs claude", () => {
|
|
const calls = [];
|
|
tmux.__setExecImpl((args) => {
|
|
calls.push(args);
|
|
if (args[0] === "display-message") return "claude\n";
|
|
return ""; // has-session succeeds → already running
|
|
});
|
|
const handle = pty.spawnRun({ laneId: 7, cwd: "/tmp/repo" });
|
|
assert.equal(handle.id, "ccam-lane-7");
|
|
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
|
|
assert.ok(!calls.some((c) => c[0] === "send-keys"), "must not type over a live agent");
|
|
});
|
|
|
|
it("spawnRun types the argv into an existing session idling at a shell prompt", () => {
|
|
const calls = [];
|
|
tmux.__setExecImpl((args) => {
|
|
calls.push(args);
|
|
if (args[0] === "display-message") return "bash\n";
|
|
return ""; // has-session succeeds → session exists, pane is a shell
|
|
});
|
|
pty.spawnRun({ laneId: 8, cwd: "/tmp/repo", resumeSessionId: "abc12345" });
|
|
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
|
|
const literal = calls.find((c) => c[0] === "send-keys" && c[3] === "-l");
|
|
assert.ok(literal, "expected a literal send-keys with the command line");
|
|
assert.match(literal[4], /^CCAM_LANE_ID='8' 'claude' .*'--resume' 'abc12345'$/);
|
|
assert.ok(
|
|
calls.some((c) => c[0] === "send-keys" && c[3] === "Enter"),
|
|
"expected the command to be submitted"
|
|
);
|
|
});
|
|
|
|
it("spawnRun single-quotes an initial prompt typed into an existing shell pane", () => {
|
|
let literal = null;
|
|
tmux.__setExecImpl((args) => {
|
|
if (args[0] === "display-message") return "zsh\n";
|
|
if (args[0] === "send-keys" && args[3] === "-l") literal = args[4];
|
|
return "";
|
|
});
|
|
pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", initialPrompt: "don't; rm -rf /" });
|
|
assert.ok(literal.endsWith(`'don'\\''t; rm -rf /'`), literal);
|
|
});
|
|
|
|
it("spawnRun puts CCAM_LANE_ID in the new session's environment", () => {
|
|
let newSessionArgv = null;
|
|
tmux.__setExecImpl((args) => {
|
|
if (args[0] === "has-session") {
|
|
const e = new Error("gone");
|
|
e.status = 1;
|
|
throw e;
|
|
}
|
|
if (args[0] === "new-session") newSessionArgv = args;
|
|
return "";
|
|
});
|
|
pty.spawnRun({ laneId: 33, cwd: "/tmp/repo", tool: "codex" });
|
|
const at = newSessionArgv.indexOf("-e");
|
|
assert.ok(at > -1, "expected a tmux -e environment flag");
|
|
assert.equal(newSessionArgv[at + 1], "CCAM_LANE_ID=33");
|
|
// Must stay ahead of the `--` that starts the pane's own argv.
|
|
assert.ok(at < newSessionArgv.indexOf("--"));
|
|
});
|
|
|
|
it("spawnRun prefixes CCAM_LANE_ID when typing into an existing shell pane", () => {
|
|
let literal = null;
|
|
tmux.__setExecImpl((args) => {
|
|
if (args[0] === "display-message") return "bash\n";
|
|
if (args[0] === "send-keys" && args[3] === "-l") literal = args[4];
|
|
return "";
|
|
});
|
|
pty.spawnRun({ laneId: 12, cwd: "/tmp/repo", tool: "codex" });
|
|
// An assignment prefix on the same line: tmux's -e only applies at session
|
|
// creation, and a live shell takes its env from the command line.
|
|
assert.match(literal, /^CCAM_LANE_ID='12' 'codex'/);
|
|
});
|
|
|
|
it("spawnRun with resumeSessionId passes --resume in argv", () => {
|
|
let newSessionArgv = null;
|
|
tmux.__setExecImpl((args) => {
|
|
if (args[0] === "has-session") {
|
|
const e = new Error("gone");
|
|
e.status = 1;
|
|
throw e;
|
|
}
|
|
if (args[0] === "new-session") newSessionArgv = args;
|
|
return "";
|
|
});
|
|
pty.spawnRun({ laneId: 1, cwd: "/tmp/repo", resumeSessionId: "abc12345" });
|
|
assert.ok(newSessionArgv.includes("--resume"));
|
|
assert.ok(newSessionArgv.includes("abc12345"));
|
|
});
|
|
|
|
it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => {
|
|
let newSessionArgv = null;
|
|
tmux.__setExecImpl((args) => {
|
|
if (args[0] === "has-session") {
|
|
const e = new Error("gone");
|
|
e.status = 1;
|
|
throw e;
|
|
}
|
|
if (args[0] === "new-session") newSessionArgv = args;
|
|
return "";
|
|
});
|
|
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
|
|
assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions"));
|
|
// ...as an OPTION only: the starting mode stays the requested one, and
|
|
// `--dangerously-skip-permissions` (which would enable it) is never passed.
|
|
assert.ok(!newSessionArgv.includes("--dangerously-skip-permissions"));
|
|
const modeAt = newSessionArgv.indexOf("--permission-mode");
|
|
assert.equal(newSessionArgv[modeAt + 1], "acceptEdits");
|
|
});
|
|
|
|
it("spawnRun appends a positional initial prompt after argv flags", () => {
|
|
let newSessionArgv = null;
|
|
tmux.__setExecImpl((args) => {
|
|
if (args[0] === "has-session") {
|
|
const e = new Error("gone");
|
|
e.status = 1;
|
|
throw e;
|
|
}
|
|
if (args[0] === "new-session") newSessionArgv = args;
|
|
return "";
|
|
});
|
|
pty.spawnRun({ laneId: 3, cwd: "/tmp/repo", initialPrompt: "fix the bug" });
|
|
assert.equal(newSessionArgv[newSessionArgv.length - 1], "fix the bug");
|
|
});
|
|
|
|
it("killRun calls tmux kill-session with the run id", () => {
|
|
const calls = [];
|
|
tmux.__setExecImpl((args) => {
|
|
calls.push(args);
|
|
return "";
|
|
});
|
|
assert.equal(pty.killRun("ccam-lane-5"), true);
|
|
assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "ccam-lane-5"));
|
|
});
|
|
|
|
it("listRuns reflects live tmux-session state, not cached memory", () => {
|
|
tmux.__setExecImpl((args) => {
|
|
if (args[0] === "list-sessions") return "ccam-lane-1\nccam-lane-2\n";
|
|
return "";
|
|
});
|
|
const first = pty.listRuns();
|
|
assert.deepEqual(first.map((r) => r.id).sort(), ["ccam-lane-1", "ccam-lane-2"]);
|
|
|
|
// Session killed out-of-band (not through killRun) — next list() call
|
|
// must self-correct, proving state is computed, not stored.
|
|
tmux.__setExecImpl((args) => {
|
|
if (args[0] === "list-sessions") return "ccam-lane-1\n";
|
|
return "";
|
|
});
|
|
const second = pty.listRuns();
|
|
assert.deepEqual(
|
|
second.map((r) => r.id),
|
|
["ccam-lane-1"]
|
|
);
|
|
});
|
|
|
|
it("laneIdFromRunId parses the numeric lane id back out", () => {
|
|
assert.equal(pty.laneIdFromRunId("ccam-lane-42"), 42);
|
|
assert.equal(pty.laneIdFromRunId("not-a-run-id"), null);
|
|
});
|
|
|
|
it("getRun returns the recorded prompt for a live run", () => {
|
|
let sessionExists = false;
|
|
tmux.__setExecImpl((args) => {
|
|
if (args[0] === "has-session") {
|
|
if (sessionExists) {
|
|
return ""; // session exists
|
|
}
|
|
// Session doesn't exist yet
|
|
const e = new Error("no such session");
|
|
e.status = 1;
|
|
throw e;
|
|
}
|
|
if (args[0] === "new-session") {
|
|
sessionExists = true; // Mark session as created
|
|
}
|
|
return "";
|
|
});
|
|
const handle = pty.spawnRun({
|
|
laneId: 99,
|
|
cwd: "/tmp/test",
|
|
initialPrompt: "the live prompt",
|
|
});
|
|
const retrieved = pty.getRun(handle.id);
|
|
assert.equal(retrieved.id, "ccam-lane-99");
|
|
assert.equal(retrieved.promptPreview, "the live prompt");
|
|
assert.equal(retrieved.status, "running");
|
|
});
|
|
|
|
function captureNewSession(fn) {
|
|
const calls = [];
|
|
tmux.__setExecImpl((args) => {
|
|
calls.push(args);
|
|
if (args[0] === "has-session") {
|
|
const e = new Error("no such session");
|
|
e.status = 1;
|
|
throw e;
|
|
}
|
|
return "";
|
|
});
|
|
fn();
|
|
return calls.find((c) => c[0] === "new-session");
|
|
}
|
|
|
|
/** The pane's own command line — everything tmux passes after the `--`. */
|
|
function paneArgv(newSessionCall) {
|
|
return newSessionCall.slice(newSessionCall.indexOf("--") + 1);
|
|
}
|
|
|
|
it("spawnRun builds a codex argv for a codex lane", () => {
|
|
const call = captureNewSession(() =>
|
|
pty.spawnRun({
|
|
laneId: 7,
|
|
cwd: "/tmp/repo",
|
|
tool: "codex",
|
|
model: "gpt-5.1-codex",
|
|
sandbox: "workspace-write",
|
|
approval: "on-request",
|
|
effort: "high",
|
|
initialPrompt: "hello",
|
|
})
|
|
);
|
|
const argv = paneArgv(call);
|
|
assert.deepEqual(argv, [
|
|
"codex",
|
|
"-m",
|
|
"gpt-5.1-codex",
|
|
"-s",
|
|
"workspace-write",
|
|
"-a",
|
|
"on-request",
|
|
"-c",
|
|
"model_reasoning_effort=high",
|
|
"hello",
|
|
]);
|
|
});
|
|
|
|
it("spawnRun puts codex resume first, as a subcommand", () => {
|
|
const withId = captureNewSession(() =>
|
|
pty.spawnRun({
|
|
laneId: 8,
|
|
cwd: "/tmp/repo",
|
|
tool: "codex",
|
|
resumeSessionId: "0199aaaa-bbbb-cccc-dddd-eeeeffff0000",
|
|
})
|
|
);
|
|
assert.deepEqual(paneArgv(withId).slice(0, 3), [
|
|
"codex",
|
|
"resume",
|
|
"0199aaaa-bbbb-cccc-dddd-eeeeffff0000",
|
|
]);
|
|
|
|
tmux.__reset();
|
|
const last = captureNewSession(() =>
|
|
pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", tool: "codex", resumeLast: true })
|
|
);
|
|
assert.deepEqual(paneArgv(last).slice(0, 3), ["codex", "resume", "--last"]);
|
|
});
|
|
|
|
it("spawnRun rejects a codex sandbox/approval outside the allowlist", () => {
|
|
tmux.__setExecImpl(() => "");
|
|
assert.throws(
|
|
() => pty.spawnRun({ laneId: 10, cwd: "/tmp/repo", tool: "codex", sandbox: "rm -rf" }),
|
|
(err) => err.code === "EBADSANDBOX"
|
|
);
|
|
assert.throws(
|
|
() => pty.spawnRun({ laneId: 10, cwd: "/tmp/repo", tool: "codex", approval: "always" }),
|
|
(err) => err.code === "EBADAPPROVAL"
|
|
);
|
|
});
|
|
|
|
it("spawnRun never emits codex bypass flags", () => {
|
|
const call = captureNewSession(() =>
|
|
pty.spawnRun({ laneId: 11, cwd: "/tmp/repo", tool: "codex", sandbox: "danger-full-access" })
|
|
);
|
|
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
|
|
});
|
|
});
|