Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions change-logs/2026/08/02/fix-native-auxiliary-terminal-panes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Short: Dev server and Rebase panes work natively

On a task using the native terminal backend, starting the dev server or running Rebase opens a real visible pane in the task's own terminal instead of failing invisibly — the dev server used to run hidden in a tmux session a native task should never touch, and the git panes errored out. Repeated clicks reuse the one pane, the agent pane keeps its focus, and closing a task now also stops a native dev server instead of leaking its processes and ports.
330 changes: 330 additions & 0 deletions src/bun/__tests__/task-aux-panes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,330 @@
/**
* task-aux-panes tests (seq 1376).
*
* Two guarantees, one per backend:
* • native never reaches tmux, dedups its own pane, and hands focus back;
* • tmux behaves exactly as it did before the module existed.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Task } from "../../shared/types";

const { FakeTmuxError } = vi.hoisted(() => ({
FakeTmuxError: class FakeTmuxError extends Error {
constructor(readonly args: string[], readonly exitCode: number, readonly stderr: string) {
super(`tmux ${args[0] ?? ""} failed (exit ${exitCode}): ${stderr || "unknown error"}`);
this.name = "TmuxError";
}
},
}));

const mocks = vi.hoisted(() => ({
// tmux singleton — every method is a spy so "no tmux happened" is provable.
tmuxListPanes: vi.fn(),
tmuxSplitWindow: vi.fn(),
tmuxSelectPane: vi.fn(),
tmuxKillPane: vi.fn(),
// native-task-panes
nativeTaskPanesState: vi.fn(),
nativeTaskPaneCommands: vi.fn(),
splitNativeTaskPane: vi.fn(),
closeNativeTaskPane: vi.fn(),
focusNativeTaskPane: vi.fn(),
}));

/** Every tmux method the module could possibly reach. */
const TMUX_METHODS = [mocks.tmuxListPanes, mocks.tmuxSplitWindow, mocks.tmuxSelectPane, mocks.tmuxKillPane];

vi.mock("../logger", () => ({
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}));

// Nothing in this module may spawn a process directly.
vi.mock("../spawn", () => ({ spawn: vi.fn(), spawnSync: vi.fn() }));

vi.mock("../tmux", () => ({
PANE_START_COMMAND_FORMAT: { formatString: "#{pane_id}\t#{pane_start_command}", parse: () => [] },
TmuxError: FakeTmuxError,
taskSessionName: (taskId: string) => `dev3-${taskId.slice(0, 8)}`,
tmux: {
listPanes: mocks.tmuxListPanes,
splitWindow: mocks.tmuxSplitWindow,
selectPane: mocks.tmuxSelectPane,
killPane: mocks.tmuxKillPane,
},
}));

vi.mock("../native-task-panes", () => ({
nativeTaskPanesState: mocks.nativeTaskPanesState,
nativeTaskPaneCommands: mocks.nativeTaskPaneCommands,
splitNativeTaskPane: mocks.splitNativeTaskPane,
closeNativeTaskPane: mocks.closeNativeTaskPane,
focusNativeTaskPane: mocks.focusNativeTaskPane,
}));

import {
auxPaneAlive,
auxPaneMarker,
auxPurposeOfCommand,
AuxPaneUnavailableError,
closeAuxPane,
findAuxPane,
nativeAuxPaneShellPid,
openAuxPane,
} from "../task-aux-panes";
import { spawn } from "../spawn";

// ── Fixtures ─────────────────────────────────────────────────────────────────

const TASK_ID = "aaaaaaaa-0000-0000-0000-000000000001";
const SESSION = `dev3-${TASK_ID.slice(0, 8)}`;
const SOCKET = "dev3-sock";

const nativeTask = { id: TASK_ID, terminalBackend: "native" } as unknown as Task;
const tmuxTask = { id: TASK_ID } as unknown as Task;

const DEV_MARKER = auxPaneMarker(TASK_ID, "devServer");

function spec(task: Task, overrides: Partial<Parameters<typeof openAuxPane>[0]> = {}) {
return {
task,
purpose: "devServer" as const,
placement: "below" as const,
size: "20%",
cwd: "/tmp/wt",
env: { DEV3_TASK_ID: TASK_ID },
socket: SOCKET,
title: "Dev Server",
tmuxCommand: `bash ${DEV_MARKER}`,
nativeLaunch: { executable: "/bin/bash", argv: [DEV_MARKER] },
...overrides,
};
}

