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
+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}`);