fix(run): refuse a run with ETMUXMISSING when the host has no tmux

Starting a run on a machine without tmux surfaced `spawnSync tmux ENOENT`
as a 400 — blaming the request for a missing host package, naming no fix,
and reaching the user as a raw errno in the console pane.

The guard goes in spawnRun, which both spawn paths (POST /api/run and
POST /api/lanes/:id/start) already route through, so one check covers
them; the message carries the install command. Both routes map the code to
503: the request was fine, the dependency is not installed. The client
already renders `error.message` from the body verbatim, so no UI change.

Also: README's node advice said "stay on 20/22/24"; 1712 server + 488
client tests are green on v26.8.1, so only 25 is actually the bad one.
This commit is contained in:
2026-09-11 15:32:24 +07:00
parent d4b949a6f4
commit 27313d4413
8 changed files with 77 additions and 6 deletions
+5 -3
View File
@@ -64,7 +64,7 @@ Internal build — all rights reserved.
| Need | Version | Required for |
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Node **24** is what the test suites are verified on |
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
| **npm** | 9+ | Ships with Node |
| **git** | any | Cloning, and every lane operation (worktrees, branch/status reads) |
| **tmux** | 3.x | The browser run console, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, the Codex nudge |
@@ -73,8 +73,10 @@ Internal build — all rights reserved.
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` and the PR stages of the ship pipelines |
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
Node 25 currently breaks 6 server tests through a better-sqlite3 ABI mismatch
and 20 client tests through a global `localStorage` change — stay on 20/22/24.
Node **25** is the one version to avoid: it breaks 6 server tests through a
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
change. Both were gone again by 26 (1712 server + 488 client tests green on
v26.8.1).
### Installing tmux
+4 -3
View File
@@ -64,7 +64,7 @@ Internal build — all rights reserved.
| Cần | Phiên bản | Dùng cho |
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Test suite được verify trên Node **24** |
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
| **npm** | 9+ | Đi kèm Node |
| **git** | bất kỳ | Clone repo, và mọi thao tác lane (worktree, đọc branch/status) |
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes shell`, gõ `/reload-plugins` vào pane đang sống, nhắc nhở Codex |
@@ -73,8 +73,9 @@ Internal build — all rights reserved.
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes ci status` và các stage PR của ship pipeline |
| **systemd (user)** | — | Tùy chọn — chỉ cho `agenthub service install` (tự khởi động khi boot, Linux) |
Node 25 hiện đang làm gãy 6 test server do lệch ABI `better-sqlite3` và 20 test
client do thay đổi global `localStorage` — hãy ở lại 20/22/24.
Node **25** là bản duy nhất nên tránh: nó làm gãy 6 test server do lệch ABI
`better-sqlite3` và 20 test client do thay đổi global `localStorage`. Tới 26 thì
cả hai đã hết (1712 test server + 488 test client xanh trên v26.8.1).
### Cài tmux
+2
View File
@@ -245,6 +245,8 @@ All fields are optional. The tool (CLI invoked) comes from the lane itself, neve
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex lanes specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude lanes, `resumeSessionId` re-attaches a Claude session by id.
Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected.
Error `409 ERUNLIVE` is returned if the lane already has a live run; stop it first with the `stop` action. The same code and status come back when the lane's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
#### Preflight a destructive lane action
+23
View File
@@ -58,6 +58,29 @@ describe("pty-run", () => {
assert.ok(newSessionCall.includes("opus"));
});
it("spawnRun refuses with ETMUXMISSING, naming the fix, when tmux is not installed", () => {
// Before this guard the ENOENT escaped from three calls down as
// `spawnSync tmux ENOENT` and the route answered 400 — blaming the request
// for a missing host package, with no hint of what to install.
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
const e = new Error("spawnSync tmux ENOENT");
e.code = "ENOENT";
throw e;
});
assert.throws(
() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }),
(err) => err.code === "ETMUXMISSING" && /install/i.test(err.message),
"a missing tmux must be reported as such, with the install command"
);
assert.ok(
!calls.some((c) => c[0] === "new-session"),
"must not attempt a session it already knows cannot be created"
);
});
it("spawnRun is a no-op (adopts) when the existing session's pane runs claude", () => {
const calls = [];
tmux.__setExecImpl((args) => {
+17
View File
@@ -118,6 +118,23 @@ describe("/api/run", () => {
assert.equal(body.error.code, "EBADCWD");
});
it("POST / answers 503, not 400, when the host has no tmux", async () => {
// The request is fine; the dependency is absent. 400 would tell the user to
// fix their input, and the body must carry the install line the UI shows.
tmux.__setExecImpl(() => {
const e = new Error("spawnSync tmux ENOENT");
e.code = "ENOENT";
throw e;
});
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { laneId: 9, cwd: TMP },
});
assert.equal(status, 503);
assert.equal(body.error.code, "ETMUXMISSING");
assert.match(body.error.message, /install/i);
});
it("POST / spawns a tmux session and GET /:id finds it", async () => {
let hasSessionCalls = 0;
tmux.__setExecImpl((args) => {
+15
View File
@@ -181,6 +181,21 @@ function spawnRun(args) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
}
// A run IS a tmux session, so an absent binary is a precondition failure, not
// a spawn failure. Without this the caller gets `spawnSync tmux ENOENT` from
// three directories down — a message that names no fix — and the route maps
// it to 400, blaming the request for the host's missing package. Checked here
// rather than in each route because both spawn paths (POST /api/run and
// POST /api/lanes/:id/start) come through this function.
if (!tmux.isTmuxAvailable()) {
throw makeErr(
"ETMUXMISSING",
"tmux is not installed, and every run the dashboard starts is a tmux session. " +
"Install it (sudo apt install tmux / brew install tmux; on Windows run AgentHub under WSL2), " +
"then restart the dashboard. Everything else keeps working without it."
);
}
const id = runIdForLane(laneId);
const startedAt = Date.now();
const argv = buildArgv({
+6
View File
@@ -1360,6 +1360,12 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
break;
}
} catch (err) {
// A host with no tmux cannot start a run, but nothing is broken on this
// side — 503 plus the install line, not a 500 the reader can only read as a
// dashboard bug.
if (err.code === "ETMUXMISSING") {
return res.status(503).json({ error: { code: err.code, message: err.message } });
}
return res.status(500).json({ error: { code: err.code, message: err.message } });
}
+5
View File
@@ -260,6 +260,11 @@ router.post("/", (req, res) => {
if (err.code === "ERUNLIVE") {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
// Nor is a host missing tmux: the request was fine, the dependency is not
// installed. 400 would tell the user to fix their input.
if (err.code === "ETMUXMISSING") {
return res.status(503).json({ error: { code: err.code, message: err.message } });
}
if (err.code && err.code.startsWith("E")) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}