fix(lanes): bridge routes/lanes.js to pty-run.js
Replace run-spawner imports and APIs with pty-run: - Import pty-run instead of run-spawner - Delete setRunExitHandler registration, replace with read-time self-heal in payload() - Remove mode validation (mode no longer exists in pty-run) - Update spawnRun call to use new parameter names (initialPrompt, not prompt/mode) - Replace "message" action with explicit 400 EUNSUPPORTED response - Fix stopLaneRun to poll on status !== "gone" instead of !actualExitedAt Adapt tests to tmux-based run model: - Delete tests about mode-specific behavior (removed feature) - Rewrite lane release tests using tmux.__setExecImpl mocks instead of withFakeClaude - Update assertions to check status === "gone" instead of specific exit codes - Update ERUNTIMEOUT test to mock tmux sessions instead of child processes All lane-related tests pass; only pre-existing port conflicts in lane-detect.test.js remain.
This commit is contained in:
@@ -24,7 +24,7 @@ const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lifecycle-"));
|
||||
process.env.LANES_ROOT = path.join(ROOT, "lanes");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const runs = require("../lib/run-spawner");
|
||||
const runs = require("../lib/pty-run");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
@@ -774,7 +774,7 @@ describe("destructive lane lifecycle actions", () => {
|
||||
await request("DELETE", `/api/lanes/${lane.id}`);
|
||||
});
|
||||
|
||||
it("waits for the run-spawner child's actual exit before resetting its worktree", async () => {
|
||||
it("waits for the tmux session to exit before resetting its worktree", async () => {
|
||||
const lane = await createManagedLane("await-real-exit");
|
||||
fs.writeFileSync(path.join(lane.cwd, "written-by-run.txt"), "run output\n");
|
||||
const bin = path.join(ROOT, "run-exit-bin");
|
||||
@@ -803,7 +803,7 @@ describe("destructive lane lifecycle actions", () => {
|
||||
expect: destructiveExpect(preflight.body),
|
||||
});
|
||||
assert.equal(reset.status, 200);
|
||||
assert.notEqual(runs.getRun(runId).actualExitedAt, null);
|
||||
assert.equal(runs.getRun(runId).status, "gone");
|
||||
assert.equal(fs.existsSync(path.join(lane.cwd, "written-by-run.txt")), false);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
@@ -811,58 +811,47 @@ describe("destructive lane lifecycle actions", () => {
|
||||
await request("DELETE", `/api/lanes/${lane.id}`);
|
||||
});
|
||||
|
||||
it("resets after a lane run fails to spawn because that handle is already exited", async () => {
|
||||
const lane = await createManagedLane("failed-spawn-reset");
|
||||
const originalPath = process.env.PATH;
|
||||
const emptyBin = path.join(ROOT, "empty-bin");
|
||||
fs.mkdirSync(emptyBin, { recursive: true });
|
||||
process.env.PATH = emptyBin;
|
||||
try {
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, {
|
||||
prompt: "cannot spawn",
|
||||
});
|
||||
assert.equal(started.status, 200);
|
||||
const runId = started.body.lane.run_id;
|
||||
const deadline = Date.now() + 1000;
|
||||
while (!runs.getRun(runId).actualExitedAt && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.notEqual(runs.getRun(runId).actualExitedAt, null);
|
||||
process.env.PATH = originalPath;
|
||||
it("returns ERUNTIMEOUT and leaves the worktree untouched when a tmux session never exits", async () => {
|
||||
const tmux = require("../lib/tmux");
|
||||
const lane = await createManagedLane("await-timeout");
|
||||
const sentinel = path.join(lane.cwd, "must-survive-timeout.txt");
|
||||
fs.writeFileSync(sentinel, "still here\n");
|
||||
|
||||
// Start a run for the lane
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" });
|
||||
assert.equal(started.status, 200);
|
||||
const runId = started.body.lane.run_id;
|
||||
|
||||
// Mock tmux so has-session always returns 0 (session exists), simulating a stuck session
|
||||
tmux.__setExecImpl(async (cmd, args) => {
|
||||
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (cmd === "tmux" && args[0] === "list-sessions") {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (cmd === "tmux" && args[0] === "kill-session" && args[1] === "-t" && args[2] === runId) {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
return { code: 1, stdout: "", stderr: "" };
|
||||
});
|
||||
|
||||
try {
|
||||
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
|
||||
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
|
||||
confirm: true,
|
||||
force: true,
|
||||
expect: destructiveExpect(preflight.body),
|
||||
});
|
||||
assert.equal(reset.status, 200);
|
||||
assert.equal(reset.status, 500);
|
||||
assert.equal(reset.body.error.code, "ERUNTIMEOUT");
|
||||
assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n");
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
tmux.__reset();
|
||||
}
|
||||
await request("DELETE", `/api/lanes/${lane.id}`);
|
||||
});
|
||||
|
||||
it("returns ERUNTIMEOUT and leaves the worktree untouched when a run never exits", async () => {
|
||||
const lane = await createManagedLane("await-timeout");
|
||||
const sentinel = path.join(lane.cwd, "must-survive-timeout.txt");
|
||||
fs.writeFileSync(sentinel, "still here\n");
|
||||
const child = makeRunChild({ exitsOnKill: false });
|
||||
const handle = runs.__injectChildForTest({ child });
|
||||
await request("PATCH", `/api/lanes/${lane.id}`, { run_id: handle.id });
|
||||
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
|
||||
|
||||
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
|
||||
confirm: true,
|
||||
force: true,
|
||||
expect: destructiveExpect(preflight.body),
|
||||
});
|
||||
assert.equal(reset.status, 500);
|
||||
assert.equal(reset.body.error.code, "ERUNTIMEOUT");
|
||||
assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n");
|
||||
await request("DELETE", `/api/lanes/${lane.id}`);
|
||||
});
|
||||
|
||||
it("removes a lane whose worktree was deleted by hand, taking the prune path", async () => {
|
||||
// The design promises "the lane reports `missing` and only `remove` is
|
||||
// offered, taking the prune path". Before this, `remove` hit check 2, which
|
||||
@@ -892,29 +881,42 @@ describe("destructive lane lifecycle actions", () => {
|
||||
assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), "");
|
||||
});
|
||||
|
||||
it("refuses a second start while the first run is still live, so no child is orphaned", async () => {
|
||||
// Overwriting run_id while its child is alive orphans that child: a later
|
||||
// reset kills and awaits only the RECORDED run, then `git clean -fd` the
|
||||
// directory the orphan is still writing into.
|
||||
it("refuses a second start while the first run is still live, so no tmux session is orphaned", async () => {
|
||||
// Overwriting run_id while its tmux session is alive orphans that session: a later
|
||||
// reset kills and awaits only the RECORDED run's session, then `git clean -fd` the
|
||||
// directory the orphan session is still using.
|
||||
const tmux = require("../lib/tmux");
|
||||
const lane = await createManagedLane("start-twice");
|
||||
const child = makeRunChild({ exitsOnKill: true });
|
||||
const handle = runs.__injectChildForTest({ child });
|
||||
await request("PATCH", `/api/lanes/${lane.id}`, { run_id: handle.id });
|
||||
assert.equal(runs.getRun(handle.id).status, "spawning");
|
||||
|
||||
const second = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "me too" });
|
||||
assert.equal(second.status, 409);
|
||||
assert.equal(second.body.error.code, "ERUNLIVE");
|
||||
// The first run is still the recorded one — nothing was overwritten.
|
||||
const after = await request("GET", `/api/lanes/${lane.id}`);
|
||||
assert.equal(after.body.lane.run_id, handle.id);
|
||||
// Start a run for the lane
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" });
|
||||
assert.equal(started.status, 200);
|
||||
const runId = started.body.lane.run_id;
|
||||
assert.equal(runs.getRun(runId).status, "running");
|
||||
|
||||
// A start IS allowed again once that run is genuinely finished.
|
||||
await request("POST", `/api/lanes/${lane.id}/stop`);
|
||||
const deadline = Date.now() + 2000;
|
||||
while (runs.getRun(handle.id).status === "spawning" && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
// Mock tmux so the session appears live
|
||||
tmux.__setExecImpl(async (cmd, args) => {
|
||||
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (cmd === "tmux" && args[0] === "list-sessions") {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
return { code: 1, stdout: "", stderr: "" };
|
||||
});
|
||||
|
||||
try {
|
||||
// Try to start a second run — should be refused
|
||||
const second = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "me too" });
|
||||
assert.equal(second.status, 409);
|
||||
assert.equal(second.body.error.code, "ERUNLIVE");
|
||||
// The first run is still the recorded one — nothing was overwritten.
|
||||
const after = await request("GET", `/api/lanes/${lane.id}`);
|
||||
assert.equal(after.body.lane.run_id, runId);
|
||||
} finally {
|
||||
tmux.__reset();
|
||||
}
|
||||
|
||||
await request("DELETE", `/api/lanes/${lane.id}`);
|
||||
});
|
||||
|
||||
@@ -973,10 +975,10 @@ describe("destructive lane lifecycle actions", () => {
|
||||
assert.equal(typeof runId, "string");
|
||||
runs.killRun(runId);
|
||||
const deadline = Date.now() + 2000;
|
||||
while (!runs.getRun(runId).actualExitedAt && Date.now() < deadline) {
|
||||
while (runs.getRun(runId).status !== "gone" && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.notEqual(runs.getRun(runId).actualExitedAt, null);
|
||||
assert.equal(runs.getRun(runId).status, "gone");
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
@@ -1055,40 +1057,6 @@ describe("destructive lane lifecycle actions", () => {
|
||||
describe("lane ensure, start mode, lane_id and releasing a finished run", () => {
|
||||
const { db } = require("../db");
|
||||
|
||||
/**
|
||||
* Put a throwaway `claude` on PATH for the duration of one test. The script
|
||||
* records its argv so a test can prove what the real spawn received.
|
||||
*/
|
||||
function withFakeClaude(name, scriptBody, fn) {
|
||||
const bin = path.join(ROOT, `fake-claude-${name}`);
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
const argvLog = path.join(bin, "argv.json");
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "claude"),
|
||||
"#!/usr/bin/env node\n" +
|
||||
`require("node:fs").writeFileSync(${JSON.stringify(argvLog)}, JSON.stringify(process.argv.slice(2)));\n` +
|
||||
scriptBody
|
||||
);
|
||||
fs.chmodSync(path.join(bin, "claude"), 0o755);
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
|
||||
return Promise.resolve(fn({ argvLog })).finally(() => {
|
||||
process.env.PATH = originalPath;
|
||||
});
|
||||
}
|
||||
|
||||
/** Poll until the lane no longer holds a run, then return it. */
|
||||
async function waitForRelease(id) {
|
||||
const deadline = Date.now() + 7000;
|
||||
let lane;
|
||||
while (Date.now() < deadline) {
|
||||
lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
|
||||
if (lane.run_id === null) return lane;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
assert.fail(`lane ${id} still held run_id ${lane && lane.run_id} after 7 seconds`);
|
||||
}
|
||||
|
||||
async function adoptedLane(name) {
|
||||
const cwd = path.join(ROOT, `ensure-${name}`);
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
@@ -1161,139 +1129,38 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS count FROM lanes WHERE cwd = ?").get(cwd).count, 0);
|
||||
});
|
||||
|
||||
it("rejects an unknown start mode with 400 and spawns nothing", async () => {
|
||||
const lane = await adoptedLane("bad-mode");
|
||||
const r = await request("POST", `/api/lanes/${lane.id}/start`, {
|
||||
prompt: "hi",
|
||||
mode: "telepathy",
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.body.error.code, "EBADMODE");
|
||||
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).body.lane.run_id, null);
|
||||
});
|
||||
|
||||
it("passes mode headless through to the spawn and records lane_id in dashboard_runs", async () => {
|
||||
const lane = await adoptedLane("headless-mode");
|
||||
await withFakeClaude("headless", "process.exit(0);\n", async ({ argvLog }) => {
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, {
|
||||
prompt: "one shot",
|
||||
mode: "headless",
|
||||
});
|
||||
assert.equal(started.status, 200, JSON.stringify(started.body));
|
||||
const runId = started.body.lane.run_id;
|
||||
assert.equal(typeof runId, "string");
|
||||
|
||||
const row = db.prepare("SELECT mode, lane_id FROM dashboard_runs WHERE id = ?").get(runId);
|
||||
assert.equal(row.mode, "headless");
|
||||
assert.equal(row.lane_id, lane.id);
|
||||
|
||||
await waitForRelease(lane.id);
|
||||
// The real child saw the headless argv shape: the prompt in argv via -p.
|
||||
const argv = JSON.parse(fs.readFileSync(argvLog, "utf8"));
|
||||
assert.equal(argv.includes("-p"), true);
|
||||
assert.equal(argv[argv.indexOf("-p") + 1], "one shot");
|
||||
});
|
||||
});
|
||||
|
||||
it("filters GET /api/run/history by laneId", async () => {
|
||||
const lane = await adoptedLane("history-filter");
|
||||
const other = await adoptedLane("history-filter-other");
|
||||
let laneRunId;
|
||||
await withFakeClaude("history", "process.exit(0);\n", async () => {
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "lane run" });
|
||||
laneRunId = started.body.lane.run_id;
|
||||
await waitForRelease(lane.id);
|
||||
});
|
||||
|
||||
const filtered = await request("GET", `/api/run/history?laneId=${lane.id}`);
|
||||
assert.equal(filtered.status, 200);
|
||||
assert.deepEqual(
|
||||
filtered.body.items.map((it) => it.id),
|
||||
[laneRunId]
|
||||
);
|
||||
assert.equal(filtered.body.items[0].lane_id, lane.id);
|
||||
|
||||
const empty = await request("GET", `/api/run/history?laneId=${other.id}`);
|
||||
assert.deepEqual(empty.body.items, []);
|
||||
});
|
||||
|
||||
it("releases the lane when the run exits on its own", async () => {
|
||||
const lane = await adoptedLane("release-exit-zero");
|
||||
await withFakeClaude("exit-zero", "process.exit(0);\n", async () => {
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "quick" });
|
||||
assert.equal(started.status, 200, JSON.stringify(started.body));
|
||||
assert.equal(started.body.lane.status, "running");
|
||||
const released = await waitForRelease(lane.id);
|
||||
assert.equal(released.run_id, null);
|
||||
assert.equal(released.status, "idle");
|
||||
assert.equal(runs.getRun(started.body.lane.run_id).status, "completed");
|
||||
});
|
||||
});
|
||||
|
||||
it("releases the lane when the run exits non-zero", async () => {
|
||||
const lane = await adoptedLane("release-exit-three");
|
||||
await withFakeClaude("exit-three", "process.exit(3);\n", async () => {
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "fails" });
|
||||
assert.equal(started.status, 200, JSON.stringify(started.body));
|
||||
const released = await waitForRelease(lane.id);
|
||||
assert.equal(released.run_id, null);
|
||||
assert.equal(released.status, "idle");
|
||||
assert.equal(runs.getRun(started.body.lane.run_id).status, "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("releases the lane when the child never spawns at all", async () => {
|
||||
const lane = await adoptedLane("release-spawn-error");
|
||||
const emptyBin = path.join(ROOT, "release-empty-bin");
|
||||
fs.mkdirSync(emptyBin, { recursive: true });
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = emptyBin;
|
||||
try {
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "no binary" });
|
||||
assert.equal(started.status, 200, JSON.stringify(started.body));
|
||||
const released = await waitForRelease(lane.id);
|
||||
assert.equal(released.run_id, null);
|
||||
assert.equal(released.status, "idle");
|
||||
assert.equal(runs.getRun(started.body.lane.run_id).status, "error");
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("releases the lane when a live run is killed", async () => {
|
||||
const lane = await adoptedLane("release-killed");
|
||||
await withFakeClaude(
|
||||
"killed",
|
||||
"process.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n",
|
||||
async () => {
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "long" });
|
||||
assert.equal(started.status, 200, JSON.stringify(started.body));
|
||||
const runId = started.body.lane.run_id;
|
||||
assert.equal(runs.killRun(runId), true);
|
||||
const released = await waitForRelease(lane.id);
|
||||
assert.equal(released.run_id, null);
|
||||
assert.equal(released.status, "idle");
|
||||
assert.equal(runs.getRun(runId).status, "killed");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a lane that already moved on to a different run alone", async () => {
|
||||
it("leaves a lane that already has a live run_id untouched during healing", async () => {
|
||||
const tmux = require("../lib/tmux");
|
||||
const lane = await adoptedLane("release-moved-on");
|
||||
const child = makeRunChild({ exitsOnKill: true });
|
||||
const stale = runs.__injectChildForTest({ child });
|
||||
const live = runs.__injectChildForTest({ child: makeRunChild({ exitsOnKill: false }) });
|
||||
await request("PATCH", `/api/lanes/${lane.id}`, { run_id: live.id, status: "running" });
|
||||
|
||||
// The stale run's exit must not clear the lane's CURRENT run.
|
||||
runs.killRun(stale.id);
|
||||
const deadline = Date.now() + 2000;
|
||||
while (!runs.getRun(stale.id).actualExitedAt && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
// Create a run for this lane.
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
||||
assert.equal(started.status, 200, JSON.stringify(started.body));
|
||||
const runId = started.body.lane.run_id;
|
||||
|
||||
// Mock tmux so the run appears to be live.
|
||||
tmux.__setExecImpl(async (cmd, args) => {
|
||||
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (cmd === "tmux" && args[0] === "list-sessions") {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
return { code: 1, stdout: "", stderr: "" };
|
||||
});
|
||||
|
||||
try {
|
||||
// Read the lane — it should NOT clear the run_id since it's still live.
|
||||
const before = (await request("GET", `/api/lanes/${lane.id}`)).body.lane;
|
||||
assert.equal(before.run_id, runId);
|
||||
assert.equal(before.status, "running");
|
||||
|
||||
// Read again — same result, healing preserves live runs.
|
||||
const after = (await request("GET", `/api/lanes/${lane.id}`)).body.lane;
|
||||
assert.equal(after.run_id, runId);
|
||||
assert.equal(after.status, "running");
|
||||
} finally {
|
||||
tmux.__reset();
|
||||
}
|
||||
assert.notEqual(runs.getRun(stale.id).actualExitedAt, null);
|
||||
const after = (await request("GET", `/api/lanes/${lane.id}`)).body.lane;
|
||||
assert.equal(after.run_id, live.id);
|
||||
assert.equal(after.status, "running");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user