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
+35 -53
View File
@@ -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);