fix(daemon): relaunch a sole seat after fresh --stop ends its tmux server (#267)

* fix(daemon): relaunch a sole seat after fresh --stop ends its tmux server

Stopping the server's last session ends tmux's server, so launchFresh's
later probes saw transport_unavailable and refused with tmux_probe_failed
after the occupant was already stopped. After its own successful stop,
launchFresh now restores an empty server with the existing startServer()
(a no-op while the server is up; no session is invented), so the
classified probes get a positive answer. Transport, permission,
collision, claimed-seat and wrong-pane refusals are unchanged.

Refs #265

* docs(changelog): describe 0.6.4-rc.1 as a pre-release and list its blocker fixes

Replace the time-sensitive "not published" wording, add the admitted
blocker fixes (#245/#257, #246/#266, #265/#267), and credit korallis for
finding the #242 handover bug in korallis/agent-stack#61.

Refs #265

---------

Co-authored-by: OpenRig contributors <noreply@openrig.dev>
This commit is contained in:
Mike Schwarz
2026-09-30 21:02:19 -07:00
committed by GitHub
co-authored by OpenRig contributors
parent cdf5fff724
commit fa770114d7
3 changed files with 165 additions and 4 deletions
+21 -4
View File
@@ -10,9 +10,24 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
## [0.6.4-rc.1]
Release candidate for 0.6.4, cut from main at `afde814f`. It is not published,
and candidate testing is in progress. Only admitted blocker fixes will change it
before 0.6.4. The changes since 0.6.3:
Release candidate for 0.6.4, cut from main at `afde814f`. This is a pre-release
candidate, not a stable release. Only admitted blocker fixes change it before
0.6.4. These notes make no test claims; the final verification summary will
state the tested environments and known limits. The changes since 0.6.3:
### Release-candidate blocker fixes
- Keep binary files byte-identical in rig bundles
([#245](https://github.com/mvschwarz/openrig/issues/245),
[#257](https://github.com/mvschwarz/openrig/pull/257)).
- Send local commands to the daemon's recorded endpoint, so a daemon started
with `rig daemon start --port` is the one they reach
([#246](https://github.com/mvschwarz/openrig/issues/246),
[#266](https://github.com/mvschwarz/openrig/pull/266)).
- Relaunch a rig's only seat after `rig seat launch --fresh --stop`, instead of
refusing once stopping it had ended the tmux server
([#265](https://github.com/mvschwarz/openrig/issues/265),
[#267](https://github.com/mvschwarz/openrig/pull/267)).
### Messaging and delivery
@@ -34,7 +49,9 @@ before 0.6.4. The changes since 0.6.3:
- Keep a blocked row's park timer through a seat handover, and retire a periodic
park timer when its row is rerouted
([#242](https://github.com/mvschwarz/openrig/pull/242)).
([#242](https://github.com/mvschwarz/openrig/pull/242)). Thanks to
[@korallis](https://github.com/korallis), who found and documented this in
[korallis/agent-stack#61](https://github.com/korallis/agent-stack/pull/61).
- Derive terminal pickup for closed queue items
([#176](https://github.com/mvschwarz/openrig/pull/176)).
- Preserve discovery claims during async rescans
@@ -550,6 +550,11 @@ export class SeatLifecycleService {
input.operator,
);
if (!stopped.ok) return stopped;
// Stopping the server's last session ends tmux's server, and every probe
// below would then be transport_unavailable, never absence. Restore an
// empty server (no session is invented; a no-op while the server is up)
// so they get a positive answer. The classified probes still decide.
await this.tmuxAdapter.startServer();
}
// Reuse clean's exhaustive, positive-absence gate for stale/history rows.
@@ -75,6 +75,7 @@ describe("SeatLifecycleService.launchFresh", () => {
}),
probeSession: vi.fn(async (name: string) => alive.has(name) ? { state: "present" as const } : { state: "absent" as const }),
hasSession: vi.fn(async (name: string) => alive.has(name)),
startServer: vi.fn(async (): Promise<TmuxResult> => ({ ok: true })),
listSessions: vi.fn(async () => [...alive].map((name) => ({ name, windows: 1, created: "", attached: false }))),
listWindows: vi.fn(async () => []),
listPanes: vi.fn(async (name: string) => {
@@ -319,6 +320,144 @@ describe("SeatLifecycleService.launchFresh", () => {
expect(result).toMatchObject({ ok: false, code: "tmux_probe_failed" });
expect(alive.has(seat.sessionName)).toBe(false);
expect(tmux.createSession).not.toHaveBeenCalled();
expect(tmux.startServer).not.toHaveBeenCalled();
});
// Real tmux ends its server when the last session goes (exit-empty), and a
// probe then fails "no server running": transport_unavailable, never absence.
// new-session and startServer() each bring a server back.
function modelServerLifetime() {
const server = { up: true, starts: 0 };
const createSession = vi.mocked(tmux.createSession).getMockImplementation()!;
vi.mocked(tmux.createSession).mockImplementation(async (...args) => {
server.up = true;
return createSession(...args);
});
vi.mocked(tmux.killSession).mockImplementation(async (name: string) => {
killed.push(name);
alive.delete(name);
livePanes.delete(name);
if (alive.size === 0) server.up = false;
return { ok: true };
});
vi.mocked(tmux.probeSession).mockImplementation(async (name: string) => {
if (!server.up) return { state: "transport_unavailable", cause: "no server running on /tmp/tmux-1000/default" };
return alive.has(name) ? { state: "present" } : { state: "absent" };
});
vi.mocked(tmux.hasSession).mockImplementation(async (name: string) => server.up && alive.has(name));
vi.mocked(tmux.startServer).mockImplementation(async () => {
if (!server.up) {
server.up = true;
server.starts += 1;
}
return { ok: true };
});
return server;
}
describe("when stopping the managed occupant ends the tmux server", () => {
it.each(["sole", "pair"] as const)("continues the requested fresh launch for a %s seat", async (shape) => {
const seat = seedSeat();
const retiringGeneration = sessionRegistry.currentOccupantTenure(seat.node.id)!.generationUuid;
if (shape === "pair") alive.add("dev-qa@fresh-rig");
const server = modelServerLifetime();
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "sole-seat fresh" });
expect(result).toMatchObject({ ok: true, status: "ready", sessionName: seat.sessionName });
if (!result.ok) return;
expect(killed).toEqual([seat.sessionName]);
expect(server.starts).toBe(shape === "sole" ? 1 : 0);
expect(tmux.createSession).toHaveBeenCalledTimes(1);
expect(alive.has(seat.sessionName)).toBe(true);
expect(result.generation).not.toBe(retiringGeneration);
expect(result.supersededSessionIds).toContain(seat.session!.id);
expect(sessionRegistry.getBindingForNode(seat.node.id)?.tmuxPane).toBe("%fresh");
});
it("proves older non-terminal rows absent on the restored server before superseding them", async () => {
const seat = seedSeat({ clean: true });
const older = sessionRegistry.registerSession(seat.node.id, "r00-dev-impl@fresh-rig");
sessionRegistry.updateStatus(older.id, "running");
const current = sessionRegistry.registerSession(seat.node.id, seat.sessionName);
sessionRegistry.updateStatus(current.id, "running");
sessionRegistry.updateBinding(seat.node.id, { tmuxSession: seat.sessionName, tmuxPane: "%old" });
alive.add(seat.sessionName);
livePanes.set(seat.sessionName, "%old");
const server = modelServerLifetime();
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "old-row history" });
expect(result).toMatchObject({ ok: true, status: "ready" });
if (!result.ok) return;
expect(server.starts).toBe(1);
expect(vi.mocked(tmux.probeSession).mock.calls.map(([name]) => name)).toContain("r00-dev-impl@fresh-rig");
expect(result.supersededSessionIds).toEqual(expect.arrayContaining([older.id, current.id]));
expect(killed).toEqual([seat.sessionName]);
});
it("still refuses a same-name session that appears on the restored server", async () => {
const seat = seedSeat();
const server = modelServerLifetime();
vi.mocked(tmux.startServer).mockImplementation(async () => {
server.up = true;
server.starts += 1;
alive.add(seat.sessionName);
livePanes.set(seat.sessionName, "%recreated");
return { ok: true };
});
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "recreated occupant" });
expect(result).toMatchObject({ ok: false, code: "unmanaged_session_collision" });
expect(killed).toEqual([seat.sessionName]);
expect(tmux.createSession).not.toHaveBeenCalled();
expect(livePanes.get(seat.sessionName)).toBe("%recreated");
});
it.each([
["the server cannot be restored", () => undefined],
["the probe fails with a permission error", () => { throw new Error("permission denied"); }],
])("keeps the transport refusal when %s", async (_label, afterStop) => {
const seat = seedSeat();
const server = modelServerLifetime();
const probe = vi.mocked(tmux.probeSession).getMockImplementation()!;
vi.mocked(tmux.probeSession).mockImplementation(async (name: string) => {
if (!server.up) afterStop();
return probe(name);
});
vi.mocked(tmux.startServer).mockResolvedValue({ ok: false, code: "tmux_unavailable", message: "The terminal server did not become available." });
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "transport stays down" });
expect(result).toMatchObject({ ok: false, code: "tmux_probe_failed" });
expect(killed).toEqual([seat.sessionName]);
expect(tmux.createSession).not.toHaveBeenCalled();
});
it("neither stops nor starts a server when stop was not requested", async () => {
const seat = seedSeat();
const server = modelServerLifetime();
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "no stop" });
expect(result).toMatchObject({ ok: false, code: "session_live" });
expect(killed).toEqual([]);
expect(tmux.startServer).not.toHaveBeenCalled();
expect(server).toEqual({ up: true, starts: 0 });
});
it("does not start a server it did not stop: a seat with no server stays a transport refusal", async () => {
seedSeat({ clean: true });
const server = modelServerLifetime();
server.up = false;
const result = await service.launchFresh({ seatRef: "dev.impl", fresh: true, reason: "server already down" });
expect(result).toMatchObject({ ok: false, code: "tmux_probe_failed" });
expect(tmux.startServer).not.toHaveBeenCalled();
expect(tmux.createSession).not.toHaveBeenCalled();
});
});
it("stops exactly the managed pod-aware occupant, launches fresh, and preserves sibling/work state", async () => {