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