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:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user