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:
2026-08-12 10:53:23 +07:00
parent 24f13911fe
commit 82bf803c2e
3 changed files with 134 additions and 287 deletions
+87 -220
View File
@@ -24,7 +24,7 @@ const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lifecycle-"));
process.env.LANES_ROOT = path.join(ROOT, "lanes"); process.env.LANES_ROOT = path.join(ROOT, "lanes");
const { createApp, startServer } = require("../index"); const { createApp, startServer } = require("../index");
const runs = require("../lib/run-spawner"); const runs = require("../lib/pty-run");
let server; let server;
let BASE; let BASE;
@@ -774,7 +774,7 @@ describe("destructive lane lifecycle actions", () => {
await request("DELETE", `/api/lanes/${lane.id}`); 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"); const lane = await createManagedLane("await-real-exit");
fs.writeFileSync(path.join(lane.cwd, "written-by-run.txt"), "run output\n"); fs.writeFileSync(path.join(lane.cwd, "written-by-run.txt"), "run output\n");
const bin = path.join(ROOT, "run-exit-bin"); const bin = path.join(ROOT, "run-exit-bin");
@@ -803,7 +803,7 @@ describe("destructive lane lifecycle actions", () => {
expect: destructiveExpect(preflight.body), expect: destructiveExpect(preflight.body),
}); });
assert.equal(reset.status, 200); 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); assert.equal(fs.existsSync(path.join(lane.cwd, "written-by-run.txt")), false);
} finally { } finally {
process.env.PATH = originalPath; process.env.PATH = originalPath;
@@ -811,47 +811,33 @@ describe("destructive lane lifecycle actions", () => {
await request("DELETE", `/api/lanes/${lane.id}`); await request("DELETE", `/api/lanes/${lane.id}`);
}); });
it("resets after a lane run fails to spawn because that handle is already exited", async () => { it("returns ERUNTIMEOUT and leaves the worktree untouched when a tmux session never exits", async () => {
const lane = await createManagedLane("failed-spawn-reset"); const tmux = require("../lib/tmux");
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;
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);
} finally {
process.env.PATH = originalPath;
}
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 lane = await createManagedLane("await-timeout");
const sentinel = path.join(lane.cwd, "must-survive-timeout.txt"); const sentinel = path.join(lane.cwd, "must-survive-timeout.txt");
fs.writeFileSync(sentinel, "still here\n"); 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`);
// 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`, { const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
confirm: true, confirm: true,
force: true, force: true,
@@ -860,6 +846,9 @@ describe("destructive lane lifecycle actions", () => {
assert.equal(reset.status, 500); assert.equal(reset.status, 500);
assert.equal(reset.body.error.code, "ERUNTIMEOUT"); assert.equal(reset.body.error.code, "ERUNTIMEOUT");
assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n"); assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n");
} finally {
tmux.__reset();
}
await request("DELETE", `/api/lanes/${lane.id}`); await request("DELETE", `/api/lanes/${lane.id}`);
}); });
@@ -892,29 +881,42 @@ describe("destructive lane lifecycle actions", () => {
assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), ""); 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 () => { it("refuses a second start while the first run is still live, so no tmux session is orphaned", async () => {
// Overwriting run_id while its child is alive orphans that child: a later // Overwriting run_id while its tmux session is alive orphans that session: a later
// reset kills and awaits only the RECORDED run, then `git clean -fd` the // reset kills and awaits only the RECORDED run's session, then `git clean -fd` the
// directory the orphan is still writing into. // directory the orphan session is still using.
const tmux = require("../lib/tmux");
const lane = await createManagedLane("start-twice"); 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");
// 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");
// 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" }); const second = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "me too" });
assert.equal(second.status, 409); assert.equal(second.status, 409);
assert.equal(second.body.error.code, "ERUNLIVE"); assert.equal(second.body.error.code, "ERUNLIVE");
// The first run is still the recorded one — nothing was overwritten. // The first run is still the recorded one — nothing was overwritten.
const after = await request("GET", `/api/lanes/${lane.id}`); const after = await request("GET", `/api/lanes/${lane.id}`);
assert.equal(after.body.lane.run_id, handle.id); assert.equal(after.body.lane.run_id, runId);
} finally {
// A start IS allowed again once that run is genuinely finished. tmux.__reset();
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));
} }
await request("DELETE", `/api/lanes/${lane.id}`); await request("DELETE", `/api/lanes/${lane.id}`);
}); });
@@ -973,10 +975,10 @@ describe("destructive lane lifecycle actions", () => {
assert.equal(typeof runId, "string"); assert.equal(typeof runId, "string");
runs.killRun(runId); runs.killRun(runId);
const deadline = Date.now() + 2000; 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)); await new Promise((resolve) => setTimeout(resolve, 10));
} }
assert.notEqual(runs.getRun(runId).actualExitedAt, null); assert.equal(runs.getRun(runId).status, "gone");
} finally { } finally {
process.env.PATH = originalPath; 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", () => { describe("lane ensure, start mode, lane_id and releasing a finished run", () => {
const { db } = require("../db"); 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) { async function adoptedLane(name) {
const cwd = path.join(ROOT, `ensure-${name}`); const cwd = path.join(ROOT, `ensure-${name}`);
fs.mkdirSync(cwd, { recursive: true }); 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); 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 () => { it("leaves a lane that already has a live run_id untouched during healing", async () => {
const lane = await adoptedLane("bad-mode"); const tmux = require("../lib/tmux");
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 () => {
const lane = await adoptedLane("release-moved-on"); 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. // Create a run for this lane.
runs.killRun(stale.id); const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
const deadline = Date.now() + 2000; assert.equal(started.status, 200, JSON.stringify(started.body));
while (!runs.getRun(stale.id).actualExitedAt && Date.now() < deadline) { const runId = started.body.lane.run_id;
await new Promise((resolve) => setTimeout(resolve, 10));
// 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: "" };
} }
assert.notEqual(runs.getRun(stale.id).actualExitedAt, null); 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; const after = (await request("GET", `/api/lanes/${lane.id}`)).body.lane;
assert.equal(after.run_id, live.id); assert.equal(after.run_id, runId);
assert.equal(after.status, "running"); assert.equal(after.status, "running");
} finally {
tmux.__reset();
}
}); });
}); });
+3 -5
View File
@@ -645,14 +645,12 @@ describe("lane actions", () => {
await request("DELETE", `/api/lanes/${c.body.lane.id}`); await request("DELETE", `/api/lanes/${c.body.lane.id}`);
}); });
it("message on a lane with a recorded-but-not-live run returns 409", async () => { it("message on a lane is no longer supported via REST", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-e" }); const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-e" });
const id = c.body.lane.id; const id = c.body.lane.id;
// Patch the lane with a bogus run_id (never existed, so not live).
await request("PATCH", `/api/lanes/${id}`, { run_id: "nonexistent-run" });
const r = await request("POST", `/api/lanes/${id}/message`, { text: "hello" }); const r = await request("POST", `/api/lanes/${id}/message`, { text: "hello" });
assert.equal(r.status, 409); assert.equal(r.status, 400);
assert.equal(r.body.error.code, "ENORUN"); assert.equal(r.body.error.code, "EUNSUPPORTED");
await request("DELETE", `/api/lanes/${id}`); await request("DELETE", `/api/lanes/${id}`);
}); });
}); });
+35 -53
View File
@@ -18,7 +18,7 @@ const { listPipelines, getPipeline, nodeStates, progressPct } = require("../lib/
const laneFeatures = require("../lib/lane-features"); const laneFeatures = require("../lib/lane-features");
const proofLib = require("../lib/proof"); const proofLib = require("../lib/proof");
const { broadcast } = require("../websocket"); const { broadcast } = require("../websocket");
const runs = require("../lib/run-spawner"); const runs = require("../lib/pty-run");
const { sameOriginGuard } = require("./run"); const { sameOriginGuard } = require("./run");
const { preflight } = require("../lib/lane-preflight"); const { preflight } = require("../lib/lane-preflight");
const { const {
@@ -69,8 +69,28 @@ function lastEventAge(lane) {
return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000)); return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000));
} }
/**
* Self-heals a stale `run_id`: a tmux-backed run has no exit event to push a
* release notification, so liveness is re-checked here, on every read,
* instead — the same "computed fact, never stored" principle this repo
* already applies to lane runtime up/down. A lane whose run_id points at a
* tmux session that's gone (the pane's process exited, or it was killed
* outside the dashboard entirely) gets released the next time anything reads
* it, exactly like the old push-based handler did, just pulled instead of
* pushed.
*/
function healRunId(lane) {
if (!lane.run_id) return lane;
const run = runs.getRun(lane.run_id);
if (run && run.status === "running") return lane;
lanesLib.updateLane(lane.id, { run_id: null, status: "idle" });
broadcastLane(lane.id);
return lanesLib.getLane(lane.id);
}
function payload(lane) { function payload(lane) {
return lanesLib.lanePayload(lane, lastEventAge(lane)); const healed = healRunId(lane);
return lanesLib.lanePayload(healed, lastEventAge(healed));
} }
/** A feature row's pipeline view, computed the same way payload() computes /** A feature row's pipeline view, computed the same way payload() computes
@@ -91,23 +111,6 @@ function broadcastLane(id) {
if (lane) broadcast("lane_update", { lane: payload(lane) }); if (lane) broadcast("lane_update", { lane: payload(lane) });
} }
/**
* Release the lane holding a run that has just finished. Registered as a
* callback because the spawner must not require this router back: it is
* already required FROM here, and broadcastLane needs this file's payload().
*
* No lane lock: the read, the guard and the write are one synchronous
* better-sqlite3 sequence with no `await` between them, so nothing can
* interleave. Matching run_id is what keeps a lane that has already moved on to
* a different run untouched.
*/
runs.setRunExitHandler(({ runId }) => {
const lane = lanesLib.listLanes().find((l) => l.run_id === runId);
if (!lane) return;
lanesLib.updateLane(lane.id, { run_id: null, status: "idle" });
broadcastLane(lane.id);
});
router.get("/", (_req, res) => { router.get("/", (_req, res) => {
const lanes = lanesLib.listLanes().map(payload); const lanes = lanesLib.listLanes().map(payload);
res.json({ res.json({
@@ -620,8 +623,6 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
}); });
const ACTIONS = new Set(["start", "stop", "message", "clear", "reset", "remove", "purge"]); const ACTIONS = new Set(["start", "stop", "message", "clear", "reset", "remove", "purge"]);
// The modes the spawner accepts, same as POST /api/run.
const RUN_MODES = new Set(["headless", "conversation"]);
const DESTRUCTIVE_ACTIONS = new Set(["reset", "remove", "purge"]); const DESTRUCTIVE_ACTIONS = new Set(["reset", "remove", "purge"]);
const RUN_EXIT_POLL_MS = 50; const RUN_EXIT_POLL_MS = 50;
// killRun escalates from SIGTERM to SIGKILL after five seconds. Leave enough // killRun escalates from SIGTERM to SIGKILL after five seconds. Leave enough
@@ -665,7 +666,7 @@ function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
/** Kill a lane run and wait for the child's real `exit` event before touching its cwd. */ /** Kill a lane run and wait for the tmux session to exit before touching its cwd. */
async function stopLaneRun(lane) { async function stopLaneRun(lane) {
if (!lane.run_id) return; if (!lane.run_id) return;
try { try {
@@ -676,7 +677,7 @@ async function stopLaneRun(lane) {
const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS; const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS;
let run = runs.getRun(lane.run_id); let run = runs.getRun(lane.run_id);
while (run && !run.actualExitedAt) { while (run && run.status !== "gone") {
if (Date.now() >= deadline) { if (Date.now() >= deadline) {
throw lifecycleError( throw lifecycleError(
"ERUNTIMEOUT", "ERUNTIMEOUT",
@@ -975,7 +976,7 @@ router.post("/:id/sync-base", sameOriginGuard, async (req, res) => {
/** /**
* Lane control. Deliberately thin: every action maps onto one existing * Lane control. Deliberately thin: every action maps onto one existing
* run-spawner call. There is no queue, no chaining, no gate evaluation — the * lifecycle function. There is no queue, no chaining, no gate evaluation — the
* dashboard drives a lane, it does not orchestrate a pipeline. * dashboard drives a lane, it does not orchestrate a pipeline.
*/ */
router.post("/:id/:action", sameOriginGuard, async (req, res) => { router.post("/:id/:action", sameOriginGuard, async (req, res) => {
@@ -1079,17 +1080,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
try { try {
switch (action) { switch (action) {
case "start": { case "start": {
// Same two modes POST /api/run accepts. Unlike that route, an unknown
// value is refused rather than silently coerced to a conversation.
if (body.mode != null && !RUN_MODES.has(body.mode)) {
return res.status(400).json({
error: { code: "EBADMODE", message: `mode must be one of: headless, conversation` },
});
}
// Overwriting run_id while its child is alive orphans that child: a later // Overwriting run_id while its child is alive orphans that child: a later
// reset would kill and await only the RECORDED run, then `git clean -fd` // reset would kill and await only the RECORDED run, then `git clean -fd`
// the directory the orphan is still writing into — the exact hazard // the directory the orphan is still writing into. Stop the first run before starting a
// actualExitedAt exists to close. Stop the first run before starting a
// second. The check and the spawn happen under the per-lane lock so that // second. The check and the spawn happen under the per-lane lock so that
// atomicity is guaranteed rather than an accident of this code having no // atomicity is guaranteed rather than an accident of this code having no
// `await` between them — a future edit that adds one must not reopen the // `await` between them — a future edit that adds one must not reopen the
@@ -1101,13 +1094,12 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
// spawning a run for a lane that no longer exists. // spawning a run for a lane that no longer exists.
if (!current) return { missing: true }; if (!current) return { missing: true };
const live = current.run_id ? runs.getRun(current.run_id) : null; const live = current.run_id ? runs.getRun(current.run_id) : null;
if (live && (live.status === "spawning" || live.status === "running")) { if (live && live.status === "running") {
return { conflict: true }; return { conflict: true };
} }
const handle = runs.spawnRun({ const handle = runs.spawnRun({
mode: body.mode || "conversation",
laneId: current.id, laneId: current.id,
prompt: body.prompt || "", initialPrompt: body.prompt || "",
cwd: current.cwd, cwd: current.cwd,
model: body.model, model: body.model,
permissionMode: body.permissionMode, permissionMode: body.permissionMode,
@@ -1140,23 +1132,13 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
break; break;
} }
case "message": { case "message": {
if (!lane.run_id) { return res.status(400).json({
return res error: {
.status(409) code: "EUNSUPPORTED",
.json({ error: { code: "ENORUN", message: "lane has no live run" } }); message:
} "sending input to a lane's run is no longer supported via REST — open the lane's terminal in Workspace and type directly (attaches over WebSocket to the same tmux session)",
// Check that the recorded run is actually live (spawning or running). },
// If a run finished recently, its run_id is still recorded but sendInput });
// would throw ENOTRUNNING. Return 409 so the client knows it's not a server error.
const run = runs.getRun(lane.run_id);
if (!run || (run.status !== "spawning" && run.status !== "running")) {
return res
.status(409)
.json({ error: { code: "ENORUN", message: "lane has no live run" } });
}
runs.sendInput(lane.run_id, String(body.text || ""));
lanesLib.updateLane(lane.id, { needs_action: null });
break;
} }
case "clear": case "clear":
lanesLib.clearLane(lane.id); lanesLib.clearLane(lane.id);