From 82bf803c2ec8cb8ef8e585350913437ef72cad25 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 10:53:23 +0700 Subject: [PATCH] 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. --- server/__tests__/lane-lifecycle.test.js | 325 +++++++----------------- server/__tests__/lanes-api.test.js | 8 +- server/routes/lanes.js | 88 +++---- 3 files changed, 134 insertions(+), 287 deletions(-) diff --git a/server/__tests__/lane-lifecycle.test.js b/server/__tests__/lane-lifecycle.test.js index fd2048c..d2d0053 100644 --- a/server/__tests__/lane-lifecycle.test.js +++ b/server/__tests__/lane-lifecycle.test.js @@ -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"); }); }); diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js index b89f27e..db34640 100644 --- a/server/__tests__/lanes-api.test.js +++ b/server/__tests__/lanes-api.test.js @@ -645,14 +645,12 @@ describe("lane actions", () => { 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 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" }); - assert.equal(r.status, 409); - assert.equal(r.body.error.code, "ENORUN"); + assert.equal(r.status, 400); + assert.equal(r.body.error.code, "EUNSUPPORTED"); await request("DELETE", `/api/lanes/${id}`); }); }); diff --git a/server/routes/lanes.js b/server/routes/lanes.js index c38278d..ec1c9ae 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -18,7 +18,7 @@ const { listPipelines, getPipeline, nodeStates, progressPct } = require("../lib/ const laneFeatures = require("../lib/lane-features"); const proofLib = require("../lib/proof"); const { broadcast } = require("../websocket"); -const runs = require("../lib/run-spawner"); +const runs = require("../lib/pty-run"); const { sameOriginGuard } = require("./run"); const { preflight } = require("../lib/lane-preflight"); const { @@ -69,8 +69,28 @@ function lastEventAge(lane) { 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) { - 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 @@ -91,23 +111,6 @@ function broadcastLane(id) { 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) => { const lanes = lanesLib.listLanes().map(payload); res.json({ @@ -620,8 +623,6 @@ router.post("/worktree", sameOriginGuard, async (req, res) => { }); 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 RUN_EXIT_POLL_MS = 50; // 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)); } -/** 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) { if (!lane.run_id) return; try { @@ -676,7 +677,7 @@ async function stopLaneRun(lane) { const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS; let run = runs.getRun(lane.run_id); - while (run && !run.actualExitedAt) { + while (run && run.status !== "gone") { if (Date.now() >= deadline) { throw lifecycleError( "ERUNTIMEOUT", @@ -975,7 +976,7 @@ router.post("/:id/sync-base", sameOriginGuard, async (req, res) => { /** * 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. */ router.post("/:id/:action", sameOriginGuard, async (req, res) => { @@ -1079,17 +1080,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { try { switch (action) { 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 // reset would kill and await only the RECORDED run, then `git clean -fd` - // the directory the orphan is still writing into — the exact hazard - // actualExitedAt exists to close. Stop the first run before starting a + // the directory the orphan is still writing into. Stop the first run before starting a // 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 // `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. if (!current) return { missing: true }; 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 }; } const handle = runs.spawnRun({ - mode: body.mode || "conversation", laneId: current.id, - prompt: body.prompt || "", + initialPrompt: body.prompt || "", cwd: current.cwd, model: body.model, permissionMode: body.permissionMode, @@ -1140,23 +1132,13 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { break; } case "message": { - if (!lane.run_id) { - return res - .status(409) - .json({ error: { code: "ENORUN", message: "lane has no live run" } }); - } - // 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; + return res.status(400).json({ + error: { + code: "EUNSUPPORTED", + 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)", + }, + }); } case "clear": lanesLib.clearLane(lane.id);