function nativePane(paneId: string, command: string[], alive = true) {
return { paneId, sessionId: `sess-${paneId}`, command, shellPid: 4242, alive };
}

function nativeState(paneIds: string[], activePaneId: string | null) {
return {
taskId: TASK_ID,
panes: paneIds.map((paneId) => ({
paneId,
sessionId: `sess-${paneId}`,
hostPid: 100,
shellPid: 101,
cols: 80,
rows: 24,
alive: true,
})),
layout: null,
activePaneId,
};
}

beforeEach(() => {
vi.clearAllMocks();
mocks.nativeTaskPanesState.mockResolvedValue(nativeState(["pane-1"], "pane-1"));
mocks.nativeTaskPaneCommands.mockResolvedValue([]);
mocks.splitNativeTaskPane.mockResolvedValue({ paneId: "pane-2", state: nativeState(["pane-1", "pane-2"], "pane-2") });
mocks.closeNativeTaskPane.mockResolvedValue({ sessionTornDown: false, state: nativeState(["pane-1"], "pane-1") });
mocks.focusNativeTaskPane.mockResolvedValue(nativeState(["pane-1", "pane-2"], "pane-1"));
mocks.tmuxSplitWindow.mockResolvedValue({ paneId: "%7", stderr: "" });
mocks.tmuxSelectPane.mockResolvedValue(undefined);
mocks.tmuxKillPane.mockResolvedValue(undefined);
mocks.tmuxListPanes.mockResolvedValue([]);
});

// ── Native backend ───────────────────────────────────────────────────────────

describe("openAuxPane (native)", () => {
it("splits from the coordinator's active pane and returns the native handle", async () => {
const handle = await openAuxPane(spec(nativeTask));

expect(mocks.splitNativeTaskPane).toHaveBeenCalledWith(TASK_ID, "pane-1", "vertical", {
cwd: "/tmp/wt",
env: { DEV3_TASK_ID: TASK_ID },
launch: { executable: "/bin/bash", argv: [DEV_MARKER] },
});
expect(handle).toEqual({ backend: "native", paneId: "pane-2" });
});

it("makes ZERO tmux calls", async () => {
await openAuxPane(spec(nativeTask));

for (const method of TMUX_METHODS) expect(method).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
});

it("hands focus back to the pane that had it before the split", async () => {
await openAuxPane(spec(nativeTask));
expect(mocks.focusNativeTaskPane).toHaveBeenCalledWith(TASK_ID, "pane-1");
});

it("forces focus nowhere when the coordinator had no active pane", async () => {
mocks.nativeTaskPanesState.mockResolvedValue(nativeState(["pane-1"], null));

await openAuxPane(spec(nativeTask));

expect(mocks.splitNativeTaskPane).toHaveBeenCalledWith(TASK_ID, "pane-1", "vertical", expect.anything());
expect(mocks.focusNativeTaskPane).not.toHaveBeenCalled();
});

it("closes the purpose's existing pane before opening the new one", async () => {
mocks.nativeTaskPaneCommands.mockResolvedValue([
nativePane("pane-1", ["/bin/zsh"]),
nativePane("pane-9", ["/bin/bash", DEV_MARKER]),
]);

await openAuxPane(spec(nativeTask));

expect(mocks.closeNativeTaskPane).toHaveBeenCalledTimes(1);
expect(mocks.closeNativeTaskPane).toHaveBeenCalledWith(TASK_ID, "pane-9");
expect(mocks.closeNativeTaskPane.mock.invocationCallOrder[0]).toBeLessThan(
mocks.splitNativeTaskPane.mock.invocationCallOrder[0],
);
expect(mocks.splitNativeTaskPane).toHaveBeenCalledTimes(1);
});

it("sweeps a dead owned pane before opening a new one", async () => {
mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-9", ["/bin/bash", DEV_MARKER], false)]);

await openAuxPane(spec(nativeTask));

expect(mocks.closeNativeTaskPane).toHaveBeenCalledWith(TASK_ID, "pane-9");
expect(mocks.splitNativeTaskPane).toHaveBeenCalledTimes(1);
});

it("throws AuxPaneUnavailableError when the native terminal is not running", async () => {
mocks.nativeTaskPanesState.mockResolvedValue(null);

await expect(openAuxPane(spec(nativeTask))).rejects.toBeInstanceOf(AuxPaneUnavailableError);
expect(mocks.splitNativeTaskPane).not.toHaveBeenCalled();
for (const method of TMUX_METHODS) expect(method).not.toHaveBeenCalled();
});

it("throws AuxPaneUnavailableError when the pane set is empty", async () => {
mocks.nativeTaskPanesState.mockResolvedValue(nativeState([], null));

await expect(openAuxPane(spec(nativeTask))).rejects.toBeInstanceOf(AuxPaneUnavailableError);
for (const method of TMUX_METHODS) expect(method).not.toHaveBeenCalled();
});
});

