fix(tmux,run,hooks): the four pre-existing bugs the #37 evaluation found

- tmux targets are exact (`=name`, `=name:` for panes/options): a bare
  `-t agenthub-lane-1` resolves by prefix to agenthub-lane-12 once lane 1
  is gone, so killing a finished lane could kill another.
- getRun/killRun refuse ids that are not `agenthub-lane-<n>`, so
  DELETE /api/run/main can no longer kill a user's own tmux session.
- router/apply reports restart_required only for a running pane; getRun
  returns a `gone` handle, so it was always true.
- needs_action is cleared only by the session that raised it (new nullable
  lanes.needs_action_session); a second session in the same cwd used to
  clear it on its second hook.
This commit is contained in:
2026-09-23 13:13:16 +07:00
parent 04b6b98d97
commit 99a2a8277c
18 changed files with 134 additions and 33 deletions
+3 -3
View File
@@ -43,13 +43,13 @@ function stubTmux(sessions, panes = {}) {
calls.push(args);
if (args[0] === "list-sessions") return sessions.join("\n");
if (args[0] === "has-session") {
if (sessions.includes(args[2])) return "";
if (sessions.includes(args[2].slice(1))) return "";
const e = new Error("no such session");
e.status = 1;
throw e;
}
if (args[0] === "display-message") {
const name = args[3];
const name = args[3].slice(1, -1);
const cmd = panes[name] ?? "claude";
if (cmd === null) {
const e = new Error("pane gone");
@@ -59,7 +59,7 @@ function stubTmux(sessions, panes = {}) {
return `${cmd}\n`;
}
if (args[0] === "send-keys") {
if (panes[args[2]] === "__send-fails__") throw new Error("pane died mid-send");
if (panes[args[2].slice(1, -1)] === "__send-fails__") throw new Error("pane died mid-send");
return "";
}
return "";
+15 -5
View File
@@ -856,9 +856,9 @@ describe("destructive lane lifecycle actions", () => {
// Mock tmux so has-session always returns 0 (session exists), simulating a stuck session
// Same shape as tmux.js's own exec: sync, argv only, throws on failure.
tmux.__setExecImpl((args) => {
if (args[0] === "has-session" && args[2] === runId) return "";
if (args[0] === "has-session" && args[2] === `=${runId}`) return "";
if (args[0] === "list-sessions") return `${runId}\n`;
if (args[0] === "kill-session" && args[2] === runId) return "";
if (args[0] === "kill-session" && args[2] === `=${runId}`) return "";
throw Object.assign(new Error("mocked tmux failure"), { status: 1 });
});
@@ -949,7 +949,12 @@ describe("destructive lane lifecycle actions", () => {
// 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) {
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") {
@@ -1218,7 +1223,12 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
// 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) {
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") {
@@ -1264,7 +1274,7 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
// Mock tmux so the session appears to be gone (has-session fails with status 1).
tmux.__setExecImpl((args) => {
if (args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
if (args[0] === "has-session" && args[1] === "-t" && args[2] === `=${runId}`) {
const e = new Error("no such session");
e.status = 1;
throw e;
+34 -1
View File
@@ -321,6 +321,27 @@ describe("/api/lanes", () => {
await request("DELETE", `/api/lanes/${id}`);
fs.rmSync(cwd, { recursive: true, force: true });
});
it("router apply asks for a restart only while the lane's pane is live", async () => {
const tmux = require("../lib/tmux");
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-live-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
try {
for (const live of [false, true]) {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session" && !live) throw new Error("no such session");
return "";
});
const r = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
assert.equal(r.status, 200);
assert.equal(r.body.restart_required, live);
assert.equal(r.body.run_id, live ? `agenthub-lane-${id}` : null);
}
} finally {
tmux.__reset();
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("hook → lane binding", () => {
@@ -396,7 +417,7 @@ describe("hook → lane binding", () => {
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-b");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
// Session B's second PostToolUse clears the flag (B is now bound)
// B is bound now, but it still did not raise the flag: A is still waiting.
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
@@ -406,6 +427,18 @@ describe("hook → lane binding", () => {
tool_use_id: "t2",
},
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
// A answering its own prompt is what clears it.
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-a",
cwd: "/tmp/lane-cross-session",
tool_name: "Read",
tool_use_id: "t3",
},
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
await request("DELETE", `/api/lanes/${id}`);
+1 -1
View File
@@ -66,7 +66,7 @@ describe("pty-attach", () => {
return "";
});
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: 80, rows: 24 });
assert.deepEqual(calls[0], ["set-option", "-t", "agenthub-lane-1", "mouse", "on"]);
assert.deepEqual(calls[0], ["set-option", "-t", "=agenthub-lane-1:", "mouse", "on"]);
});
it("still attaches even if the mouse-mode set-option call fails", () => {
+12 -1
View File
@@ -241,7 +241,18 @@ describe("pty-run", () => {
return "";
});
assert.equal(pty.killRun("agenthub-lane-5"), true);
assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "agenthub-lane-5"));
assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "=agenthub-lane-5"));
});
it("never touches a tmux session that is not a lane pane", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
assert.equal(pty.killRun("main"), false);
assert.equal(pty.getRun("main"), null);
assert.deepEqual(calls, []);
});
it("listRuns reflects live tmux-session state, not cached memory", () => {
+17 -3
View File
@@ -51,7 +51,7 @@ describe("tmux wrapper", () => {
"--model",
"opus",
]);
assert.deepEqual(calls[1], ["set-option", "-t", "agenthub-lane-1", "mouse", "on"]);
assert.deepEqual(calls[1], ["set-option", "-t", "=agenthub-lane-1:", "mouse", "on"]);
});
it("sendText types the literal string and presses Enter, with no quoting", () => {
@@ -64,11 +64,25 @@ describe("tmux wrapper", () => {
assert.deepEqual(calls[0], [
"send-keys",
"-t",
"agenthub-lane-1",
"=agenthub-lane-1:",
"-l",
'Reminder: run "agenthub stage implement"',
]);
assert.deepEqual(calls[1], ["send-keys", "-t", "agenthub-lane-1", "Enter"]);
assert.deepEqual(calls[1], ["send-keys", "-t", "=agenthub-lane-1:", "Enter"]);
});
it("targets a session exactly, so lane 1 never resolves to lane 12 by prefix", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
tmux.hasSession("agenthub-lane-1");
tmux.killSession("agenthub-lane-1");
assert.deepEqual(calls, [
["has-session", "-t", "=agenthub-lane-1"],
["kill-session", "-t", "=agenthub-lane-1"],
]);
});
it("killSession never throws when the session is already gone", () => {