Skip to content

Commit 7c6e439

Browse files
h0x91b-wixh0x91b
authored andcommitted
Refuse a proven replacement when a pane's own ownership cannot be established
1 parent 495e5a8 commit 7c6e439

6 files changed

Lines changed: 236 additions & 156 deletions

File tree

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ 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. 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.
14-
15-
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.
13+
The pane is owned by purpose, not by a remembered id: `launchColumnAgent` goes through `openAuxPane` under a new `AuxPanePurpose` value `columnAgent` whose marker is the existing `col-agent.sh` temp path, the `col-agent-pane` id file is gone, and `replaceAuxPanes` closes every pane the purpose owns and re-reads the set to prove they went — a launch that cannot prove it refuses rather than risk two agents in one worktree.
14+
Proof covers the LOOKUP too, because several production paths turn an undecidable read into an empty list (`readPaneSet` catches every recovery exception, a `null` pane set becomes `[]`, an unreadable pane record becomes `command: []`, a tmux error becomes no rows), so the replacement path reads through `readPaneSetStrict` / `nativeTaskPaneCommandsStrict` and, at the root, through `recoverPaneSet(..., { strict: true })` — which throws `PaneOwnershipUnknownError` before reconciling an unknown-owner pane away, since sweeping it would delete the evidence while its shell keeps running.
15+
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.
16+
The failure is reported as well as parked: `columnAgentFailed` carries `column: ColumnAgentIdentity`, `movedTo?` and `reason?`, `columnAgentFailureCopy` picks one of four localized keys and localizes a built-in column from its status, and the report is emitted only after the fallback move's column write lands — a rejected or failed write stops the effect run, so the toast can never claim a move that did not happen, and the renderer never reads the English `error` string.
1617

1718
## Risks
1819

Lines changed: 136 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,50 @@
11
/**
2-
* "I could not tell" must never read as "there is nothing there" — over the REAL
3-
* native discovery path.
2+
* "I could not tell" must never read as "there is nothing there" — through the REAL
3+
* coordinator, over REAL registry files.
44
*
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:
5+
* The trap is not one function but a chain, and every link is production code here:
6+
* `probePane` reads a pane's record and, finding none, used to call it dead;
7+
* `recoverPaneSet` then filtered that pane out of the set, rewrote the coordinator
8+
* record and called `stopPane`, which reports success for a record it cannot read
9+
* without ever proving the process died. So the pane vanished, its shell survived,
10+
* and the strict command read never saw it — a second review agent could open
11+
* beside a live unknown process.
712
*
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.
13+
* Nothing about that chain is mocked. The coordinator is the real class, the
14+
* coordinator and pane records are real files in real temp dirs, and record reads
15+
* go through the real registry. Only three deps are controlled, because they leave
16+
* the machine: pane start, pane stop, and the `ps` ownership probe — and start/stop
17+
* are asserted to stay untouched, which is the point of the strict path.
1718
*/
1819
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1920
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2021
import { tmpdir } from "node:os";
2122
import { join } from "node:path";
22-
import { NATIVE_SESSIONS_DIR_ENV, recordFile, sessionDir } from "../native-terminal-registry/paths";
23+
import { createSplitTree, listPaneIds, serializeSplitTree, splitPane } from "../../shared/split-tree";
24+
import { NATIVE_SESSIONS_DIR_ENV, sessionDir } from "../native-terminal-registry/paths";
2325
import {
2426
NATIVE_SESSION_SCHEMA_VERSION,
2527
writeRecordAtomic,
2628
type NativeSessionRecord,
2729
} from "../native-terminal-registry/record";
30+
import { NATIVE_MULTIPANE_DIR_ENV } from "../native-terminal-multipane/paths";
31+
import {
32+
NATIVE_MULTIPANE_SCHEMA_VERSION,
33+
readMultipaneRecord,
34+
writeMultipaneRecordAtomic,
35+
} from "../native-terminal-multipane/record";
2836
import type { Task } from "../../shared/types";
2937

3038
const TASK_ID = "dddddddd-0000-0000-0000-000000000004";
39+
const COORD_ID = `dev3-task-${TASK_ID}`;
3140
const SOCKET = "dev3-sock";
3241
const nativeTask = { id: TASK_ID, seq: 909, terminalBackend: "native" } as unknown as Task;
3342

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>;
43+
const mocks = vi.hoisted(() => ({
44+
registryStart: vi.fn(),
45+
registryStop: vi.fn(async () => true),
46+
classifyOwnership: vi.fn(async () => "owned" as const),
47+
}));
3648

