Skip to content

Commit 495e5a8

Browse files
h0x91b-wixh0x91b
authored andcommitted
Refuse a proven replacement when native pane discovery is undecidable
1 parent caa5b34 commit 495e5a8

9 files changed

Lines changed: 343 additions & 20 deletions

decisions/197-column-agent-pane-ownership-and-failure-reason.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ Reproduced on a native task through the card's status menu: `launchColumnAgent S
1010

1111
## Decision
1212

13-
The pane is owned by purpose, not by a remembered id: `launchColumnAgent` (`src/bun/rpc-handlers/tmux-pty.ts`) goes through `openAuxPane` under a new `AuxPanePurpose` value `columnAgent` (`src/bun/task-aux-panes.ts`), and the `col-agent-pane` id file is gone. That purpose is marked `provenReplace`, so `replaceAuxPanes` closes **every** pane it owns and re-reads the pane set to prove they are gone — a launch that cannot prove it refuses rather than risk two agents in one worktree. A stopped task terminal is never resurrected (that would cut across decision 184's explicit wake); the task is parked in Your Review with an actionable message instead.
13+
The pane is owned by purpose, not by a remembered id: `launchColumnAgent` (`src/bun/rpc-handlers/tmux-pty.ts`) goes through `openAuxPane` under a new `AuxPanePurpose` value `columnAgent` (`src/bun/task-aux-panes.ts`), and the `col-agent-pane` id file is gone. That purpose is marked `provenReplace`, so `replaceAuxPanes` closes **every** pane it owns and re-reads the pane set to prove they are gone — a launch that cannot prove it refuses rather than risk two agents in one worktree. Proof includes the LOOKUP, not just the close: both backends have production paths that turn an undecidable read into an empty list (`readPaneSet` catches every recovery exception, a `null` pane set becomes `[]`, an unreadable per-pane record becomes `command: []`, a tmux error becomes no rows), so the replacement path reads through `readPaneSetStrict` / `nativeTaskPaneCommandsStrict` and refuses via `AuxPaneUndecidableError` unless the empty list was actually observed. A stopped task terminal is never resurrected (that would cut across decision 184's explicit wake); the task is parked in Your Review with an actionable message instead.
1414

1515
The failure is reported as well as parked. `columnAgentFailed` carries `column: ColumnAgentIdentity`, `movedTo?: TaskStatus` and `reason?: ColumnAgentFailureReason` (`src/shared/types.ts`); `columnAgentFailureCopy` (`src/mainview/utils/columnAgentFailureToast.ts`) picks one of four localized keys from `reason` × `movedTo` and localizes a built-in column's name from its status. The report rides on the fallback move and is emitted *after* that move's column write, because a rejected or failed write stops the effect run — a toast claiming a move that never landed is worse than silence. The renderer never reads the English `error` string; that stays diagnostics for failures the app cannot explain.
1616

1717
## Risks
1818

19-
Each new recognised failure costs another reason value plus two keys × three locales — deliberate, because the alternatives are gluing localized fragments or matching on English. `auxPaneTitle("columnAgent")` is the generic "Column Agent" in the native pane picker, since the label is derived from the launch command and cannot know which column launched it; the pane's own OSC title is still the real column name.
19+
Each new recognised failure costs another reason value plus two keys × three locales — deliberate, because the alternatives are gluing localized fragments or matching on English. The strict read costs an extra ownership sweep on each replacement, and it refuses in cases the tolerant read would have sailed through — that is the point, but it does mean a flaky pane set now blocks AI Review instead of silently double-launching. `auxPaneTitle("columnAgent")` is the generic "Column Agent" in the native pane picker, since the label is derived from the launch command and cannot know which column launched it; the pane's own OSC title is still the real column name.
2020

2121
## Alternatives considered
2222

src/bun/__tests__/ai-review-move-entry.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ const mocks = vi.hoisted(() => ({
2626
// native pane runtime
2727
nativeTaskPanesState: vi.fn(),
2828
nativeTaskPaneCommands: vi.fn(async () => [] as any[]),
29+
// Serves the same fake pane list as the tolerant read; the undecidable and
30+
// unreadable-record cases run against the real discovery path in
31+
// column-agent-strict-discovery.test.ts.
32+
nativeTaskPaneCommandsStrict: vi.fn(async () => ({ kind: "read", panes: await mocks.nativeTaskPaneCommands(), unreadable: [] as string[] })),
2933
nativeTaskPaneCommandsOf: vi.fn(() => [] as any[]),
3034
splitNativeTaskPane: vi.fn(),
3135
closeNativeTaskPane: vi.fn(),
@@ -99,6 +103,7 @@ vi.mock("../artifact-template", () => ({ ensureArtifactTemplateEnv: () => ({}) }
99103
vi.mock("../native-task-panes", () => ({
100104
nativeTaskPanesState: mocks.nativeTaskPanesState,
101105
nativeTaskPaneCommands: mocks.nativeTaskPaneCommands,
106+
nativeTaskPaneCommandsStrict: mocks.nativeTaskPaneCommandsStrict,
102107
nativeTaskPaneCommandsOf: mocks.nativeTaskPaneCommandsOf,
103108
splitNativeTaskPane: mocks.splitNativeTaskPane,
104109
closeNativeTaskPane: mocks.closeNativeTaskPane,

src/bun/__tests__/column-agent-pane-recovery.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,16 @@ vi.mock("../native-task-panes", async (importOriginal) => {
7676
return {
7777
...actual,
7878
nativeTaskPanesState: vi.fn(async () => state()),
79-
// REAL: reads each pane's launch command back from its on-disk record.
79+
// REAL: both reads take each pane's launch command back off its on-disk record.
8080
nativeTaskPaneCommands: vi.fn(async () => actual.nativeTaskPaneCommandsOf(state())),
81+
nativeTaskPaneCommandsStrict: vi.fn(async () => {
82+
const panes = actual.nativeTaskPaneCommandsOf(state());
83+
return {
84+
kind: "read" as const,
85+
panes,
86+
unreadable: panes.filter((pane) => pane.command.length === 0).map((pane) => pane.paneId),
87+
};
88+
}),
8189
splitNativeTaskPane: vi.fn(async () => ({ paneId: "pane-new", state: state() })),
8290
// A real close: the record leaves the disk, so the verification re-read has
8391
// something honest to look at.
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/**
2+
* "I could not tell" must never read as "there is nothing there" — over the REAL
3+
* native discovery path.
4+
*
5+
* Three production behaviours each collapse an undecidable read into an empty
6+
* list, and any of them would let a second review agent open beside a live one:
7+
*
8+
* • `NativeTerminalBackend.readPaneSet` catches every recovery exception and
9+
* returns `null`;
10+
* • `nativeTaskPaneCommands` turns a `null` pane set into `[]`;
11+
* • `nativeTaskPaneCommandsOf` turns a pane whose own record is unreadable into
12+
* `command: []`, so it matches no marker.
13+
*
14+
* So nothing here mocks the module under test. The registry directory is real, the
15+
* records are real files, and the failures are injected where they actually happen:
16+
* in `recoverPaneSet` and in the per-pane record on disk.
17+
*/
18+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
20+
import { tmpdir } from "node:os";
21+
import { join } from "node:path";
22+
import { NATIVE_SESSIONS_DIR_ENV, recordFile, sessionDir } from "../native-terminal-registry/paths";
23+
import {
24+
NATIVE_SESSION_SCHEMA_VERSION,
25+
writeRecordAtomic,
26+
type NativeSessionRecord,
27+
} from "../native-terminal-registry/record";
28+
import type { Task } from "../../shared/types";
29+
30+
const TASK_ID = "dddddddd-0000-0000-0000-000000000004";
31+
const SOCKET = "dev3-sock";
32+
const nativeTask = { id: TASK_ID, seq: 909, terminalBackend: "native" } as unknown as Task;
33+
34+
/** What the coordinator's recovery does when asked for this task's pane set. */
35+
let recovery: () => Promise<{ panes: { paneId: string; sessionId: string }[] } | null>;
36+
37+
vi.mock("../logger", () => ({
38+
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
39+
}));
40+
vi.mock("../spawn", () => ({ spawn: vi.fn(), spawnSync: vi.fn() }));
41+
vi.mock("../tmux", () => ({
42+
PANE_START_COMMAND_FORMAT: { formatString: "", parse: () => [] },
43+
TmuxError: class extends Error {},
44+
taskSessionName: (taskId: string) => `dev3-${taskId.slice(0, 8)}`,
45+
tmux: {
46+
listPanes: vi.fn(() => {
47+
throw new Error("a native task must not reach tmux");
48+
}),
49+
splitWindow: vi.fn(() => {
50+
throw new Error("a native task must not reach tmux");
51+
}),
52+
selectPane: vi.fn(),
53+
killPane: vi.fn(() => {
54+
throw new Error("a native task must not reach tmux");
55+
}),
56+
},
57+
}));
58+
59+
// The ONE injection point: the coordinator's own recovery. Everything above it —
60+
// readPaneSet's catch, buildState's null handling, the per-pane record read — is
61+
// the real production code.
62+
vi.mock("../native-terminal-multipane/coordinator", async (importOriginal) => {
63+
const actual = await importOriginal<typeof import("../native-terminal-multipane/coordinator")>();
64+
return {
65+
...actual,
66+
NativeMultipaneCoordinator: class {
67+
static async recoverPaneSet() {
68+
const recovered = await recovery();
69+
if (!recovered) return null;
70+
return {
71+
coordinator: { layout: { activePaneId: recovered.panes[0]?.paneId ?? "", root: null } },
72+
panes: recovered.panes.map(({ paneId, sessionId }) => ({
73+
paneId,
74+
sessionId,
75+
hostPid: 1,
76+
shellPid: 2,
77+
cols: 80,
78+
rows: 24,
79+
state: "alive",
80+
})),
81+
};
82+
}
83+
},
84+
};
85+
});
86+
vi.mock("../../shared/split-tree", async (importOriginal) => ({
87+
...(await importOriginal<typeof import("../../shared/split-tree")>()),
88+
serializeSplitTree: () => "layout",
89+
}));
90+
91+
const {
92+
auxPaneMarker,
93+
findAuxPanes,
94+
openAuxPane,
95+
AuxPaneUndecidableError,
96+
} = await import("../task-aux-panes");
97+
const { splitNativeTaskPane } = await import("../native-task-panes");
98+
const nativePanes = await import("../native-task-panes");
99+
100+
let sessionsDir: string;
101+
102+
function record(sessionId: string, paneId: string, command: string[]): NativeSessionRecord {
103+
return {
104+
schemaVersion: NATIVE_SESSION_SCHEMA_VERSION,
105+
sessionId,
106+
paneId,
107+
protocolVersion: 1,
108+
hostArtifactVersion: "1",
109+
runtimeVersion: "1.3.14",
110+
platform: "darwin",
111+
host: { pid: 1, executable: "/bin/bun", startSignature: "1@t0" },
112+
shell: { pid: 2, command, startSignature: "2@t0" },
113+
endpoint: { transport: "ws", address: "127.0.0.1", port: 51234 },
114+
ownership: { evidenceKind: "posix-start-signature" },
115+
cols: 80,
116+
rows: 24,
117+
createdAt: "2026-08-02T00:00:00.000Z",
118+
updatedAt: "2026-08-02T00:00:00.000Z",
119+
};
120+
}
121+
122+
function columnSpec() {
123+
const marker = auxPaneMarker(TASK_ID, "columnAgent");
124+
return {
125+
task: nativeTask,
126+
purpose: "columnAgent" as const,
127+
placement: "right" as const,
128+
size: "40%",
129+
cwd: "/tmp/wt",
130+
socket: SOCKET,
131+
title: "AI Review",
132+
tmuxCommand: `bash "${marker}"`,
133+
nativeLaunch: { executable: "/bin/bash", argv: [marker] },
134+
};
135+
}
136+
137+
beforeEach(() => {
138+
vi.clearAllMocks();
139+
sessionsDir = mkdtempSync(join(tmpdir(), "dev3-strict-discovery-"));
140+
process.env[NATIVE_SESSIONS_DIR_ENV] = sessionsDir;
141+
nativePanes._resetBackendForTests();
142+
recovery = async () => null;
143+
});
144+
145+
afterEach(() => {
146+
delete process.env[NATIVE_SESSIONS_DIR_ENV];
147+
rmSync(sessionsDir, { recursive: true, force: true });
148+
});
149+
150+
describe("strict native discovery for a proven replacement", () => {
151+
it("refuses when recovery throws, instead of reading it as an empty pane set", async () => {
152+
recovery = async () => {
153+
throw new Error("ownership sweep failed");
154+
};
155+
156+
// The tolerant read is what production does elsewhere, and it hides this.
157+
await expect(nativePanes.nativeTaskPaneCommands(TASK_ID)).resolves.toEqual([]);
158+
// The replacement path must not accept that answer.
159+
await expect(findAuxPanes(nativeTask, "columnAgent", SOCKET, { strict: true })).rejects.toThrow(/ownership sweep/);
160+
await expect(openAuxPane(columnSpec())).rejects.toBeInstanceOf(AuxPaneUndecidableError);
161+
expect(splitNativeTaskPane).toBeDefined();
162+
});
163+
164+
it("refuses when a pane's own launch command cannot be read", async () => {
165+
const marker = auxPaneMarker(TASK_ID, "columnAgent");
166+
writeRecordAtomic(record("sess-agent", "pane-1", ["/bin/zsh"]));
167+
writeRecordAtomic(record("sess-review", "pane-9", ["/bin/bash", marker]));
168+
// A record that exists but cannot be parsed — the case that silently becomes
169+
// `command: []` and therefore matches no marker.
170+
writeFileSync(join(sessionDir("sess-review"), "record.json"), "{ not json");
171+
recovery = async () => ({
172+
panes: [
173+
{ paneId: "pane-1", sessionId: "sess-agent" },
174+
{ paneId: "pane-9", sessionId: "sess-review" },
175+
],
176+
});
177+
178+
// Tolerant read: the review pane looks like it is not there at all.
179+
const tolerant = await nativePanes.nativeTaskPaneCommands(TASK_ID);
180+
expect(tolerant.find((pane) => pane.paneId === "pane-9")?.command).toEqual([]);
181+
// Strict read refuses rather than opening a second agent beside it.
182+
await expect(openAuxPane(columnSpec())).rejects.toBeInstanceOf(AuxPaneUndecidableError);
183+
});
184+
185+
it("treats a genuinely absent pane set as owning nothing, not as undecidable", async () => {
186+
recovery = async () => null;
187+
188+
await expect(findAuxPanes(nativeTask, "columnAgent", SOCKET, { strict: true })).resolves.toEqual([]);
189+
});
190+
191+
it("keeps the tolerant read tolerant for the best-effort purposes", async () => {
192+
recovery = async () => {
193+
throw new Error("ownership sweep failed");
194+
};
195+
196+
await expect(findAuxPanes(nativeTask, "devServer", SOCKET)).resolves.toEqual([]);
197+
});
198+
199+
it("reads a healthy pane set through the real record files", async () => {
200+
const marker = auxPaneMarker(TASK_ID, "columnAgent");
201+
writeRecordAtomic(record("sess-agent", "pane-1", ["/bin/zsh"]));
202+
writeRecordAtomic(record("sess-review", "pane-9", ["/bin/bash", marker]));
203+
recovery = async () => ({
204+
panes: [
205+
{ paneId: "pane-1", sessionId: "sess-agent" },
206+
{ paneId: "pane-9", sessionId: "sess-review" },
207+
],
208+
});
209+
210+
await expect(findAuxPanes(nativeTask, "columnAgent", SOCKET, { strict: true })).resolves.toEqual([
211+
{ backend: "native", paneId: "pane-9" },
212+
]);
213+
expect(recordFile("sess-review")).toContain("sess-review");
214+
});
215+
});

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ const mockNativePanes = {
159159
nativeTaskPaneLayout: vi.fn(async (..._args: any[]) => null as any),
160160
nativeTaskPanesAlive: vi.fn(async () => false),
161161
nativeTaskPaneCommands: vi.fn(async () => [] as any[]),
162+
// Serves the same fake pane list as the tolerant read; the undecidable and
163+
// unreadable-record cases run against the real discovery path in
164+
// column-agent-strict-discovery.test.ts.
165+
nativeTaskPaneCommandsStrict: vi.fn(async () => ({ kind: "read", panes: await mockNativePanes.nativeTaskPaneCommands(), unreadable: [] as string[] })),
162166
nativeTaskPaneCommandsOf: vi.fn(() => [] as any[]),
163167
splitNativeTaskPane: vi.fn(async (..._args: any[]) => null as any),
164168
closeNativeTaskPane: vi.fn(async (..._args: any[]) => ({ sessionTornDown: false, state: null }) as any),

src/bun/__tests__/task-aux-panes.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({
2626
// native-task-panes
2727
nativeTaskPanesState: vi.fn(),
2828
nativeTaskPaneCommands: vi.fn(),
29+
nativeTaskPaneCommandsStrict: vi.fn(),
2930
splitNativeTaskPane: vi.fn(),
3031
closeNativeTaskPane: vi.fn(),
3132
focusNativeTaskPane: vi.fn(),
@@ -56,6 +57,10 @@ vi.mock("../tmux", () => ({
5657
vi.mock("../native-task-panes", () => ({
5758
nativeTaskPanesState: mocks.nativeTaskPanesState,
5859
nativeTaskPaneCommands: mocks.nativeTaskPaneCommands,
60+
// The strict read answers from the same fake pane list; the cases that make it
61+
// throw or report an unreadable record live in column-agent-strict-discovery,
62+
// where the production discovery path runs for real.
63+
nativeTaskPaneCommandsStrict: mocks.nativeTaskPaneCommandsStrict,
5964
splitNativeTaskPane: mocks.splitNativeTaskPane,
6065
closeNativeTaskPane: mocks.closeNativeTaskPane,
6166
focusNativeTaskPane: mocks.focusNativeTaskPane,
@@ -128,6 +133,11 @@ beforeEach(() => {
128133
vi.clearAllMocks();
129134
mocks.nativeTaskPanesState.mockResolvedValue(nativeState(["pane-1"], "pane-1"));
130135
mocks.nativeTaskPaneCommands.mockResolvedValue([]);
136+
mocks.nativeTaskPaneCommandsStrict.mockImplementation(async () => ({
137+
kind: "read",
138+
panes: await mocks.nativeTaskPaneCommands(),
139+
unreadable: [],
140+
}));
131141
mocks.splitNativeTaskPane.mockResolvedValue({ paneId: "pane-2", state: nativeState(["pane-1", "pane-2"], "pane-2") });
132142
mocks.closeNativeTaskPane.mockResolvedValue({ sessionTornDown: false, state: nativeState(["pane-1"], "pane-1") });
133143
mocks.focusNativeTaskPane.mockResolvedValue(nativeState(["pane-1", "pane-2"], "pane-1"));

src/bun/native-task-panes.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,21 @@ function coordinatorId(taskId: string): string {
9696
* `null` means no pane survived — the same verdict `describeSession` returns.
9797
*/
9898
async function buildState(taskId: string): Promise<NativeTaskPanesState | null> {
99-
const paneSet = await getBackend().readPaneSet(coordinatorId(taskId));
99+
return shapeState(taskId, await getBackend().readPaneSet(coordinatorId(taskId)));
100+
}
101+
102+
/**
103+
* {@link buildState} that does not hide an undecidable read: `null` means recovery
104+
* ran and no pane survived, while a throw means the pane set could not be read.
105+
*/
106+
async function buildStateStrict(taskId: string): Promise<NativeTaskPanesState | null> {
107+
return shapeState(taskId, await getBackend().readPaneSetStrict(coordinatorId(taskId)));
108+
}
109+
110+
/** Derived from the backend method so this module keeps its single import seam. */
111+
type ReadPaneSet = Awaited<ReturnType<NativeTerminalBackend["readPaneSet"]>>;
112+
113+
function shapeState(taskId: string, paneSet: ReadPaneSet): NativeTaskPanesState | null {
100114
if (!paneSet) return null;
101115
return {
102116
taskId,
@@ -343,6 +357,40 @@ export async function nativeTaskPaneCommands(taskId: string): Promise<NativeTask
343357
return state ? nativeTaskPaneCommandsOf(state) : [];
344358
}
345359

360+
/**
361+
* What a caller learns when "I could not tell" must not be reported as "there is
362+
* nothing". `absent` is a real answer — recovery ran and this task owns no pane
363+
* set. Anything it could not determine throws instead, and a pane whose own record
364+
* is unreadable is listed in `unreadable`: its launch command is unknown, so no
365+
* caller may claim the pane is not theirs.
366+
*/
367+
export type NativeTaskPaneCommandsRead =
368+
| { kind: "absent" }
369+
| { kind: "read"; panes: NativeTaskPaneCommand[]; unreadable: string[] };
370+
371+
export async function nativeTaskPaneCommandsStrict(taskId: string): Promise<NativeTaskPaneCommandsRead> {
372+
const state = await buildStateStrict(taskId);
373+
if (!state) return { kind: "absent" };
374+
const panes = state.panes.map((pane) => {
375+
const record = readRecord(pane.sessionId);
376+
return {
377+
pane: {
378+
paneId: pane.paneId,
379+
sessionId: pane.sessionId,
380+
command: record?.shell.command ?? [],
381+
shellPid: pane.shellPid,
382+
alive: pane.alive,
383+
},
384+
readable: record !== null,
385+
};
386+
});
387+
return {
388+
kind: "read",
389+
panes: panes.map((entry) => entry.pane),
390+
unreadable: panes.filter((entry) => !entry.readable).map((entry) => entry.pane.paneId),
391+
};
392+
}
393+
346394
/**
347395
* True when the coordinator record exists and contains at least one owned pane.
348396
* Read-only: does NOT register or cache the recovered coordinator as a side effect.

0 commit comments

Comments
 (0)