Skip to content

Commit eee53a8

Browse files
authored
Launch bug hunters as native panes instead of a raw tmux split (#1228)
* Launch bug hunters as native panes instead of a raw tmux split * Even out the native hunter column and prove every hunter really started * Fail a native hunter launch whose column cannot be evened out
1 parent 4e89815 commit eee53a8

6 files changed

Lines changed: 740 additions & 85 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Short: Find bugs works on native tasks
2+
3+
Launching bug hunters on a native task failed with a tmux socket error, because the launch path always split a tmux pane. Hunters now open as real native panes through the shared backend-neutral pane API, stack in an equal column beside the agent's pane, get their prompt through the native pane, and leave keyboard focus with the main agent. A native launch that cannot open every hunter, lay the column out or deliver every prompt is rolled back and reported instead of half-starting — a pane that refuses to close is named rather than counted as gone — while tmux tasks are unchanged.

src/bun/__tests__/rpc-handlers.test.ts

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@ import { mkdir, rm } from "node:fs/promises";
33
import type { GlobalSettings, Project, Task, TaskDiffResponse } from "../../shared/types";
44
import { buildTaskDialogSubject, getPreparingStageProgress, resolveTaskCompareBaseBranch } from "../../shared/types";
55
import { ENV_UNSET } from "../../shared/agent-accounts";
6+
import {
7+
activatePane,
8+
closePane,
9+
createSplitTree,
10+
getPaneRects,
11+
listPaneIds,
12+
serializeSplitTree,
13+
setSplitRatio,
14+
splitPane,
15+
type SplitOrientation,
16+
type SplitTree,
17+
} from "../../shared/split-tree";
618

719
// ---- Mocks ----
820

@@ -139,6 +151,32 @@ vi.mock("../system-clipboard", () => ({
139151
writeSystemClipboard: vi.fn(() => "pbcopy"),
140152
}));
141153

154+
// The native pane runtime spawns real host processes, so it is mocked wholesale.
155+
// Unmarked tasks are tmux and never reach it; the native suites drive these
156+
// doubles directly.
157+
const mockNativePanes = {
158+
nativeTaskPanesState: vi.fn(async () => null as any),
159+
nativeTaskPaneLayout: vi.fn(async (..._args: any[]) => null as any),
160+
nativeTaskPanesAlive: vi.fn(async () => false),
161+
nativeTaskPaneCommands: vi.fn(async () => [] as any[]),
162+
nativeTaskPaneCommandsOf: vi.fn(() => [] as any[]),
163+
splitNativeTaskPane: vi.fn(async (..._args: any[]) => null as any),
164+
closeNativeTaskPane: vi.fn(async (..._args: any[]) => ({ sessionTornDown: false, state: null }) as any),
165+
focusNativeTaskPane: vi.fn(async (..._args: any[]) => null as any),
166+
setNativeTaskPaneLayout: vi.fn(async (..._args: any[]) => null as any),
167+
};
168+
vi.mock("../native-task-panes", () => mockNativePanes);
169+
170+
const mockSendPromptToNativePane = vi.fn(async () => true);
171+
vi.mock("../agent-prompt-native", () => ({
172+
sendPromptToNativePane: (...args: any[]) => mockSendPromptToNativePane(...(args as [])),
173+
sendPromptToNativeAgentPane: vi.fn(async () => true),
174+
resolveNativeAgentPane: vi.fn(async () => null),
175+
deliverNativePromptAsOwner: vi.fn(async () => true),
176+
NATIVE_AGENT_PANE_ID: "pane-1",
177+
NATIVE_PROMPT_DELIVERY_METHOD: "_native.deliverPrompt",
178+
}));
179+
142180
vi.mock("../agents", () => ({
143181
ensureClaudeTrust: vi.fn(),
144182
ensureCodexTrust: vi.fn(),
@@ -343,6 +381,7 @@ const {
343381
portableReadKey,
344382
} = await import("../rpc-handlers");
345383
const {
384+
nativeHunterColumnRatios,
346385
tmuxAction,
347386
tmuxKillPane,
348387
tmuxPaneCount,
@@ -8604,6 +8643,315 @@ describe("handlers.spawnBugHuntersInTask", () => {
86048643
).rejects.toThrow("no worktree");
86058644
});
86068645
});
8646+
// ================================================================
8647+
// handlers.spawnBugHuntersInTask on a NATIVE task (seq 1394)
8648+
//
8649+
// Same entry point the Find bugs dialog calls, on a task whose terminal is
8650+
// native rather than tmux.
8651+
// ================================================================
8652+
8653+
describe("handlers.spawnBugHuntersInTask on a native task", () => {
8654+
const TASK_ID = "abcd1234-full-id";
8655+
const READINESS_DELAY_MS = 5100;
8656+
8657+
beforeEach(() => {
8658+
vi.clearAllMocks();
8659+
vi.useFakeTimers();
8660+
(globalThis as any).Bun.write = vi.fn().mockResolvedValue(undefined);
8661+
mockSendPromptToNativePane.mockResolvedValue(true);
8662+
mockNativePanes.focusNativeTaskPane.mockResolvedValue(null as any);
8663+
mockNativePanes.closeNativeTaskPane.mockResolvedValue({ sessionTornDown: false, state: null } as any);
8664+
});
8665+
afterEach(() => {
8666+
vi.useRealTimers();
8667+
});
8668+
8669+
/**
8670+
* A pane set that starts with the agent's pane and grows on every split. The
8671+
* layout is a REAL SplitTree driven by the shared split-tree functions, so the
8672+
* geometry assertions measure what the app would draw.
8673+
*/
8674+
function arrangeNativePaneSet(opts: {
8675+
failAtSplit?: number;
8676+
closeAlwaysFails?: boolean;
8677+
/** A pane set the task already had before Find bugs was clicked. */
8678+
seed?: SplitTree;
8679+
/** The layout read-back returns nothing, as if the coordinator vanished. */
8680+
layoutUnreadable?: boolean;
8681+
/** Publishing the evened-out column fails. */
8682+
publishFails?: boolean;
8683+
} = {}) {
8684+
let tree = opts.seed ?? createSplitTree();
8685+
const state = () => ({
8686+
taskId: TASK_ID,
8687+
panes: listPaneIds(tree).map((paneId) => ({ paneId, sessionId: `${paneId}-s`, hostPid: 1, shellPid: 2, cols: 80, rows: 24, alive: true })),
8688+
layout: serializeSplitTree(tree),
8689+
activePaneId: tree.activePaneId,
8690+
});
8691+
mockNativePanes.nativeTaskPanesState.mockImplementation(async () => state() as any);
8692+
mockNativePanes.nativeTaskPaneLayout.mockImplementation(async () => (opts.layoutUnreadable ? null : (tree as any)));
8693+
mockNativePanes.setNativeTaskPaneLayout.mockImplementation(async (_taskId: string, next: SplitTree) => {
8694+
if (opts.publishFails) throw new Error("coordinator refused the geometry");
8695+
tree = next;
8696+
return state() as any;
8697+
});
8698+
mockNativePanes.focusNativeTaskPane.mockImplementation(async (_taskId: string, paneId: string) => {
8699+
tree = activatePane(tree, paneId);
8700+
return state() as any;
8701+
});
8702+
mockNativePanes.closeNativeTaskPane.mockImplementation(async (_taskId: string, paneId: string) => {
8703+
// A close that "fails" leaves the pane in the set — how a host that refuses
8704+
// to die looks to the caller.
8705+
if (!opts.closeAlwaysFails) tree = closePane(tree, paneId);
8706+
const next = state();
8707+
return { sessionTornDown: next.panes.length === 0, state: next } as any;
8708+
});
8709+
let splitCount = 0;
8710+
mockNativePanes.splitNativeTaskPane.mockImplementation(async (_taskId: string, fromPaneId: string, orientation: SplitOrientation) => {
8711+
splitCount += 1;
8712+
if (opts.failAtSplit === splitCount) throw new Error("host would not start");
8713+
const paneId = `pane-${tree.nextPaneOrdinal}`;
8714+
tree = splitPane(tree, fromPaneId, orientation);
8715+
return { paneId, state: state() } as any;
8716+
});
8717+
return {
8718+
paneIds: () => listPaneIds(tree),
8719+
activePaneId: () => tree.activePaneId,
8720+
rects: () => getPaneRects(tree),
8721+
tree: () => tree,
8722+
};
8723+
}
8724+
8725+
/** Pane heights in whole percent, so a third reads as 33 rather than 0.3333…. */
8726+
function heightPercents(rects: Map<string, { height: number }>): Record<string, number> {
8727+
const out: Record<string, number> = {};
8728+
for (const [paneId, rect] of rects) out[paneId] = Math.round(rect.height * 100);
8729+
return out;
8730+
}
8731+
8732+
function arrangeNativeTask() {
8733+
const project = makeProject();
8734+
const task = makeTask({ id: TASK_ID, worktreePath: "/tmp/wt", terminalBackend: "native" } as any);
8735+
(data.getProject as any).mockResolvedValue(project);
8736+
(data.getTask as any).mockResolvedValue(task);
8737+
(agents.resolveCommandForAgent as any).mockResolvedValue({ command: "claude", extraEnv: {}, agent: { baseCommand: "claude" } });
8738+
return { project, task };
8739+
}
8740+
8741+
/** Launch and let the readiness delay elapse — native delivery is awaited. */
8742+
function launchHunters(count: number): Promise<{ spawned: number }> {
8743+
const launch = handlers.spawnBugHuntersInTask({
8744+
taskId: TASK_ID, projectId: "proj-1", agentId: "builtin-claude", configId: "claude-default", count,
8745+
});
8746+
const settled = launch.catch(() => undefined);
8747+
return vi.advanceTimersByTimeAsync(READINESS_DELAY_MS).then(() => settled).then(() => launch);
8748+
}
8749+
8750+
it("opens the requested number of native panes and never calls tmux", async () => {
8751+
arrangeNativeTask();
8752+
const set = arrangeNativePaneSet();
8753+
8754+
const result = await launchHunters(3);
8755+
8756+
expect(result).toEqual({ spawned: 3 });
8757+
expect(mockNativePanes.splitNativeTaskPane).toHaveBeenCalledTimes(3);
8758+
// Zero tmux process spawns — no split-window, and no probe of the default socket.
8759+
expect(mockSpawn).not.toHaveBeenCalled();
8760+
8761+
// First hunter splits off the agent's pane to the right; each later hunter
8762+
// splits the previous hunter's pane downwards, so the right column stacks.
8763+
const anchors = mockNativePanes.splitNativeTaskPane.mock.calls.map((c: any[]) => [c[1], c[2]]);
8764+
expect(anchors).toEqual([
8765+
["pane-1", "horizontal"],
8766+
["pane-2", "vertical"],
8767+
["pane-3", "vertical"],
8768+
]);
8769+
expect(set.paneIds()).toEqual(["pane-1", "pane-2", "pane-3", "pane-4"]);
8770+
// The agent keeps the keyboard: the last split made a hunter pane active.
8771+
expect(set.activePaneId()).toBe("pane-1");
8772+
});
8773+
8774+
// A bare chain of SplitTree splits lands at 50/25/25, while tmux deliberately
8775+
// produces equal thirds. The launch re-publishes the ratios of its own splits.
8776+
it("leaves the hunter column in equal parts, like the tmux path, and does not move the agent's pane", async () => {
8777+
arrangeNativeTask();
8778+
const set = arrangeNativePaneSet();
8779+
8780+
await launchHunters(3);
8781+
8782+
const rects = set.rects();
8783+
expect(rects.get("pane-1")).toEqual({ x: 0, y: 0, width: 0.5, height: 1 });
8784+
expect(heightPercents(rects)).toEqual({ "pane-1": 100, "pane-2": 33, "pane-3": 33, "pane-4": 33 });
8785+
expect([...rects].filter(([id]) => id !== "pane-1").map(([id, r]) => [id, Math.round(r.y * 100), Math.round(r.x * 100)])).toEqual([
8786+
["pane-2", 0, 50],
8787+
["pane-3", 33, 50],
8788+
["pane-4", 67, 50],
8789+
]);
8790+
});
8791+
8792+
it("evens out a full column of six hunters too", async () => {
8793+
arrangeNativeTask();
8794+
const set = arrangeNativePaneSet();
8795+
8796+
const result = await launchHunters(6);
8797+
8798+
expect(result).toEqual({ spawned: 6 });
8799+
const rects = set.rects();
8800+
expect(rects.get("pane-1")).toEqual({ x: 0, y: 0, width: 0.5, height: 1 });
8801+
expect(heightPercents(rects)).toEqual({
8802+
"pane-1": 100, "pane-2": 17, "pane-3": 17, "pane-4": 17, "pane-5": 17, "pane-6": 17, "pane-7": 17,
8803+
});
8804+
});
8805+
8806+
it("computes the column ratios that make each hunter equal", () => {
8807+
expect(nativeHunterColumnRatios(1)).toEqual([]);
8808+
expect(nativeHunterColumnRatios(3)).toEqual([1 / 3, 1 / 2]);
8809+
expect(nativeHunterColumnRatios(6)).toEqual([1 / 6, 1 / 5, 1 / 4, 1 / 3, 1 / 2]);
8810+
});
8811+
8812+
it("delivers the hunter prompt through the native pane, not through send-keys", async () => {
8813+
arrangeNativeTask();
8814+
arrangeNativePaneSet();
8815+
8816+
await launchHunters(2);
8817+
8818+
expect(mockSendPromptToNativePane).toHaveBeenCalledTimes(2);
8819+
const [, paneId, prompt] = mockSendPromptToNativePane.mock.calls[0] as any[];
8820+
expect(paneId).toBe("pane-2");
8821+
expect(prompt).toContain("/dev3-bug-hunter");
8822+
expect(mockSpawn).not.toHaveBeenCalled();
8823+
});
8824+
8825+
it("fails the launch when a prompt is reported undelivered", async () => {
8826+
arrangeNativeTask();
8827+
const set = arrangeNativePaneSet();
8828+
mockSendPromptToNativePane.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
8829+
8830+
// Not "only 2 of 2 could be started" — the panes DID open, the prompt did not land.
8831+
await expect(launchHunters(2)).rejects.toThrow(
8832+
"All 2 hunter panes opened, but the hunt prompt never reached pane-3; launch rolled back.",
8833+
);
8834+
8835+
expect(set.paneIds()).toEqual(["pane-1"]);
8836+
expect(set.activePaneId()).toBe("pane-1");
8837+
});
8838+
8839+
it("fails the launch when a prompt delivery throws", async () => {
8840+
arrangeNativeTask();
8841+
const set = arrangeNativePaneSet();
8842+
mockSendPromptToNativePane.mockRejectedValue(new Error("writer lease is gone"));
8843+
8844+
await expect(launchHunters(2)).rejects.toThrow(/writer lease is gone/);
8845+
8846+
expect(set.paneIds()).toEqual(["pane-1"]);
8847+
expect(set.activePaneId()).toBe("pane-1");
8848+
});
8849+
8850+
it("rolls the whole launch back when a later split fails, instead of leaving a partial set", async () => {
8851+
arrangeNativeTask();
8852+
const set = arrangeNativePaneSet({ failAtSplit: 3 });
8853+
8854+
await expect(launchHunters(4)).rejects.toThrow("Only 2 of 4 bug hunters could be started");
8855+
8856+
// Both panes that did open were closed again; the agent's pane is untouched.
8857+
expect(mockNativePanes.closeNativeTaskPane.mock.calls.map((c: any[]) => c[1])).toEqual(["pane-2", "pane-3"]);
8858+
expect(set.paneIds()).toEqual(["pane-1"]);
8859+
expect(set.activePaneId()).toBe("pane-1");
8860+
});
8861+
8862+
// "Rolled back" must never be printed over panes that are still on screen.
8863+
it("names the panes it could not close when the rollback itself fails", async () => {
8864+
arrangeNativeTask();
8865+
const set = arrangeNativePaneSet({ failAtSplit: 3, closeAlwaysFails: true });
8866+
8867+
await expect(launchHunters(4)).rejects.toThrow(
8868+
/the rollback could not close pane-2, pane-3 those panes are still open, close them by hand\./,
8869+
);
8870+
8871+
expect(set.paneIds()).toEqual(["pane-1", "pane-2", "pane-3"]);
8872+
expect(set.activePaneId()).toBe("pane-1");
8873+
});
8874+
8875+
// Geometry is part of the result, not a nice-to-have: reporting a launched
8876+
// column that is actually drawn 50/25/25 is the silent wrong answer.
8877+
it("fails the launch when the layout cannot be read back", async () => {
8878+
arrangeNativeTask();
8879+
const set = arrangeNativePaneSet({ layoutUnreadable: true });
8880+
8881+
await expect(launchHunters(3)).rejects.toThrow(
8882+
"All 3 hunter panes opened, but the column could not be laid out evenly " +
8883+
"(Error: the task's native pane layout could not be read back); launch rolled back.",
8884+
);
8885+
8886+
expect(set.paneIds()).toEqual(["pane-1"]);
8887+
expect(set.activePaneId()).toBe("pane-1");
8888+
});
8889+
8890+
it("fails the launch when the evened-out column cannot be published", async () => {
8891+
arrangeNativeTask();
8892+
const set = arrangeNativePaneSet({ publishFails: true });
8893+
8894+
await expect(launchHunters(3)).rejects.toThrow(
8895+
/the column could not be laid out evenly \(Error: coordinator refused the geometry\); launch rolled back\./,
8896+
);
8897+
8898+
expect(set.paneIds()).toEqual(["pane-1"]);
8899+
expect(set.activePaneId()).toBe("pane-1");
8900+
});
8901+
8902+
it("fails the launch when a split it made is gone from the layout", async () => {
8903+
arrangeNativeTask();
8904+
const set = arrangeNativePaneSet();
8905+
// The layout read-back no longer contains the hunter panes at all.
8906+
mockNativePanes.nativeTaskPaneLayout.mockResolvedValue(createSplitTree() as any);
8907+
8908+
await expect(launchHunters(3)).rejects.toThrow(/the split opened off pane-2 is no longer in the layout/);
8909+
8910+
expect(set.paneIds()).toEqual(["pane-1"]);
8911+
});
8912+
8913+
it("evens out its own column without moving a pane the task already had", async () => {
8914+
arrangeNativeTask();
8915+
// The task already runs a dev-server pane below the agent, at a ratio the user
8916+
// dragged to 70/30 — nothing about this launch may touch it.
8917+
let seed = splitPane(createSplitTree(), "pane-1", "vertical");
8918+
const existingSplit = (seed.root as { id: string }).id;
8919+
seed = setSplitRatio(seed, existingSplit, 0.7);
8920+
seed = activatePane(seed, "pane-1");
8921+
const before = getPaneRects(seed).get("pane-2");
8922+
const set = arrangeNativePaneSet({ seed });
8923+
8924+
await launchHunters(3);
8925+
8926+
const rects = set.rects();
8927+
// The pre-existing pane and its split ratio are byte-identical.
8928+
expect(rects.get("pane-2")).toEqual(before);
8929+
expect((set.tree().root as { ratio: number }).ratio).toBe(0.7);
8930+
// …while the hunter column this launch opened is exactly equal.
8931+
const hunterHeights = ["pane-3", "pane-4", "pane-5"].map((id) => rects.get(id)!.height);
8932+
expect(hunterHeights[0]).toBeCloseTo(hunterHeights[1], 10);
8933+
expect(hunterHeights[1]).toBeCloseTo(hunterHeights[2], 10);
8934+
expect(hunterHeights[0] * 3).toBeCloseTo(0.7, 10);
8935+
});
8936+
8937+
it("reports honestly when the native terminal is not running", async () => {
8938+
arrangeNativeTask();
8939+
mockNativePanes.nativeTaskPanesState.mockResolvedValue(null as any);
8940+
8941+
await expect(launchHunters(3)).rejects.toThrow("the task terminal is not running");
8942+
expect(mockSpawn).not.toHaveBeenCalled();
8943+
});
8944+
8945+
it("writes no tmux pane entry into sessionState", async () => {
8946+
arrangeNativeTask();
8947+
arrangeNativePaneSet();
8948+
8949+
await launchHunters(2);
8950+
8951+
const sessionStateWrites = (data.updateTask as any).mock.calls.filter((c: any[]) => c[2]?.sessionState);
8952+
expect(sessionStateWrites).toHaveLength(0);
8953+
});
8954+
});
86078955

86088956
describe("resumeTask session-id healing", () => {
86098957
const WORKTREE = "/tmp/resume-wt";

0 commit comments

Comments
 (0)