describe("native pane lookup by launch-command marker", () => {
it("findAuxPane resolves the pane carrying the marker", async () => {
mocks.nativeTaskPaneCommands.mockResolvedValue([
nativePane("pane-1", ["/bin/zsh"]),
nativePane("pane-9", ["/bin/bash", DEV_MARKER]),
]);

await expect(findAuxPane(nativeTask, "devServer", SOCKET)).resolves.toEqual({
backend: "native",
paneId: "pane-9",
});
});

it("findAuxPane returns null for an ordinary pane", async () => {
mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-1", ["/bin/zsh"])]);
await expect(findAuxPane(nativeTask, "devServer", SOCKET)).resolves.toBeNull();
});

it("auxPaneAlive is true only while the marked pane's process runs", async () => {
mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-9", ["/bin/bash", DEV_MARKER])]);
await expect(auxPaneAlive(nativeTask, "devServer", SOCKET)).resolves.toBe(true);

mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-9", ["/bin/bash", DEV_MARKER], false)]);
await expect(auxPaneAlive(nativeTask, "devServer", SOCKET)).resolves.toBe(false);
});

it("auxPaneAlive is false for an ordinary pane", async () => {
mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-1", ["/bin/zsh"])]);
await expect(auxPaneAlive(nativeTask, "devServer", SOCKET)).resolves.toBe(false);
});

it("nativeAuxPaneShellPid returns the live pane's pid, null otherwise", async () => {
mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-9", ["/bin/bash", DEV_MARKER])]);
await expect(nativeAuxPaneShellPid(nativeTask, "devServer")).resolves.toBe(4242);

mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-1", ["/bin/zsh"])]);
await expect(nativeAuxPaneShellPid(nativeTask, "devServer")).resolves.toBeNull();
});

it("auxPurposeOfCommand labels a command only by its own marker", () => {
expect(auxPurposeOfCommand(TASK_ID, ["/bin/bash", DEV_MARKER])).toBe("devServer");
expect(auxPurposeOfCommand(TASK_ID, ["/bin/bash", auxPaneMarker(TASK_ID, "gitOp") + "rebase.sh"])).toBe("gitOp");
expect(auxPurposeOfCommand(TASK_ID, ["/bin/zsh"])).toBeNull();
});
});

// ── tmux backend (regression guard: behaviour must be unchanged) ──────────────

describe("openAuxPane (tmux)", () => {
it("splits the task session below at the requested size", async () => {
const handle = await openAuxPane(spec(tmuxTask));

expect(mocks.tmuxSplitWindow).toHaveBeenCalledWith({
target: SESSION,
orientation: "vertical",
size: "20%",
printPaneId: true,
env: { DEV3_TASK_ID: TASK_ID },
cwd: "/tmp/wt",
command: `bash ${DEV_MARKER}`,
socket: SOCKET,
});
expect(handle).toEqual({ backend: "tmux", paneId: "%7" });
});

it("maps placement 'right' to a horizontal split", async () => {
await openAuxPane(spec(tmuxTask, { placement: "right", size: "50%" }));

expect(mocks.tmuxSplitWindow).toHaveBeenCalledWith(
expect.objectContaining({ orientation: "horizontal", size: "50%" }),
);
});

it("titles the new pane", async () => {
await openAuxPane(spec(tmuxTask));
expect(mocks.tmuxSelectPane).toHaveBeenCalledWith("%7", { socket: SOCKET, title: "Dev Server" });
});

it("surfaces a failed split as a readable error", async () => {
mocks.tmuxSplitWindow.mockRejectedValue(new FakeTmuxError(["split-window"], 1, "no such session"));

await expect(openAuxPane(spec(tmuxTask))).rejects.toThrow(/tmux split-window failed/);
});

it("makes ZERO native calls", async () => {
await openAuxPane(spec(tmuxTask));

expect(mocks.nativeTaskPanesState).not.toHaveBeenCalled();
expect(mocks.splitNativeTaskPane).not.toHaveBeenCalled();
expect(mocks.closeNativeTaskPane).not.toHaveBeenCalled();
expect(mocks.focusNativeTaskPane).not.toHaveBeenCalled();
expect(mocks.nativeTaskPaneCommands).not.toHaveBeenCalled();
});
});