3749
vi.mock("../logger", () => ({
3850
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
@@ -56,50 +68,26 @@ vi.mock("../tmux", () => ({
5668
},
5769
}));
5870

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",
71+
// Only what would leave the machine. Record reads stay real, so the coordinator
72+
// still decides from the files on disk.
73+
vi.mock("../native-terminal-registry/registry", async (importOriginal) => ({
74+
...(await importOriginal<typeof import("../native-terminal-registry/registry")>()),
75+
start: mocks.registryStart,
76+
stop: mocks.registryStop,
77+
}));
78+
vi.mock("../native-terminal-registry/ownership", async (importOriginal) => ({
79+
...(await importOriginal<typeof import("../native-terminal-registry/ownership")>()),
80+
classifyOwnership: mocks.classifyOwnership,
8981
}));
9082

91-
const {
92-
auxPaneMarker,
93-
findAuxPanes,
94-
openAuxPane,
95-
AuxPaneUndecidableError,
96-
} = await import("../task-aux-panes");
97-
const { splitNativeTaskPane } = await import("../native-task-panes");
83+
const { auxPaneMarker, findAuxPanes, openAuxPane, AuxPaneUndecidableError } = await import("../task-aux-panes");
9884
const nativePanes = await import("../native-task-panes");
85+
const { PaneOwnershipUnknownError } = await import("../native-terminal-multipane/coordinator");
9986

10087
let sessionsDir: string;
88+
let multipaneDir: string;
10189

102-
function record(sessionId: string, paneId: string, command: string[]): NativeSessionRecord {
90+
function paneRecord(sessionId: string, paneId: string, command: string[]): NativeSessionRecord {
10391
return {
10492
schemaVersion: NATIVE_SESSION_SCHEMA_VERSION,
10593
sessionId,
@@ -108,8 +96,8 @@ function record(sessionId: string, paneId: string, command: string[]): NativeSes
10896
hostArtifactVersion: "1",
10997
runtimeVersion: "1.3.14",
11098
platform: "darwin",
111-
host: { pid: 1, executable: "/bin/bun", startSignature: "1@t0" },
112-
shell: { pid: 2, command, startSignature: "2@t0" },
99+
host: { pid: 4242, executable: "/bin/bun", startSignature: "4242@t0" },
100+
shell: { pid: 4243, command, startSignature: "4243@t0" },
113101
endpoint: { transport: "ws", address: "127.0.0.1", port: 51234 },
114102
ownership: { evidenceKind: "posix-start-signature" },
115103
cols: 80,
@@ -119,6 +107,31 @@ function record(sessionId: string, paneId: string, command: string[]): NativeSes
119107
};
120108
}
121109

110+
/** Two panes on disk: the task's own agent, and a review agent beside it. */
111+
function writeTwoPaneSet(reviewCommand: string[]): void {
112+
let tree = createSplitTree();
113+
const agentPane = listPaneIds(tree)[0]!;
114+
tree = splitPane(tree, agentPane, "horizontal");
115+
const reviewPane = listPaneIds(tree).find((paneId) => paneId !== agentPane)!;
116+
writeRecordAtomic(paneRecord(`${COORD_ID}-${agentPane}`, agentPane, ["/bin/zsh"]));
117+
writeRecordAtomic(paneRecord(`${COORD_ID}-${reviewPane}`, reviewPane, reviewCommand));
118+
writeMultipaneRecordAtomic({
119+
schemaVersion: NATIVE_MULTIPANE_SCHEMA_VERSION,
120+
coordinatorId: COORD_ID,
121+
epoch: "epoch-1",
122+
updatedAt: "2026-08-02T00:00:00.000Z",
123+
layout: serializeSplitTree(tree),
124+
panes: [
125+
{ paneId: agentPane, sessionId: `${COORD_ID}-${agentPane}` },
126+
{ paneId: reviewPane, sessionId: `${COORD_ID}-${reviewPane}` },
127+
],
128+
});
129+
}
130+
131+
function reviewPaneSessionId(): string {
132+
return readMultipaneRecord(COORD_ID)!.panes[1]!.sessionId;
133+
}
134+
122135
function columnSpec() {
123136
const marker = auxPaneMarker(TASK_ID, "columnAgent");
124137
return {
@@ -136,80 +149,94 @@ function columnSpec() {
136149

137150
beforeEach(() => {
138151
vi.clearAllMocks();
139-
sessionsDir = mkdtempSync(join(tmpdir(), "dev3-strict-discovery-"));
152+
mocks.registryStop.mockResolvedValue(true);
153+
mocks.classifyOwnership.mockResolvedValue("owned");
154+
sessionsDir = mkdtempSync(join(tmpdir(), "dev3-strict-sessions-"));
155+
multipaneDir = mkdtempSync(join(tmpdir(), "dev3-strict-multipane-"));
140156
process.env[NATIVE_SESSIONS_DIR_ENV] = sessionsDir;
157+
process.env[NATIVE_MULTIPANE_DIR_ENV] = multipaneDir;
141158
nativePanes._resetBackendForTests();
142-
recovery = async () => null;
143159
});
144160

145161
afterEach(() => {
146162
delete process.env[NATIVE_SESSIONS_DIR_ENV];
163+
delete process.env[NATIVE_MULTIPANE_DIR_ENV];
147164
rmSync(sessionsDir, { recursive: true, force: true });
165+
rmSync(multipaneDir, { recursive: true, force: true });
148166
});
149167

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-
};
168+
describe("strict native discovery, through the real coordinator", () => {
169+
it("finds the review pane when every record is readable", async () => {
170+
writeTwoPaneSet(["/bin/bash", auxPaneMarker(TASK_ID, "columnAgent")]);
155171

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();
172+
const found = await findAuxPanes(nativeTask, "columnAgent", SOCKET, { strict: true });
173+
174+
expect(found).toHaveLength(1);
175+
expect(mocks.registryStop).not.toHaveBeenCalled();
162176
});
163177

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.
178+
it("refuses instead of reconciling away a pane whose record is corrupt", async () => {
179+
writeTwoPaneSet(["/bin/bash", auxPaneMarker(TASK_ID, "columnAgent")]);
180+
const reviewSession = reviewPaneSessionId();
181+
const before = readMultipaneRecord(COORD_ID)!;
182+
// The record file exists but no longer parses — the process it described may
183+
// well still be running.
184+
writeFileSync(join(sessionDir(reviewSession), "record.json"), "{ not json");
185+
186+
// What tolerant recovery does with it, i.e. the trap: the pane is swept out of
187+
// the set, the coordinator record is rewritten, and stop() is told to drop it.
179188
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.
189+
expect(tolerant.map((pane) => pane.sessionId)).not.toContain(reviewSession);
190+
// Whatever tolerant recovery decides to do with it, the pane is invisible to
191+
// the caller — which is precisely why the strict path may not reuse that answer.
192+
expect(tolerant.map((pane) => pane.sessionId)).not.toContain(reviewSession);
193+
194+
// Rebuild the same situation and take the strict path instead.
195+
vi.clearAllMocks();
196+
mocks.registryStop.mockResolvedValue(true);
197+
mocks.classifyOwnership.mockResolvedValue("owned");
198+
nativePanes._resetBackendForTests();
199+
writeTwoPaneSet(["/bin/bash", auxPaneMarker(TASK_ID, "columnAgent")]);
200+
const rebuilt = readMultipaneRecord(COORD_ID)!;
201+
writeFileSync(join(sessionDir(reviewPaneSessionId()), "record.json"), "{ not json");
202+
182203
await expect(openAuxPane(columnSpec())).rejects.toBeInstanceOf(AuxPaneUndecidableError);
204+
205+
// No new pane, no process touched, and the set is exactly as it was found.
206+
expect(mocks.registryStart).not.toHaveBeenCalled();
207+
expect(mocks.registryStop).not.toHaveBeenCalled();
208+
expect(readMultipaneRecord(COORD_ID)).toEqual(rebuilt);
209+
expect(before.panes).toHaveLength(2);
183210
});
184211

185-
it("treats a genuinely absent pane set as owning nothing, not as undecidable", async () => {
186-
recovery = async () => null;
212+
it("refuses when a pane's record file is missing outright, which the tolerant read hides", async () => {
213+
writeTwoPaneSet(["/bin/bash", auxPaneMarker(TASK_ID, "columnAgent")]);
214+
const reviewSession = reviewPaneSessionId();
215+
const snapshot = readMultipaneRecord(COORD_ID)!;
216+
rmSync(sessionDir(reviewSession), { recursive: true, force: true });
217+
218+
// Strict first, so the assertions below describe an untouched set.
219+
await expect(findAuxPanes(nativeTask, "columnAgent", SOCKET, { strict: true }))
220+
.rejects.toBeInstanceOf(PaneOwnershipUnknownError);
221+
expect(mocks.registryStart).not.toHaveBeenCalled();
222+
expect(mocks.registryStop).not.toHaveBeenCalled();
223+
expect(readMultipaneRecord(COORD_ID)).toEqual(snapshot);
224+
225+
// The tolerant read, by contrast, cannot see the pane at all — it is the answer
226+
// the proven-replacement path must not reuse.
227+
const tolerant = await nativePanes.nativeTaskPaneCommands(TASK_ID);
228+
expect(tolerant.map((pane) => pane.sessionId)).not.toContain(reviewSession);
229+
});
187230

231+
it("treats a genuinely absent pane set as owning nothing, not as undecidable", async () => {
188232
await expect(findAuxPanes(nativeTask, "columnAgent", SOCKET, { strict: true })).resolves.toEqual([]);
189233
});
190234

191-
it("keeps the tolerant read tolerant for the best-effort purposes", async () => {
192-
recovery = async () => {
193-
throw new Error("ownership sweep failed");
194-
};
235+
it("keeps tolerant recovery tolerant for the best-effort purposes", async () => {
236+
writeTwoPaneSet(["/bin/bash", auxPaneMarker(TASK_ID, "devServer")]);
237+
rmSync(sessionDir(reviewPaneSessionId()), { recursive: true, force: true });
195238

239+
// devServer deliberately still reads a swept pane as "not there".
196240
await expect(findAuxPanes(nativeTask, "devServer", SOCKET)).resolves.toEqual([]);
197241
});
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-
});
215242
});

0 commit comments

Comments
 (0)