Files
openrig/packages/cli/test/remove-kept-session-output.test.ts
T
Mike SchwarzandOpenRig contributors 3b1b335dbe fix(daemon): rig remove never kills another rig's live seat; seat refs skip archived rigs (#174) (#181)
* fix(daemon): rig remove never kills another rig's live seat; seat refs skip archived rigs (#174)

An archived duplicate of a live rig shares its name and its seats'
canonical session names.

- `rig remove <archived-rigId> <node>` killed the tmux session by name,
  so it could end the live rig's seat. Removal now asks whether another
  node owns that session name, using the handover's live-ownership check,
  now shared as session-owner.ts. If another node owns it, removal
  leaves the session running, doesn't block on or reroute the queue
  work addressed to it, and reports the owner as `sessionKeptFor`. The
  node itself is still removed.
- Seat refs (`member@rig`) for seat launch, stop, model and handover
  resolved rigs by name including archived ones, so they failed with
  "matched multiple nodes". They now resolve unarchived rigs only, as
  default reads do since migration 042. Other findRigsByName callers
  are unchanged.

* fix(daemon): archived rigs don't claim a live seat's session or input target (#174)

Review follow-up. Archiving a rig keeps its bindings, so the archived twin
still names the live seat's session:

- Removing the live node found the archived twin as the "other owner",
  kept the session and removed the node's open work. Removal now ignores
  owners in archived rigs, so the live node kills its own session and
  still refuses open work without --fallback. The handover check is
  unchanged.
- The delivery guard needs exactly one match for a target, and the twin's
  binding made it two, so delivery to the live seat failed. Unarchived
  matches now win, and archived ones count only when nothing else
  matches. Exactly one match is still required, so a truly ambiguous
  live target still refuses.
- `rig remove` and `rig shrink` human output now says when a session was
  kept, and for whom.

---------

Co-authored-by: OpenRig contributors <noreply@openrig.dev>
2026-09-30 10:23:18 -07:00

99 lines
4.4 KiB
TypeScript

// #174: when removal keeps a session that belongs to another rig's live seat, the human output of
// `rig remove` and `rig shrink` says so (JSON already carries `sessionKeptFor`).
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { Command } from "commander";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type DaemonState } from "../src/daemon-lifecycle.js";
import { removeCommand } from "../src/commands/remove.js";
import { shrinkCommand } from "../src/commands/shrink.js";
import type { StatusDeps } from "../src/commands/status.js";
const RESPONSES: Record<string, unknown> = {
"DELETE /api/rigs/rig-1/nodes/lead.planner": {
ok: true, rigId: "rig-1", nodeId: "node-9", logicalId: "lead.planner",
sessionsKilled: 0, sessionKeptFor: "lead.planner@live-rig", reroutedQitemIds: [],
},
"DELETE /api/rigs/rig-1/nodes/dev.impl": {
ok: true, rigId: "rig-1", nodeId: "node-1", logicalId: "dev.impl", sessionsKilled: 1, reroutedQitemIds: [],
},
"DELETE /api/rigs/rig-1/pods/kept": {
ok: true, status: "ok", rigId: "rig-1", podId: "pod-9", namespace: "kept",
removedLogicalIds: ["kept.lead"], sessionsKilled: 0, reroutedQitemIds: [],
nodes: [{ logicalId: "kept.lead", nodeId: "node-9", status: "removed", sessionsKilled: 0, sessionKeptFor: "kept.lead@live-rig" }],
},
};
describe("#174 remove/shrink human output names a kept session", () => {
let server: http.Server;
let port: number;
beforeAll(async () => {
server = http.createServer((req, res) => {
const body = RESPONSES[`${req.method} ${req.url}`];
res.writeHead(body ? 200 : 404, { "Content-Type": "application/json" });
res.end(JSON.stringify(body ?? { error: "not found" }));
});
await new Promise<void>((resolve) => { server.listen(0, "127.0.0.1", resolve); });
port = (server.address() as { port: number }).port;
});
afterAll(() => { server.close(); });
function deps(): StatusDeps {
return {
lifecycleDeps: {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn((p: string) => p === STATE_FILE
? JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-09-30T00:00:00Z" } as DaemonState)
: null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn((p: string) => p === STATE_FILE),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
},
clientFactory: () => new DaemonClient(`http://127.0.0.1:${port}`),
};
}
async function run(command: Command, args: string[]): Promise<{ output: string; exitCode: number | undefined }> {
const logs: string[] = [];
const log = vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { logs.push(a.join(" ")); });
const err = vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { logs.push(a.join(" ")); });
process.exitCode = undefined;
const program = new Command();
program.exitOverride();
program.addCommand(command);
try {
await program.parseAsync(["node", "rig", ...args]);
return { output: logs.join("\n"), exitCode: process.exitCode };
} finally {
log.mockRestore();
err.mockRestore();
process.exitCode = undefined;
}
}
it("remove says the session was kept and for whom", async () => {
const { output, exitCode } = await run(removeCommand(deps()), ["remove", "rig-1", "lead.planner"]);
expect(exitCode).toBeUndefined();
expect(output).toContain("Removed node lead.planner from rig rig-1 (0 session killed)");
expect(output).toContain("Session kept: owned by lead.planner@live-rig");
});
it("control: remove prints no kept line when it killed its own session", async () => {
const { output } = await run(removeCommand(deps()), ["remove", "rig-1", "dev.impl"]);
expect(output).toContain("Removed node dev.impl from rig rig-1 (1 session killed)");
expect(output).not.toContain("Session kept");
});
it("shrink names each node whose session was kept", async () => {
const { output, exitCode } = await run(shrinkCommand(deps()), ["shrink", "rig-1", "kept"]);
expect(exitCode).toBeUndefined();
expect(output).toContain("kept.lead: session kept, owned by kept.lead@live-rig");
});
});