Skip to content

Commit d90ec30

Browse files
committed
Keep unknown pane ownership legible through tolerant reads
Ordinary renderer polling could erase the very evidence the strict column-agent launch depends on: tolerant recovery swept a pane whose record it could not read out of the coordinator record, so a later strict read found a clean set and could open a second review agent beside a process nobody can account for. A pane whose record is present but untrustworthy is now marked ownership-unknown and kept in the record on both paths, while a pane that left no record at all is still swept as the dead pane it is. The coordinator record gets the same treatment via a strict read that separates ENOENT from corrupt. Failure copy selection is now exhaustive over the reason codes, so a new recognised reason cannot compile without its localized copy.
1 parent 7c6e439 commit d90ec30

6 files changed

Lines changed: 217 additions & 40 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Reproduced on a native task through the card's status menu: `launchColumnAgent S
1212

1313
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.
1414
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+
The line is drawn at whether there was anything to read: no record and no coordinator file mean the pane really is gone and it is swept exactly as before, while a record (or coordinator record) that is present and untrustworthy — corrupt, foreign-schema, unreadable — marks the pane ownership-unknown, and `recoverPaneSet` keeps that pane in the record even on the tolerant path, so ordinary renderer polling can no longer erase the evidence before a strict launch reads it.
1516
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.
1617
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.
1718

src/bun/__tests__/column-agent-strict-discovery.test.ts

