mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
1 parent
0a36f99844
commit
8c69f9d407
5 files changed
+218
-40
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user