fix(seat-handover): respawn-no-k + terminate-retiree-first — respawn-pane -k CLEARED scrollback

The e2e caught a core defect the component gates could not: tmux respawn-pane -k CLEARS the pane
scrollback (verified 3->0 markers on tmux 3.6a), defeating the money-proof's central claim. My earlier
'scrollback survives' was an UNVERIFIED assumption — I checked pane-id/command/env (a proxy), never the
scrollback itself. Empirically, only respawn-pane WITHOUT -k, after the retiree EXITS with remain-on-exit
ON, preserves it (deep history intact minus the final screen the successor overwrites: 128/146 ~88%).

FIX (the plan's own no-k form + 'retiree exits in place'):
 - respawnPane: drop -k (respawn refuses a still-live pane — the correct guard; never clobber a live
   retiree). tmux-adapter test pins NO -k.
 - New adapter methods: setRemainOnExit (pane-scoped, set BEFORE exit so the pane survives dead),
   isPaneDead (the poll gate), signalPaneProcess (TERM graceful / KILL fallback).
 - createSuccessor: terminateRetiree — setRemainOnExit(true) -> graceful SIGTERM -> bounded isPaneDead
   poll -> SIGKILL fallback (pinned degraded path) -> respawn-pane no-k on the dead pane. If the retiree
   never dies -> structured retiree_not_terminated, NEVER respawn (never clobber a live retiree).

Graceful signal = SIGTERM (desk-provisional under build-ahead; dev-planner ratifies the exact graceful
mechanism at resume). RED pins: graceful path (dead after TERM, no KILL) + forced fallback (survives
TERM -> KILL -> respawn) + never-exits (retiree_not_terminated, no respawn) + same-pane + successor.

RED-first: tmux-adapter 100 + successor-session-launcher 14 + seat-handover-service 33 + recap suites,
158/158, tsc 0. E2e re-run PROVES scrollback now survives (was 0 with -k).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-08-07 08:45:30 +00:00
1 parent 0a36f99844
commit 8c69f9d407
5 files changed
+218 -40

No files matched your search

+54 -13
View File
@@ -382,20 +382,20 @@ export class TmuxAdapter {
}
}
/** Seat-handover cutover (plan 411c43de): respawn a pane IN PLACE with a new command (`-k` kills the
* retiree's pane process first). The successor takes the retiree's EXACT pane, so native scrollback
* survives untouched — predecessor history stays above the successor boot, same window, same pane.
* The command is shell-quoted as ONE unit (tmux runs it via the shell).
/** Seat-handover cutover (plan 411c43de): respawn a pane IN PLACE (reuse the retiree's EXACT pane) so
* the successor boots below the predecessor's history — same window, same pane, predecessor scrollback
* PRESERVED above the boot. The command is shell-quoted as ONE unit (tmux runs it via the shell).
*
* ⚠ NO `-k`: empirically (tmux 3.6a) `respawn-pane -k` force-kills+respawns atomically and CLEARS the
* pane's scrollback — defeating the money-proof. So the cutover terminates the retiree FIRST (graceful
* exit + `setRemainOnExit(true)` so the pane survives dead), waits for `isPaneDead`, then calls this
* WITHOUT -k on the already-dead pane — which preserves the history. respawn-pane refuses a still-live
* pane ("still active"), which is the correct guard: never respawn over a live retiree.
*
* Optional `cwd`/`env` inject the successor's start-directory + OpenRig identity env onto the reused
* pane via respawn-pane's `-c`/`-e` flags (tmux ≥3.0) — the SAME mechanism createSession uses, because
* a respawn (unlike new-session) carries no fresh environment of its own. Any flags precede the
* command, which always stays LAST (tmux treats the trailing operand as the shell-command).
*
* `command` is OPTIONAL: omitting it re-runs the pane's creation command, which for every OpenRig
* seat is the default login shell (the harness is send-keys'd INTO a shell, never exec'd as the pane
* command — verified on tmux 3.6a). That is exactly what the cutover successor wants: a fresh shell
* in the retiree's pane for launchHarness to drive, with the predecessor scrollback intact above. */
* pane via respawn-pane's `-c`/`-e` flags (tmux ≥3.0), the SAME mechanism createSession uses. Any flags
* precede the command, which always stays LAST. `command` is OPTIONAL: omitting it re-runs the pane's
* creation command (the default login shell) — the fresh shell the cutover successor's launchHarness drives. */
async respawnPane(
paneTarget: string,
command?: string,
@@ -406,7 +406,7 @@ export class TmuxAdapter {
? Object.entries(opts.env).map(([k, v]) => ` -e ${shellQuote(`${k}=${v}`)}`).join("")
: "";
const commandArg = command != null && command.length > 0 ? ` ${shellQuote(command)}` : "";
const cmd = `tmux respawn-pane -k -t ${shellQuote(paneTarget)}${cwdFlag}${envFlags}${commandArg}`;
const cmd = `tmux respawn-pane -t ${shellQuote(paneTarget)}${cwdFlag}${envFlags}${commandArg}`;
try {
await this.exec(cmd);
return { ok: true };
@@ -415,6 +415,47 @@ export class TmuxAdapter {
}
}
/** Seat-handover cutover: set the pane-scoped `remain-on-exit` so the pane SURVIVES (goes dead, not
* destroyed) when the retiree process exits — holding its scrollback for the successor's respawn.
* Set to `on` BEFORE the retiree is signalled to exit (else the pane is destroyed on exit and there
* is nothing to respawn into). */
async setRemainOnExit(paneTarget: string, on: boolean): Promise<TmuxResult> {
const cmd = `tmux set-option -p -t ${shellQuote(paneTarget)} remain-on-exit ${on ? "on" : "off"}`;
try {
await this.exec(cmd);
return { ok: true };
} catch (err) {
return classifyWriteError(err);
}
}
/** Seat-handover cutover: is the pane's process dead (the retiree exited; the pane held by
* remain-on-exit)? The gate the cutover polls before respawn-no-k. Never throws → false on error. */
async isPaneDead(paneId: string): Promise<boolean> {
try {
const output = await this.exec(`tmux display-message -p -t ${shellQuote(paneId)} "#{pane_dead}"`);
return output.trim() === "1";
} catch {
return false;
}
}
/** Seat-handover cutover: signal the pane's foreground process (the retiree) — `TERM` for the graceful
* exit-in-place, `KILL` for the bounded-timeout force fallback. Resolves the pane pid then `kill`s it;
* an unresolvable pid is a structured, non-throwing failure. */
async signalPaneProcess(paneId: string, signal: "TERM" | "KILL"): Promise<TmuxResult> {
const pid = await this.getPanePid(paneId);
if (pid == null) {
return { ok: false, code: "pane_pid_unavailable", message: `Could not resolve the pane pid for "${paneId}".` };
}
try {
await this.exec(`kill -${signal} ${pid}`);
return { ok: true };
} catch (err) {
return classifyWriteError(err);
}
}
/** Get the PID of the foreground process in a pane. Returns null if unavailable. */
async getPanePid(paneId: string): Promise<number | null> {
try {
@@ -71,6 +71,8 @@ export class SuccessorSessionLauncher {
private readinessTimeoutMs: number;
private sleep: (ms: number) => Promise<void>;
private tmuxOptionDefaults: TmuxOptionDefaultsApplier | null;
private exitPollMs: number;
private exitTimeoutMs: number;
constructor(
tmuxAdapter: TmuxAdapter,
@@ -92,6 +94,9 @@ export class SuccessorSessionLauncher {
* its just-created session. Omitted → option application is skipped.
*/
tmuxOptionDefaults?: TmuxOptionDefaultsApplier;
/** Cutover retiree-exit poll interval + total bounded timeout (per graceful/forced phase). */
exitPollMs?: number;
exitTimeoutMs?: number;
} = {},
) {
this.tmuxAdapter = tmuxAdapter;
@@ -102,6 +107,8 @@ export class SuccessorSessionLauncher {
this.readinessTimeoutMs = opts.readinessTimeoutMs ?? 30_000;
this.sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
this.tmuxOptionDefaults = opts.tmuxOptionDefaults ?? null;
this.exitPollMs = opts.exitPollMs ?? 100;
this.exitTimeoutMs = opts.exitTimeoutMs ?? 3_000;
}
/**
@@ -154,11 +161,15 @@ export class SuccessorSessionLauncher {
};
}
// 2. Respawn the successor INTO the retiree's pane in place. respawn-pane -k force-replaces the
// retiree with a fresh default login shell (no command) carrying the successor's identity env +
// cwd; the pane id and window are unchanged, so native scrollback survives. This is the swap: the
// retiree exits in place (session file = wake target). A respawn failure returns a structured
// create_successor error; we do NOT kill the pane (the seat stays re-wakeable from its file).
// 2. Terminate the retiree IN PLACE, then respawn the now-dead pane WITHOUT -k so native scrollback
// survives (respawn-pane -k CLEARS the pane history — verified on tmux 3.6a; that would defeat the
// money-proof). The swap: set remain-on-exit so the pane survives the exit → graceful SIGTERM
// ("exits in place"; also protects the provider session state the successor's resume depends on) →
// bounded-timeout SIGKILL fallback (pinned degraded path) → respawn-pane (no -k) on the dead pane.
const terminated = await this.terminateRetiree(pane.id);
if (!terminated.ok) {
return { ok: false, code: "retiree_not_terminated", step: "create_successor", message: terminated.message };
}
const respawned = await this.tmuxAdapter.respawnPane(pane.id, undefined, { cwd, env });
if (!respawned.ok) {
return {
@@ -322,6 +333,37 @@ export class SuccessorSessionLauncher {
}
}
/**
* Cutover: terminate the retiree occupant in its pane so the successor can respawn into it WHILE
* PRESERVING scrollback. `setRemainOnExit(true)` FIRST (so the pane survives dead, not destroyed, on
* exit); graceful SIGTERM ("exits in place" — a clean exit also protects the provider session state the
* successor's resume depends on); a bounded-timeout SIGKILL is the pinned DEGRADED fallback. Returns
* which path terminated the retiree, or a failure if it never became dead (then commit never runs).
* (Graceful signal = SIGTERM as the desk-provisional default under build-ahead; dev-planner ratifies
* the exact graceful mechanism — SIGTERM vs a runtime-specific quit — at their resume.)
*/
private async terminateRetiree(
paneId: string,
): Promise<{ ok: true; path: "graceful" | "forced" } | { ok: false; message: string }> {
await this.tmuxAdapter.setRemainOnExit(paneId, true);
await this.tmuxAdapter.signalPaneProcess(paneId, "TERM");
if (await this.waitPaneDead(paneId)) return { ok: true, path: "graceful" };
// Graceful window elapsed — force-kill (the pinned degraded path).
await this.tmuxAdapter.signalPaneProcess(paneId, "KILL");
if (await this.waitPaneDead(paneId)) return { ok: true, path: "forced" };
return { ok: false, message: `Retiree in pane "${paneId}" did not exit after graceful TERM + forced KILL.` };
}
/** Poll `isPaneDead` up to the bounded exit timeout (count-bounded so injected no-op sleeps stay fast). */
private async waitPaneDead(paneId: string): Promise<boolean> {
const maxPolls = Math.max(1, Math.ceil(this.exitTimeoutMs / this.exitPollMs));
for (let i = 0; i < maxPolls; i++) {
if (await this.tmuxAdapter.isPaneDead(paneId)) return true;
await this.sleep(this.exitPollMs);
}
return this.tmuxAdapter.isPaneDead(paneId);
}
/**
* Composer unwind for a successor that launched but failed downstream (context delivery or continuity
* verify). CUTOVER INVARIANT: the successor occupies the retiree's PRESERVED pane, so cleanup NEVER
@@ -20,6 +20,9 @@ describe("SeatHandoverService", () => {
let listPanes: ReturnType<typeof vi.fn>;
let killSession: ReturnType<typeof vi.fn>;
let respawnPane: ReturnType<typeof vi.fn>;
let setRemainOnExit: ReturnType<typeof vi.fn>;
let signalPaneProcess: ReturnType<typeof vi.fn>;
let isPaneDead: ReturnType<typeof vi.fn>;
let sendText: ReturnType<typeof vi.fn>;
let sendKeys: ReturnType<typeof vi.fn>;
let capturePaneScreen: ReturnType<typeof vi.fn>;
@@ -42,6 +45,9 @@ describe("SeatHandoverService", () => {
listPanes = vi.fn(async () => [{ id: "%9", index: 0, cwd: "/project", width: 80, height: 24, active: true }]);
killSession = vi.fn(async () => ({ ok: true }));
respawnPane = vi.fn(async () => ({ ok: true }));
setRemainOnExit = vi.fn(async () => ({ ok: true }));
signalPaneProcess = vi.fn(async () => ({ ok: true }));
isPaneDead = vi.fn(async () => true); // cutover: retiree exits gracefully on SIGTERM
sendText = vi.fn(async () => ({ ok: true }));
sendKeys = vi.fn(async () => ({ ok: true }));
capturePaneScreen = vi.fn(async () => "predecessor screen tail");
@@ -62,7 +68,7 @@ describe("SeatHandoverService", () => {
});
function tmux(): TmuxAdapter {
return { hasSession, createSession, listPanes, killSession, respawnPane, sendText, sendKeys, capturePaneScreen } as unknown as TmuxAdapter;
return { hasSession, createSession, listPanes, killSession, respawnPane, setRemainOnExit, signalPaneProcess, isPaneDead, sendText, sendKeys, capturePaneScreen } as unknown as TmuxAdapter;
}
function codexAdapter(): RuntimeAdapter {
@@ -14,6 +14,9 @@ describe("SuccessorSessionLauncher", () => {
let listPanes: ReturnType<typeof vi.fn>;
let killSession: ReturnType<typeof vi.fn>;
let respawnPane: ReturnType<typeof vi.fn>;
let setRemainOnExit: ReturnType<typeof vi.fn>;
let signalPaneProcess: ReturnType<typeof vi.fn>;
let isPaneDead: ReturnType<typeof vi.fn>;
let launchHarness: ReturnType<typeof vi.fn>;
let checkReady: ReturnType<typeof vi.fn>;
@@ -24,6 +27,10 @@ describe("SuccessorSessionLauncher", () => {
listPanes = vi.fn(async () => [{ id: "%7", index: 0, cwd: "/w", width: 80, height: 24, active: true }]);
killSession = vi.fn(async () => ({ ok: true }));
respawnPane = vi.fn(async () => ({ ok: true }));
setRemainOnExit = vi.fn(async () => ({ ok: true }));
signalPaneProcess = vi.fn(async () => ({ ok: true }));
// Cutover: default = retiree exits gracefully (dead right after SIGTERM), so no forced fallback.
isPaneDead = vi.fn(async () => true);
// A live successor is launched via the runtime adapter (launchHarness + readiness), not left as a
// bare shell. Default mock: launches ready with a scraped resume token.
launchHarness = vi.fn(async () => ({ ok: true, resumeToken: "codex-thread-xyz", resumeType: "codex_id" }));
@@ -37,22 +44,24 @@ describe("SuccessorSessionLauncher", () => {
}
function launcher(tmuxOptionDefaults?: TmuxOptionDefaultsApplier): SuccessorSessionLauncher {
const tmux = { createSession, listPanes, killSession, respawnPane } as unknown as TmuxAdapter;
const tmux = { createSession, listPanes, killSession, respawnPane, setRemainOnExit, signalPaneProcess, isPaneDead } as unknown as TmuxAdapter;
return new SuccessorSessionLauncher(tmux, discoveryRepo, {
sessionEnv: { OPENRIG_HOME: "/home", HOME: "/daemon-home", CODEX_HOME: "/daemon-codex" },
newId: () => "01ABCDEFG",
runtimeAdapters: { codex: fakeAdapter("codex") },
readinessTimeoutMs: 50,
sleep: async () => {},
exitPollMs: 1,
exitTimeoutMs: 5,
tmuxOptionDefaults,
});
}
it("CUTOVER: respawns the successor into the DEPARTING pane (preserved name, same pane id) — no fresh session", async () => {
// A SEAT = one durable tmux session; the successor takes over the retiree's EXACT pane via
// respawn-pane so native scrollback survives (predecessor history stays above the successor boot).
// No fresh new-session, no -h shuffle: the canonical session name is preserved and the pane id is
// unchanged. respawn-pane with no command re-runs the pane's default login shell for launchHarness.
it("CUTOVER: terminates the retiree then respawns (no -k) into the DEPARTING pane (preserved name, same pane id)", async () => {
// A SEAT = one durable tmux session; the successor takes over the retiree's EXACT pane so native
// scrollback survives. The retiree is terminated IN PLACE first (remain-on-exit → graceful SIGTERM),
// then respawn-pane WITHOUT -k reuses the dead pane (respawn -k would CLEAR scrollback). No fresh
// new-session, no -h shuffle: the canonical session name is preserved and the pane id is unchanged.
listPanes.mockResolvedValue([{ id: "%42", index: 0, cwd: "/w", width: 80, height: 24, active: true }]);
const res = await launcher().createSuccessor({
@@ -83,6 +92,14 @@ describe("SuccessorSessionLauncher", () => {
CODEX_HOME: "/daemon-codex",
});
// Retiree terminated IN PLACE before the respawn: remain-on-exit set, then GRACEFUL SIGTERM; the
// pane went dead on the first probe, so NO forced KILL. respawn happens only after the pane is dead.
expect(setRemainOnExit).toHaveBeenCalledWith("%42", true);
expect(signalPaneProcess).toHaveBeenCalledWith("%42", "TERM");
expect(signalPaneProcess).not.toHaveBeenCalledWith("%42", "KILL");
expect(setRemainOnExit.mock.invocationCallOrder[0]!).toBeLessThan(signalPaneProcess.mock.invocationCallOrder[0]!);
expect(respawnPane.mock.invocationCallOrder[0]!).toBeGreaterThan(signalPaneProcess.mock.invocationCallOrder[0]!);
// launchHarness drives the harness into the SAME preserved session/pane; readiness ran; the
// launch resume token is captured + returned (persisted at commit by the composer, never here).
expect(launchHarness).toHaveBeenCalledTimes(1);
@@ -94,7 +111,7 @@ describe("SuccessorSessionLauncher", () => {
expect(res.resumeType).toBe("codex_id");
expect(res.tmuxSession).toBe("dev-impl@rig");
expect(res.tmuxPane).toBe("%42");
// Order: resolve departing pane → respawn in place → launch the harness into it.
// Order: resolve departing pane → terminate → respawn in place → launch the harness into it.
expect(respawnPane.mock.invocationCallOrder[0]!).toBeGreaterThan(listPanes.mock.invocationCallOrder[0]!);
expect(launchHarness.mock.invocationCallOrder[0]!).toBeGreaterThan(respawnPane.mock.invocationCallOrder[0]!);
@@ -106,6 +123,35 @@ describe("SuccessorSessionLauncher", () => {
expect(db.prepare("SELECT COUNT(*) AS n FROM sessions").get()).toEqual({ n: 0 });
});
it("CUTOVER forced fallback: retiree survives graceful SIGTERM → bounded SIGKILL, then respawns", async () => {
listPanes.mockResolvedValue([{ id: "%42", index: 0, cwd: "/w", width: 80, height: 24, active: true }]);
// The pane stays live through the graceful window; only the forced KILL makes it dead.
let killed = false;
signalPaneProcess.mockImplementation(async (_p: string, sig: string) => { if (sig === "KILL") killed = true; return { ok: true }; });
isPaneDead.mockImplementation(async () => killed);
const res = await launcher().createSuccessor({ node: { id: "n", runtime: "codex", cwd: "/w" }, departingSessionName: "dev-impl@rig" });
expect(res.ok).toBe(true);
expect(signalPaneProcess).toHaveBeenCalledWith("%42", "TERM");
expect(signalPaneProcess).toHaveBeenCalledWith("%42", "KILL"); // graceful failed → forced fallback
expect(respawnPane).toHaveBeenCalledTimes(1); // respawn only after the pane died (post-KILL)
expect(launchHarness).toHaveBeenCalledTimes(1);
});
it("retiree NEVER exits (survives TERM and KILL) → structured retiree_not_terminated, NO respawn (never clobber a live retiree)", async () => {
isPaneDead.mockResolvedValue(false); // never becomes dead
const res = await launcher().createSuccessor({ node: { id: "n", runtime: "codex", cwd: "/w" }, departingSessionName: "a@r" });
expect(res).toMatchObject({ ok: false, step: "create_successor", code: "retiree_not_terminated" });
expect(signalPaneProcess).toHaveBeenCalledWith("%7", "TERM");
expect(signalPaneProcess).toHaveBeenCalledWith("%7", "KILL");
// The pane never died → we NEVER respawn (respawn over a live retiree would fail/clobber) and never launch.
expect(respawnPane).not.toHaveBeenCalled();
expect(launchHarness).not.toHaveBeenCalled();
expect(killSession).not.toHaveBeenCalled();
expect(discoveryRepo.listDiscovered()).toHaveLength(0);
});
it("UNWIND INVARIANT: a failed successor launch NEVER kills the retiree's preserved session — it stays re-wakeable from its session file", async () => {
// The new safety invariant (cutover): the retiree exits in place; its provider session file is the
// durable wake target. So when the successor's launch/readiness FAILS after the respawn, unwind must
@@ -154,8 +200,8 @@ describe("SuccessorSessionLauncher", () => {
});
it("no runtime adapter for the seat's runtime → structured failure, preserved seat NOT killed", async () => {
const tmux = { createSession, listPanes, killSession, respawnPane } as unknown as TmuxAdapter;
const noAdapter = new SuccessorSessionLauncher(tmux, discoveryRepo, { newId: () => "01ABCDEFG", runtimeAdapters: {} });
const tmux = { createSession, listPanes, killSession, respawnPane, setRemainOnExit, signalPaneProcess, isPaneDead } as unknown as TmuxAdapter;
const noAdapter = new SuccessorSessionLauncher(tmux, discoveryRepo, { newId: () => "01ABCDEFG", runtimeAdapters: {}, exitPollMs: 1, exitTimeoutMs: 5 });
const res = await noAdapter.createSuccessor({ node: { id: "n", runtime: "codex", cwd: "/w" }, departingSessionName: "a@r" });
expect(res).toMatchObject({ ok: false, step: "start_agent", code: "successor_runtime_unsupported" });
expect(killSession).not.toHaveBeenCalled();
+55 -12
View File
@@ -1059,20 +1059,21 @@ describe("TmuxAdapter", () => {
// Seat-handover cutover (plan 411c43de): the successor RESUMES INTO THE SAME PANE via respawn-pane,
// so native scrollback survives (predecessor history stays above the successor boot).
describe("respawnPane", () => {
it("respawns the pane in place (-k kills the retiree first) with the quoted target + command", async () => {
it("respawns the pane in place WITHOUT -k (retiree already exited; -k would CLEAR scrollback)", async () => {
// Empirically (tmux 3.6a): respawn-pane -k CLEARS the pane's scrollback, defeating the money-proof.
// The cutover terminates the retiree FIRST (graceful exit + remain-on-exit), then respawns the
// now-dead pane with NO -k — which PRESERVES the predecessor history above the successor boot.
const exec = vi.fn<ExecFn>().mockResolvedValue("");
const adapter = new TmuxAdapter(exec);
await adapter.respawnPane("%3", "openrig-agent --resume tok");
expect(exec).toHaveBeenCalledOnce();
expect(exec.mock.calls[0]![0]).toBe("tmux respawn-pane -k -t '%3' 'openrig-agent --resume tok'");
expect(exec.mock.calls[0]![0]).toBe("tmux respawn-pane -t '%3' 'openrig-agent --resume tok'");
expect(exec.mock.calls[0]![0]).not.toContain(" -k"); // -k clears scrollback — never used
});
it("with env + cwd injects -c and -e flags (successor self-identifies in the reused pane), command stays last", async () => {
// Seat-handover cutover: the successor takes over the retiree's pane via respawn-pane, so its
// OpenRig identity env must be injected on the reused pane exactly like createSession's -e flags
// (there is no fresh new-session to carry it). Mirrors createSession's -c/-e construction.
const exec = vi.fn<ExecFn>().mockResolvedValue("");
const adapter = new TmuxAdapter(exec);
@@ -1083,16 +1084,11 @@ describe("TmuxAdapter", () => {
expect(exec).toHaveBeenCalledOnce();
expect(exec.mock.calls[0]![0]).toBe(
"tmux respawn-pane -k -t '%3' -c '/w' -e 'OPENRIG_NODE_ID=node123' -e 'OPENRIG_SESSION_NAME=dev-impl@rig' 'openrig-agent --resume tok'",
"tmux respawn-pane -t '%3' -c '/w' -e 'OPENRIG_NODE_ID=node123' -e 'OPENRIG_SESSION_NAME=dev-impl@rig' 'openrig-agent --resume tok'",
);
});
it("with NO command re-runs the pane's default login shell (omits the trailing command arg)", async () => {
// Seat-handover cutover: the successor takes over the retiree's pane and needs a FRESH login
// shell for launchHarness to send-keys the harness into. respawn-pane with no command re-runs
// the pane's creation command — which for every OpenRig seat is the default shell, because the
// harness is send-keys'd into a shell, never exec'd as the pane command (verified on tmux 3.6a).
// So we omit the command arg entirely (env/cwd still injected onto the reused pane).
const exec = vi.fn<ExecFn>().mockResolvedValue("");
const adapter = new TmuxAdapter(exec);
@@ -1103,7 +1099,7 @@ describe("TmuxAdapter", () => {
expect(exec).toHaveBeenCalledOnce();
expect(exec.mock.calls[0]![0]).toBe(
"tmux respawn-pane -k -t '%3' -c '/w' -e 'OPENRIG_SESSION_NAME=dev-impl@rig'",
"tmux respawn-pane -t '%3' -c '/w' -e 'OPENRIG_SESSION_NAME=dev-impl@rig'",
);
});
@@ -1113,4 +1109,51 @@ describe("TmuxAdapter", () => {
expect(result.ok).toBe(false);
});
});
describe("setRemainOnExit", () => {
it("sets the pane-scoped remain-on-exit option so the pane survives the retiree's exit (for respawn)", async () => {
const exec = vi.fn<ExecFn>().mockResolvedValue("");
const adapter = new TmuxAdapter(exec);
await adapter.setRemainOnExit("%3", true);
expect(exec.mock.calls[0]![0]).toBe("tmux set-option -p -t '%3' remain-on-exit on");
});
it("clears it with off", async () => {
const exec = vi.fn<ExecFn>().mockResolvedValue("");
const adapter = new TmuxAdapter(exec);
await adapter.setRemainOnExit("%3", false);
expect(exec.mock.calls[0]![0]).toBe("tmux set-option -p -t '%3' remain-on-exit off");
});
});
describe("isPaneDead", () => {
it("returns true when pane_dead is 1 (retiree has exited; pane held by remain-on-exit)", async () => {
const adapter = new TmuxAdapter(mockExec({ "display-message": { stdout: "1\n" } }));
expect(await adapter.isPaneDead("%3")).toBe(true);
});
it("returns false when pane_dead is 0 (retiree still live)", async () => {
const adapter = new TmuxAdapter(mockExec({ "display-message": { stdout: "0\n" } }));
expect(await adapter.isPaneDead("%3")).toBe(false);
});
it("returns false (never throws) when the probe errors", async () => {
const adapter = new TmuxAdapter(mockExec({ "display-message": { error: NO_SERVER_ERROR } }));
expect(await adapter.isPaneDead("%3")).toBe(false);
});
});
describe("signalPaneProcess", () => {
it("sends the given signal to the pane's foreground pid (graceful TERM / fallback KILL)", async () => {
const calls: string[] = [];
const exec = vi.fn<ExecFn>(async (cmd: string) => { calls.push(cmd); return cmd.includes("pane_pid") ? "4242\n" : ""; });
const adapter = new TmuxAdapter(exec);
const res = await adapter.signalPaneProcess("%3", "TERM");
expect(res.ok).toBe(true);
expect(calls.some((c) => c.includes("#{pane_pid}") && c.includes("'%3'"))).toBe(true);
expect(calls).toContain("kill -TERM 4242");
});
it("returns a failure (no throw) when the pane pid is unavailable", async () => {
const adapter = new TmuxAdapter(mockExec({ "display-message": { stdout: "\n" } }));
const res = await adapter.signalPaneProcess("%3", "KILL");
expect(res.ok).toBe(false);
});
});
});