describe("closeAuxPane", () => {
it("kills the tmux pane best-effort", async () => {
mocks.tmuxListPanes.mockResolvedValue([{ paneId: "%7", startCommand: `bash ${DEV_MARKER}` }]);

await closeAuxPane(tmuxTask, "devServer", SOCKET);

expect(mocks.tmuxKillPane).toHaveBeenCalledWith("%7", { socket: SOCKET, bestEffort: true });
});

it("closes the native pane through the native backend", async () => {
mocks.nativeTaskPaneCommands.mockResolvedValue([nativePane("pane-9", ["/bin/bash", DEV_MARKER])]);

await closeAuxPane(nativeTask, "devServer", SOCKET);

expect(mocks.closeNativeTaskPane).toHaveBeenCalledWith(TASK_ID, "pane-9");
expect(mocks.tmuxKillPane).not.toHaveBeenCalled();
});

it("does nothing when the purpose owns no pane", async () => {
await closeAuxPane(tmuxTask, "devServer", SOCKET);
expect(mocks.tmuxKillPane).not.toHaveBeenCalled();
});
});
17 changes: 11 additions & 6 deletions src/bun/lifecycle/__tests__/native-teardown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,16 +154,21 @@ describe("destroyTaskPty", () => {
});

describe("killDevServer", () => {
it("is skipped for a native task, which owns no tmux dev session", async () => {
await executeLifecycleEffect(effect("killDevServer"), context(task({ terminalBackend: "native" })));

expect(killDevServerSession).not.toHaveBeenCalled();
// A native task hosts its dev server in an auxiliary pane rather than a tmux
// session, so teardown must still run — skipping it here leaked the whole
// dev-server process tree, ports included.
it("tears the dev server down for a native task too", async () => {
const nativeTask = task({ terminalBackend: "native" });
await executeLifecycleEffect(effect("killDevServer"), context(nativeTask));

expect(killDevServerSession).toHaveBeenCalledWith(nativeTask, "dev3", "/tmp/wt");
});

it("still tears the dev session down for an unmarked task", async () => {
await executeLifecycleEffect(effect("killDevServer"), context(task()));
const tmuxTask = task();
await executeLifecycleEffect(effect("killDevServer"), context(tmuxTask));

expect(killDevServerSession).toHaveBeenCalledWith(TASK_ID, "dev3", "/tmp/wt");
expect(killDevServerSession).toHaveBeenCalledWith(tmuxTask, "dev3", "/tmp/wt");
});
});

Expand Down
8 changes: 4 additions & 4 deletions src/bun/lifecycle/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,11 +763,11 @@ export async function executeLifecycleEffect(
}
return {};
case "killDevServer":
// A dev server is a tmux session; a native task has none, and probing tmux
// for it is exactly what the native path must never do.
if (taskTerminalBackendIdentity(ctx.sourceTask) === "native") return {};
// Both backends host a dev server (tmux: a nested session, native: an
// auxiliary pane), and killDevServerSession picks the right one. Skipping
// native here used to leak the whole dev-server process tree on teardown.
await killDevServerSession(
ctx.sourceTask.id,
ctx.sourceTask,
ctx.sourceTask.tmuxSocket ?? DEFAULT_TMUX_SOCKET,
ctx.sourceTask.worktreePath,
);
Expand Down
2 changes: 0 additions & 2 deletions src/bun/lifecycle/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export interface LifecycleActorRuntime {
prPending?: boolean;
prPromoted?: boolean;
prSignalKey?: string;
gitOpPaneId?: string;
branchChecks?: Map<string, Promise<BranchStatus>>;
activeActivities?: Set<LifecycleActivity>;
}
Expand Down Expand Up @@ -230,7 +229,6 @@ class LifecycleService {
clearTaskRuntime: (id) => {
const runtime = this.actors.runtime(id);
delete runtime.mergePromptReservation;
delete runtime.gitOpPaneId;
runtime.branchChecks?.clear();
delete runtime.branchChecks;
delete runtime.mergeNextDue;
Expand Down
Loading