Lines changed: 91 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* are asserted to stay untouched, which is the point of the strict path.
1818
*/
1919
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
20+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2121
import { tmpdir } from "node:os";
2222
import { join } from "node:path";
2323
import { createSplitTree, listPaneIds, serializeSplitTree, splitPane } from "../../shared/split-tree";
@@ -27,7 +27,7 @@ import {
2727
writeRecordAtomic,
2828
type NativeSessionRecord,
2929
} from "../native-terminal-registry/record";
30-
import { NATIVE_MULTIPANE_DIR_ENV } from "../native-terminal-multipane/paths";
30+
import { NATIVE_MULTIPANE_DIR_ENV, coordinatorRecordFile } from "../native-terminal-multipane/paths";
3131
import {
3232
NATIVE_MULTIPANE_SCHEMA_VERSION,
3333
readMultipaneRecord,
@@ -187,9 +187,10 @@ describe("strict native discovery, through the real coordinator", () => {
187187
// the set, the coordinator record is rewritten, and stop() is told to drop it.
188188
const tolerant = await nativePanes.nativeTaskPaneCommands(TASK_ID);
189189
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);
190+
// Whatever tolerant recovery decides to show, the pane's own record must stay
191+
// on disk: erasing it is what would let a later strict read answer "nothing
192+
// here" for a process that is still running.
193+
expect(readMultipaneRecord(COORD_ID)!.panes.map((pane) => pane.sessionId)).toContain(reviewSession);
193194

194195
// Rebuild the same situation and take the strict path instead.
195196
vi.clearAllMocks();
@@ -209,23 +210,98 @@ describe("strict native discovery, through the real coordinator", () => {
209210
expect(before.panes).toHaveLength(2);
210211
});
211212

212-
it("refuses when a pane's record file is missing outright, which the tolerant read hides", async () => {
213+
it("refuses on a record written by a schema it does not understand, too", async () => {
213214
writeTwoPaneSet(["/bin/bash", auxPaneMarker(TASK_ID, "columnAgent")]);
214215
const reviewSession = reviewPaneSessionId();
215216
const snapshot = readMultipaneRecord(COORD_ID)!;
217+
// Parses fine, describes a live host, and says nothing this version can act on:
218+
// another installed version of the app may own that process.
219+
writeFileSync(
220+
join(sessionDir(reviewSession), "record.json"),
221+
JSON.stringify({ schemaVersion: NATIVE_SESSION_SCHEMA_VERSION + 7, sessionId: reviewSession }),
222+
);
223+
224+
await expect(findAuxPanes(nativeTask, "columnAgent", SOCKET, { strict: true })).rejects.toBeInstanceOf(
225+
PaneOwnershipUnknownError,
226+
);
227+
expect(mocks.registryStart).not.toHaveBeenCalled();
228+
expect(mocks.registryStop).not.toHaveBeenCalled();
229+
expect(readMultipaneRecord(COORD_ID)).toEqual(snapshot);
230+
});
231+
232+
// Where the line is drawn, and why it is not drawn at "the record is unreadable
233+
// for any reason at all". A finished pane unlinks its record and then tries to
234+
// remove its directory, which fails whenever a sibling file survives — so "the
235+
// directory is there but holds no record" is an ORDINARY dead pane. Calling that
236+
// undecidable would wedge AI Review on every task that ever closed a pane.
237+
it("sweeps a pane that left its directory behind without a record", async () => {
238+
writeTwoPaneSet(["/bin/bash", auxPaneMarker(TASK_ID, "columnAgent")]);
239+
const reviewSession = reviewPaneSessionId();
240+
rmSync(join(sessionDir(reviewSession), "record.json"), { force: true });
241+
242+
await expect(findAuxPanes(nativeTask, "columnAgent", SOCKET, { strict: true })).resolves.toEqual([]);
243+
expect(readMultipaneRecord(COORD_ID)!.panes.map((pane) => pane.sessionId)).not.toContain(reviewSession);
244+
});
245+
246+
it("sweeps a pane whose session directory is gone entirely", async () => {
247+
writeTwoPaneSet(["/bin/bash", auxPaneMarker(TASK_ID, "columnAgent")]);
248+
const reviewSession = reviewPaneSessionId();
216249
rmSync(sessionDir(reviewSession), { recursive: true, force: true });
217250

218-
// Strict first, so the assertions below describe an untouched set.
219-
await expect(findAuxPanes(nativeTask, "columnAgent", SOCKET, { strict: true }))
220-
.rejects.toBeInstanceOf(PaneOwnershipUnknownError);
251+
await expect(findAuxPanes(nativeTask, "columnAgent", SOCKET, { strict: true })).resolves.toEqual([]);
252+
expect(readMultipaneRecord(COORD_ID)!.panes.map((pane) => pane.sessionId)).not.toContain(reviewSession);
253+
});
254+
255+
// THE REAL SEQUENCE. TaskTerminal polls taskPaneState continuously, so by the time
256+
// the user clicks AI Review the tolerant path has already run several times. If a
257+
// poll had swept the unknown-owner pane out of the coordinator record, the strict
258+
// read that follows would find a clean, empty set and open a second agent beside a
259+
// process nobody can account for.
260+
it("still refuses after renderer-style tolerant polling has already run", async () => {
261+
writeTwoPaneSet(["/bin/bash", auxPaneMarker(TASK_ID, "columnAgent")]);
262+
const reviewSession = reviewPaneSessionId();
263+
const snapshot = readMultipaneRecord(COORD_ID)!;
264+
writeFileSync(join(sessionDir(reviewSession), "record.json"), "{ not json");
265+
266+
// Three polls, exactly as the terminal view would issue them.
267+
for (let poll = 0; poll < 3; poll++) await nativePanes.nativeTaskPanesState(TASK_ID);
268+
269+
// The evidence survived every one of them.
270+
expect(readMultipaneRecord(COORD_ID)).toEqual(snapshot);
271+
expect(mocks.registryStop).not.toHaveBeenCalled();
272+
273+
// And the click that follows is refused, with nothing started.
274+
await expect(openAuxPane(columnSpec())).rejects.toBeInstanceOf(AuxPaneUndecidableError);
221275
expect(mocks.registryStart).not.toHaveBeenCalled();
222276
expect(mocks.registryStop).not.toHaveBeenCalled();
223277
expect(readMultipaneRecord(COORD_ID)).toEqual(snapshot);
278+
});
224279

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);
280+
it("hides the unidentifiable pane from a tolerant read without deleting it", async () => {
281+
writeTwoPaneSet(["/bin/bash", auxPaneMarker(TASK_ID, "columnAgent")]);
282+
const reviewSession = reviewPaneSessionId();
283+
writeFileSync(join(sessionDir(reviewSession), "record.json"), "{ not json");
284+
285+
const state = await nativePanes.nativeTaskPanesState(TASK_ID);
286+
287+
// The UI does not show a pane it cannot describe...
288+
expect(state!.panes.map((pane) => pane.sessionId)).not.toContain(reviewSession);
289+
// ...but the record still says the task owns it.
290+
expect(readMultipaneRecord(COORD_ID)!.panes.map((pane) => pane.sessionId)).toContain(reviewSession);
291+
});
292+
293+
it("refuses when the COORDINATOR record itself is corrupt, and changes nothing", async () => {
294+
writeTwoPaneSet(["/bin/bash", auxPaneMarker(TASK_ID, "columnAgent")]);
295+
const corrupt = "{ not a coordinator record";
296+
writeFileSync(coordinatorRecordFile(COORD_ID), corrupt);
297+
298+
// The tolerant read cannot tell this from "no pane set at all".
299+
await expect(nativePanes.nativeTaskPanesState(TASK_ID)).resolves.toBeNull();
300+
// The strict one refuses instead of inventing an empty set.
301+
await expect(openAuxPane(columnSpec())).rejects.toBeInstanceOf(AuxPaneUndecidableError);
302+
expect(mocks.registryStart).not.toHaveBeenCalled();
303+
expect(mocks.registryStop).not.toHaveBeenCalled();
304+
expect(readFileSync(coordinatorRecordFile(COORD_ID), "utf8")).toBe(corrupt);
229305
});
230306

231307
it("treats a genuinely absent pane set as owning nothing, not as undecidable", async () => {
@@ -234,9 +310,9 @@ describe("strict native discovery, through the real coordinator", () => {
234310

235311
it("keeps tolerant recovery tolerant for the best-effort purposes", async () => {
236312
writeTwoPaneSet(["/bin/bash", auxPaneMarker(TASK_ID, "devServer")]);
237-
rmSync(sessionDir(reviewPaneSessionId()), { recursive: true, force: true });
313+
writeFileSync(join(sessionDir(reviewPaneSessionId()), "record.json"), "{ not json");
238314

239-
// devServer deliberately still reads a swept pane as "not there".
315+
// devServer deliberately still reads an undecidable pane as "not there".
240316
await expect(findAuxPanes(nativeTask, "devServer", SOCKET)).resolves.toEqual([]);
241317
});
242318
});

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ vi.mock("../spawn", () => ({ spawn: vi.fn(), spawnSync: vi.fn() }));
2222
vi.mock("../native-terminal-registry/record", () => ({
2323
readRecord: vi.fn(() => null),
2424
readToken: vi.fn(() => null),
25+
// Neither a record nor a session directory: a pane that is genuinely gone, which
26+
// is what every case here means by "the host died".
27+
inspectRecordFile: vi.fn(() => ({ ok: false, problem: { kind: "absent" } })),
2528
}));
2629

2730
vi.mock("../native-terminal-registry/registry", () => ({

src/bun/native-terminal-multipane/coordinator.ts

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@ import { withFileLock } from "../file-lock";
2222
import { NativeSessionClient } from "../native-terminal-registry/client";
2323
import { classifyOwnership, type OwnershipVerdict } from "../native-terminal-registry/ownership";
2424
import type { ClientRole } from "../native-terminal-registry/writer-ownership";
25-
import { readRecord, readToken, type NativeSessionRecord } from "../native-terminal-registry/record";
25+
import {
26+
inspectRecordFile,
27+
readRecord,
28+
readToken,
29+
type NativeSessionRecord,
30+
type RecordInspection,
31+
} from "../native-terminal-registry/record";
2632
import { start, stop, type StartOptions, type StartResult } from "../native-terminal-registry/registry";
2733
import type { ShellLaunchSpec } from "../native-terminal-registry/shell-launch";
2834
import { CoordinatorClientView } from "./client-view";
@@ -42,6 +48,7 @@ import {
4248
NATIVE_MULTIPANE_SCHEMA_VERSION,
4349
pruneCoordinatorDir,
4450
readMultipaneRecord,
51+
readMultipaneRecordStrict,
4552
removeMultipaneRecord,
4653
writeMultipaneRecordAtomic,
4754
type MultipanePaneEntry,
@@ -103,6 +110,12 @@ export interface CoordinatorDeps {
103110
startPane(sessionId: string, opts: StartOptions): Promise<StartResult>;
104111
stopPane(sessionId: string, opts?: { timeoutMs?: number }): Promise<boolean>;
105112
readPaneRecord(sessionId: string): NativeSessionRecord | null;
113+
/**
114+
* Why a record was rejected, for the one decision that needs it: telling a
115+
* record that is gone from one that is there but unreadable. Optional so an
116+
* in-memory double need not model file problems.
117+
*/
118+
inspectPaneRecord?(sessionId: string): RecordInspection;
106119
readPaneToken(sessionId: string): string | null;
107120
classifyPane(record: NativeSessionRecord, token: string | null): Promise<OwnershipVerdict>;
108121
connectPane(record: NativeSessionRecord, token: string): Promise<PaneConnection>;
@@ -112,6 +125,7 @@ export const defaultCoordinatorDeps: CoordinatorDeps = {
112125
startPane: (sessionId, opts) => start(sessionId, opts),
113126
stopPane: (sessionId, opts) => stop(sessionId, opts ?? {}),
114127
readPaneRecord: readRecord,
128+
inspectPaneRecord: inspectRecordFile,
115129
readPaneToken: readToken,
116130
classifyPane: classifyOwnership,
117131
async connectPane(record, token) {
@@ -231,7 +245,11 @@ export class NativeMultipaneCoordinator {
231245
return withFileLock(
232246
coordinatorRecordFile(coordinatorId),
233247
async () => {
234-
const record = readMultipaneRecord(coordinatorId);
248+
// A strict caller must not have "no record" invented for it: a coordinator
249+
// file that exists but cannot be parsed means the pane set is unknown.
250+
const record = opts.strict === true
251+
? readMultipaneRecordStrict(coordinatorId)
252+
: readMultipaneRecord(coordinatorId);
235253
if (!record) return null;
236254
const tree = restoreSplitTree(record.layout);
237255
if (!tree) return null;
@@ -240,21 +258,25 @@ export class NativeMultipaneCoordinator {
240258
// to `ps`, so doing them in sequence made recovery cost grow linearly with
241259
// the pane count on the click path (seq 1382).
242260
const probes = await Promise.all(record.panes.map((pane) => probePane(pane, deps)));
243-
// A strict caller is about to decide whether this task already owns a
244-
// pane. Reconciling an unknown-owner pane away first would answer that
245-
// question by deleting the evidence: the pane leaves the set, its shell
246-
// keeps running (stop() reports success for a record it cannot read), and
247-
// the caller opens a second agent beside it. So refuse BEFORE mutating
248-
// anything — no drop, no record rewrite, no membership change.
261+
// A pane whose own record we cannot read is not proof of death: its shell
262+
// may still be running, and `stop()` reports success for a record it
263+
// cannot read without ever signalling the pid. So the EVIDENCE that such
264+
// a pane exists must outlive every read, tolerant ones included — a
265+
// renderer poll that swept it out of the record would answer a later
266+
// strict question by having deleted the answer.
249267
const unknown = probes.filter((probe) => probe.ownershipUnknown);
250268
if (opts.strict === true && unknown.length > 0) {
269+
// Refuse BEFORE mutating anything: no drop, no rewrite, no membership
270+
// change, so the caller sees the set exactly as it was found.
251271
throw new PaneOwnershipUnknownError(
252272
coordinatorId,
253273
unknown.map((probe) => probe.pane.paneId),
254274
);
255275
}
256-
const dead = probes.filter((probe) => probe.verdict !== "owned");
257-
if (dead.length === 0) {
276+
// Only a pane whose death was actually established may be reconciled away.
277+
const dead = probes.filter((probe) => probe.verdict !== "owned" && !probe.ownershipUnknown);
278+
const hidden = unknown.length > 0;
279+
if (dead.length === 0 && !hidden) {
258280
return {
259281
coordinator: new NativeMultipaneCoordinator(coordinatorId, record.epoch, tree, deps),
260282
panes: probes.map(snapshotOf),
@@ -269,11 +291,17 @@ export class NativeMultipaneCoordinator {
269291
for (const probe of dead) reconciled = closeTreePane(reconciled, probe.pane.paneId);
270292
reconciled = normalizeSharedLayout(reconciled);
271293
await dropPanes(dead.map((probe) => probe.pane), deps);
272-
writeMultipaneRecordAtomic(
273-
buildRecord(coordinatorId, record.epoch, reconciled, bindPanes(coordinatorId, reconciled)),
274-
);
294+
// An unknown-owner pane stays in the record and in the tree; it is only
295+
// HIDDEN from this snapshot, so the UI does not show a pane it cannot
296+
// describe while the evidence stays on disk for the next strict read.
297+
if (dead.length > 0) {
298+
writeMultipaneRecordAtomic(
299+
buildRecord(coordinatorId, record.epoch, reconciled, bindPanes(coordinatorId, reconciled)),
300+
);
301+
}
302+
const survivorTree = hidden ? tree : reconciled;
275303
return {
276-
coordinator: new NativeMultipaneCoordinator(coordinatorId, record.epoch, reconciled, deps),
304+
coordinator: new NativeMultipaneCoordinator(coordinatorId, record.epoch, survivorTree, deps),
277305
// Survivors keep the record's order, which is the reconciled tree's order.
278306
panes: probes.filter((probe) => probe.verdict === "owned").map(snapshotOf),
279307
};
@@ -518,11 +546,14 @@ interface PaneProbe {
518546

519547
async function probePane(pane: MultipanePaneEntry, deps: CoordinatorDeps): Promise<PaneProbe> {
520548
const record = deps.readPaneRecord(pane.sessionId);
521-
// A record we cannot read is NOT proof of death — the shell may well still be
522-
// running, we simply lost the only thing that identifies it. Tolerant recovery
523-
// keeps treating it as dead (that is how a crashed pane is swept), but the probe
524-
// records the difference so a strict caller can refuse to guess.
525-
if (!record) return { pane, record: null, verdict: "dead", ownershipUnknown: true };
549+
// A record that is GONE is proof enough: a stopped pane takes its record with it,
550+
// so there is nothing left to verify against and the pane is swept as before.
551+
// A record that is PRESENT but unreadable is a different animal — the shell it
552+
// described may still be running and `stop()` would report success without ever
553+
// signalling the pid, so its ownership is unknown rather than dead.
554+
if (!record) {
555+
return { pane, record: null, verdict: "dead", ownershipUnknown: paneRecordPresentButUnreadable(pane, deps) };
556+
}
526557
return {
527558
pane,
528559
record,
@@ -531,6 +562,26 @@ async function probePane(pane: MultipanePaneEntry, deps: CoordinatorDeps): Promi
531562
};
532563
}
533564

565+
/**
566+
* Whether the pane's record file exists yet cannot be interpreted. Falls back to
567+
* `false` when a caller supplies no inspector, which keeps every existing
568+
* in-memory dep double behaving exactly as it did.
569+
*/
570+
function paneRecordPresentButUnreadable(pane: MultipanePaneEntry, deps: CoordinatorDeps): boolean {
571+
const inspection = deps.inspectPaneRecord?.(pane.sessionId);
572+
if (!inspection || inspection.ok) return false;
573+
switch (inspection.problem.kind) {
574+
// Both mean "there is no record to read". A finished pane unlinks its record and
575+
// only then tries to remove its directory, which fails whenever a sibling file
576+
// survives — so a record-less directory is an ordinary dead pane, not a doubt.
577+
case "absent":
578+
case "missing":
579+
return false;
580+
default:
581+
return true;
582+
}
583+
}
584+
534585
function snapshotOf({ pane, record, verdict }: PaneProbe): PaneSnapshot {
535586
const { paneId, sessionId } = pane;
536587
if (!record) return { paneId, sessionId, hostPid: -1, shellPid: -1, cols: 0, rows: 0, state: "dead" };

0 commit comments

Comments
 (0)