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>
This commit is contained in:
Mike Schwarz
2026-09-30 10:23:18 -07:00
committed by GitHub
co-authored by OpenRig contributors
parent e3ebd4ac2c
commit 3b1b335dbe
11 changed files with 331 additions and 28 deletions
+3
View File
@@ -51,6 +51,9 @@ export function removeCommand(depsOverride?: StatusDeps): Command {
}
console.log(`Removed node ${res.data["logicalId"]} from rig ${res.data["rigId"]} (${res.data["sessionsKilled"]} session killed)`);
if (typeof res.data["sessionKeptFor"] === "string") {
console.log(`Session kept: owned by ${res.data["sessionKeptFor"]}`);
}
});
return cmd;
+6 -1
View File
@@ -19,6 +19,7 @@ type ShrinkResponse = {
nodeId: string;
status: "removed" | "failed";
sessionsKilled: number;
sessionKeptFor?: string;
error?: string;
}>;
error?: string;
@@ -74,7 +75,8 @@ export function shrinkCommand(depsOverride?: StatusDeps): Command {
for (const node of res.data.nodes ?? []) {
const icon = node.status === "removed" ? "OK" : "FAIL";
const error = node.error ? ` — ${node.error}` : "";
console.log(` [${icon}] ${node.logicalId}${error}`);
const kept = node.sessionKeptFor ? ` (session kept: owned by ${node.sessionKeptFor})` : "";
console.log(` [${icon}] ${node.logicalId}${kept}${error}`);
}
process.exitCode = 1;
return;
@@ -83,6 +85,9 @@ export function shrinkCommand(depsOverride?: StatusDeps): Command {
console.log(
`Removed pod ${res.data.namespace} from rig ${res.data.rigId} (${res.data.removedLogicalIds?.length ?? 0} node(s), ${res.data.sessionsKilled} session killed)`
);
for (const node of res.data.nodes ?? []) {
if (node.sessionKeptFor) console.log(` ${node.logicalId}: session kept, owned by ${node.sessionKeptFor}`);
}
});
return cmd;
@@ -0,0 +1,98 @@
// #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");
});
});
@@ -5,6 +5,7 @@ import type { DiscoveryRepository } from "./discovery-repository.js";
import type { EventBus } from "./event-bus.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import type { QueueRepository } from "./queue-repository.js";
import { findOtherSessionOwner } from "./session-owner.js";
type ClaimedSessionRow = {
session_id: string;
@@ -63,6 +64,8 @@ export type RemoveNodeResult =
nodeId: string;
logicalId: string;
sessionsKilled: number;
/** #174: the session name belonged to another node's seat (logical id @ rig), so it was kept. */
sessionKeptFor?: string;
fallbackDestination?: string;
reroutedQitemIds: string[];
}
@@ -384,8 +387,15 @@ export class RigLifecycleService {
if (invalidFallback) return invalidFallback;
}
// #174: a session name can be reused by another rig's live seat (for example an archived duplicate
// of a live rig). When another unarchived node owns the name, that session and the queue work
// addressed to it are the other seat's: removal neither kills it nor routes its work. Owners in
// archived rigs don't count, since archiving keeps their stale bindings.
const sessionOwner = node.latest_session_name
? findOtherSessionOwner(this.db, node.latest_session_name, node.node_id, { ignoreArchived: true })
: null;
const activeQitemIds = this.activeQitemIdsForSessionNames(
node.latest_session_name ? [node.latest_session_name] : [],
node.latest_session_name && !sessionOwner ? [node.latest_session_name] : [],
);
if (activeQitemIds.length > 0 && fallbackDestination === undefined) {
return {
@@ -407,9 +417,10 @@ export class RigLifecycleService {
}
const preserveDetachedClaimedSession = node.latest_session_origin === "claimed" && node.latest_session_status === "detached";
const keepSession = preserveDetachedClaimedSession || sessionOwner !== null;
let sessionsKilled = 0;
if (node.latest_session_name && !preserveDetachedClaimedSession) {
if (node.latest_session_name && !keepSession) {
const kill = await this.tmuxAdapter?.killSession(node.latest_session_name);
if (kill && !kill.ok && kill.code !== "session_not_found") {
return {
@@ -426,7 +437,7 @@ export class RigLifecycleService {
const persisted: Array<{ type: "session.detached" | "node.removed"; seq: number; createdAt: string }> = [];
let rosterEvent: ReturnType<EventBus["persistWithinTransaction"]> | null = null;
const tx = this.db.transaction(() => {
if (node.latest_session_name && !preserveDetachedClaimedSession) {
if (node.latest_session_name && !keepSession) {
const detached = this.eventBus.persistWithinTransaction({
type: "session.detached",
rigId,
@@ -480,6 +491,7 @@ export class RigLifecycleService {
nodeId: node.node_id,
logicalId: node.logical_id,
sessionsKilled,
...(sessionOwner ? { sessionKeptFor: `${sessionOwner.logical_id}@${sessionOwner.rig_name}` } : {}),
...(fallbackDestination !== undefined ? { fallbackDestination } : {}),
reroutedQitemIds: activeQitemIds,
};
@@ -549,6 +561,7 @@ export class RigLifecycleService {
logicalId: string;
status: "removed" | "failed";
sessionsKilled: number;
sessionKeptFor?: string;
error?: string;
}> = [];
for (const node of nodes) {
@@ -613,6 +626,7 @@ export class RigLifecycleService {
logicalId: removed.logicalId,
status: "removed",
sessionsKilled: removed.sessionsKilled,
...(removed.sessionKeptFor ? { sessionKeptFor: removed.sessionKeptFor } : {}),
});
}
@@ -517,6 +517,15 @@ export class RigRepository {
return rows.map((r) => this.rowToRig(r));
}
/** #174: seat-ref resolution sees only unarchived rigs, as default reads do since migration 042. */
findUnarchivedRigsByName(name: string): Rig[] {
const archived = this.hasRigColumn("archived_at") ? " AND archived_at IS NULL" : "";
const rows = this.db
.prepare(`SELECT * FROM rigs WHERE name = ?${archived} ORDER BY created_at`)
.all(name) as RigRow[];
return rows.map((r) => this.rowToRig(r));
}
getRigSummaries(filter?: RigArchiveFilter): Array<{ id: string; name: string; nodeCount: number; latestSnapshotAt: string | null; latestSnapshotId: string | null; hasServices: boolean; archivedAt: string | null }> {
const cond = archiveWhereClause("r.archived_at", filter);
const where = cond ? `WHERE ${cond}` : "";
@@ -215,15 +215,23 @@ export class SeatDeliveryGuard {
}
/** Current binding, never a latest historical session-name guess. Unbound seats
* resolve by node/canonical address for preferences and lifecycle preflight. */
* resolve by node/canonical address for preferences and lifecycle preflight.
* #174: an archived rig can keep a binding to the same session name as a live
* seat, so unarchived matches win; archived ones count only when nothing else
* matches. Exactly one match is still required. */
export function resolveGuardTarget(db: Database.Database, name: string): GuardTarget | null {
const rows = db.prepare(`SELECT n.id AS nodeId,
coalesce(b.tmux_session, replace(n.logical_id,'.','-') || '@' || r.name) AS session,
b.tmux_pane AS pane,
(SELECT generation_uuid FROM occupant_tenures t WHERE t.node_id=n.id ORDER BY generation_ordinal DESC LIMIT 1) AS occupant
(SELECT generation_uuid FROM occupant_tenures t WHERE t.node_id=n.id ORDER BY generation_ordinal DESC LIMIT 1) AS occupant,
r.archived_at AS archivedAt
FROM nodes n JOIN rigs r ON r.id=n.rig_id LEFT JOIN bindings b ON b.node_id=n.id
WHERE n.id=? OR b.tmux_session=? OR b.tmux_pane=? OR n.logical_id=?
OR (b.tmux_session IS NULL AND replace(n.logical_id,'.','-') || '@' || r.name=?)`)
.all(name, name, name, name, name) as GuardTarget[];
return rows.length === 1 ? rows[0]! : null;
.all(name, name, name, name, name) as Array<GuardTarget & { archivedAt: string | null }>;
const unarchived = rows.filter((row) => row.archivedAt === null);
const pool = unarchived.length > 0 ? unarchived : rows;
if (pool.length !== 1) return null;
const { archivedAt: _archivedAt, ...target } = pool[0]!;
return target;
}
@@ -22,6 +22,7 @@ import type { JsonlExchange } from "./session-jsonl.js";
import type { PersistedEvent } from "./types.js";
import type { AppliedLaunchObservation } from "./permission-drift.js";
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
import { findOtherSessionOwner } from "./session-owner.js";
/** A bounded labeled-from-record recap of the predecessor's last exchanges + the record path,
* resolved from the predecessor's provider transcript (claude transcript_path / codex rollout_path).
@@ -891,24 +892,7 @@ export class SeatHandoverService {
}
private lookupManagedOwner(tmuxSession: string, targetNodeId: string): BindingOwnerRow | null {
const bindingOwner = this.db.prepare(`
SELECT n.id AS node_id, n.logical_id, r.name AS rig_name
FROM bindings b
JOIN nodes n ON n.id = b.node_id
JOIN rigs r ON r.id = n.rig_id
WHERE b.tmux_session = ? AND n.id != ?
LIMIT 1
`).get(tmuxSession, targetNodeId) as BindingOwnerRow | undefined;
if (bindingOwner) return bindingOwner;
return this.db.prepare(`
SELECT n.id AS node_id, n.logical_id, r.name AS rig_name
FROM sessions s
JOIN nodes n ON n.id = s.node_id
JOIN rigs r ON r.id = n.rig_id
WHERE s.session_name = ? AND n.id != ? AND s.status NOT IN ('superseded', 'detached', 'exited')
LIMIT 1
`).get(tmuxSession, targetNodeId) as BindingOwnerRow | undefined ?? null;
return findOtherSessionOwner(this.db, tmuxSession, targetNodeId);
}
private commit(input: {
@@ -1100,7 +1100,7 @@ export class SeatLifecycleService {
const parsed = parseSessionName(ref);
if (parsed.kind === "canonical") {
const localRef = parsed.member;
const rigs = this.rigRepo.findRigsByName(parsed.rig);
const rigs = this.rigRepo.findUnarchivedRigsByName(parsed.rig);
return rigs.flatMap((rig) => getNodeInventory(this.db, rig.id).filter((entry) =>
entry.canonicalSessionName === ref
|| deriveCanonicalFromEntry(entry) === ref
@@ -82,7 +82,7 @@ export class SeatStatusService {
if (parsed.kind === "canonical") {
const localRef = parsed.member;
const rigName = parsed.rig;
const rigs = this.rigRepo.findRigsByName(rigName);
const rigs = this.rigRepo.findUnarchivedRigsByName(rigName);
return rigs.flatMap((rig) => this.entriesForRig(rig.id).filter((entry) =>
entry.canonicalSessionName === ref || entry.logicalId === localRef
).map((entry) => ({ entry })));
@@ -0,0 +1,41 @@
import type Database from "better-sqlite3";
export interface SessionOwnerRow {
node_id: string;
logical_id: string;
rig_name: string;
}
/**
* The OTHER managed node that currently owns a tmux session name, if any: a node whose binding names
* it, or a node with a live (not superseded, detached or exited) session row under it. A session name
* can be shared across rigs, for example by an archived duplicate of a live rig, so the name alone
* never proves which node a session belongs to. `ignoreArchived` skips owners in archived rigs: an
* archived rig keeps its bindings, so its node would otherwise claim the live seat's session.
*/
export function findOtherSessionOwner(
db: Database.Database,
tmuxSession: string,
nodeId: string,
opts?: { ignoreArchived?: boolean },
): SessionOwnerRow | null {
const live = opts?.ignoreArchived ? " AND r.archived_at IS NULL" : "";
const bindingOwner = db.prepare(`
SELECT n.id AS node_id, n.logical_id, r.name AS rig_name
FROM bindings b
JOIN nodes n ON n.id = b.node_id
JOIN rigs r ON r.id = n.rig_id
WHERE b.tmux_session = ? AND n.id != ?${live}
LIMIT 1
`).get(tmuxSession, nodeId) as SessionOwnerRow | undefined;
if (bindingOwner) return bindingOwner;
return db.prepare(`
SELECT n.id AS node_id, n.logical_id, r.name AS rig_name
FROM sessions s
JOIN nodes n ON n.id = s.node_id
JOIN rigs r ON r.id = n.rig_id
WHERE s.session_name = ? AND n.id != ? AND s.status NOT IN ('superseded', 'detached', 'exited')${live}
LIMIT 1
`).get(tmuxSession, nodeId) as SessionOwnerRow | undefined ?? null;
}
@@ -0,0 +1,141 @@
// #174 — an archived duplicate of a live rig shares its name and its seats' canonical session names.
// (b) `rig remove` on the archived rig must not kill, or route the queue work of, the live seat that owns
// the shared session name. (a) Seat refs resolve against unarchived rigs only, so the archived duplicate
// no longer makes the live seat ambiguous. Fake tmux only: no live session is touched.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type Database from "better-sqlite3";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { DiscoveryRepository } from "../src/domain/discovery-repository.js";
import { RigLifecycleService } from "../src/domain/rig-lifecycle-service.js";
import { SeatStatusService } from "../src/domain/seat-status-service.js";
import { SeatLifecycleService } from "../src/domain/seat-lifecycle-service.js";
import { resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
const RIG = "bodies-in-motion";
const SEAT = `lead-planner@${RIG}`;
describe("#174 archived duplicate rig vs the live seat with the same name", () => {
let db: Database.Database;
let rigRepo: RigRepository;
let sessionRegistry: SessionRegistry;
let eventBus: EventBus;
let queueRepo: QueueRepository;
let killSession: ReturnType<typeof vi.fn>;
let lifecycle: RigLifecycleService;
let stale: { rigId: string; nodeId: string };
let live: { rigId: string; nodeId: string; sessionId: string };
function seat(rigName: string, status: "running" | "exited", logicalId = "lead.planner", name = SEAT) {
const rig = rigRepo.createRig(rigName);
const node = rigRepo.addNode(rig.id, logicalId, { role: "planner", runtime: "claude-code" });
const session = sessionRegistry.registerSession(node.id, name);
sessionRegistry.updateStatus(session.id, status);
sessionRegistry.updateBinding(node.id, { tmuxSession: name });
return { rigId: rig.id, nodeId: node.id, sessionId: session.id };
}
beforeEach(() => {
db = createFullTestDb();
rigRepo = new RigRepository(db);
sessionRegistry = new SessionRegistry(db);
eventBus = new EventBus(db);
queueRepo = new QueueRepository(db, eventBus, { validateRig: () => true });
killSession = vi.fn(async () => ({ ok: true as const }));
lifecycle = new RigLifecycleService({
db, rigRepo, sessionRegistry, discoveryRepo: new DiscoveryRepository(db), eventBus, queueRepo,
tmuxAdapter: { killSession } as unknown as TmuxAdapter,
});
// The reported shape: the stale record was archived; the live rig re-created the same seat name.
stale = seat(RIG, "exited");
rigRepo.archiveRig(stale.rigId);
live = seat(RIG, "running");
});
afterEach(() => db.close());
it("(b) removing the archived rig's node keeps the live seat's session and names its owner", async () => {
const result = await lifecycle.removeNode(stale.rigId, "lead.planner");
expect(result).toMatchObject({ ok: true, sessionsKilled: 0, sessionKeptFor: `lead.planner@${RIG}` });
expect(killSession).not.toHaveBeenCalled();
expect(rigRepo.getRig(stale.rigId)!.nodes).toHaveLength(0);
const liveRow = db.prepare("SELECT status FROM sessions WHERE id = ?").get(live.sessionId) as { status: string };
expect(liveRow.status).toBe("running");
const liveBinding = db.prepare("SELECT tmux_session FROM bindings WHERE node_id = ?").get(live.nodeId) as { tmux_session: string };
expect(liveBinding.tmux_session).toBe(SEAT);
const detachedForName = db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'session.detached'").get() as { n: number };
expect(detachedForName.n).toBe(0);
});
it("(b) the live seat's open queue work neither blocks the removal nor moves", async () => {
const work = await queueRepo.create({ sourceSession: `orch@${RIG}`, destinationSession: SEAT, body: "live seat's work" });
const result = await lifecycle.removeNode(stale.rigId, "lead.planner");
expect(result).toMatchObject({ ok: true, reroutedQitemIds: [] });
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: SEAT, state: "pending" });
});
it("(b) removing the LIVE node still kills its own session once; the archived twin's stale binding is not an owner", async () => {
const result = await lifecycle.removeNode(live.rigId, "lead.planner");
expect(result).toMatchObject({ ok: true, sessionsKilled: 1 });
expect(result.ok && "sessionKeptFor" in result).toBe(false);
expect(killSession).toHaveBeenCalledExactlyOnceWith(SEAT);
});
it("(b) removing the LIVE node still refuses its open work without --fallback", async () => {
const work = await queueRepo.create({ sourceSession: `orch@${RIG}`, destinationSession: SEAT, body: "live seat's work" });
const result = await lifecycle.removeNode(live.rigId, "lead.planner");
expect(result).toMatchObject({ ok: false, code: "active_qitems" });
expect(killSession).not.toHaveBeenCalled();
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: SEAT, state: "pending" });
});
it("(guard) the delivery guard resolves the live seat despite the archived twin's binding", () => {
expect(resolveGuardTarget(db, SEAT)).toMatchObject({ nodeId: live.nodeId, session: SEAT });
});
it("(guard) control: two unarchived bindings to the same name still resolve to nothing", () => {
seat(RIG, "running");
expect(resolveGuardTarget(db, SEAT)).toBeNull();
});
it("control: removing a node that owns its session still kills that session once", async () => {
const own = seat("solo-rig", "running", "dev.impl", "dev-impl@solo-rig");
const result = await lifecycle.removeNode(own.rigId, "dev.impl");
expect(result).toMatchObject({ ok: true, sessionsKilled: 1 });
expect(result.ok && "sessionKeptFor" in result).toBe(false);
expect(killSession).toHaveBeenCalledExactlyOnceWith("dev-impl@solo-rig");
});
it("(a) seat status and handover resolution no longer see the archived duplicate", () => {
const status = new SeatStatusService({ rigRepo }).getStatus(SEAT);
expect(status.ok).toBe(true);
expect(status.ok && status.status.rig_id).toBe(live.rigId);
});
it("(a) seat lifecycle verbs resolve the canonical ref to the live seat", async () => {
const service = new SeatLifecycleService({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: { killSession } as unknown as TmuxAdapter });
const result = await service.setModel({ seatRef: SEAT, model: "claude-fable-5", reason: "issue 174" });
expect(result.ok || result.code).not.toBe("seat_ambiguous");
expect(result.ok).toBe(true);
});
it("control: an unarchived duplicate is still reported as ambiguous", () => {
seat(RIG, "running", "lead.planner");
const status = new SeatStatusService({ rigRepo }).getStatus(SEAT);
expect(status.ok).toBe(false);
expect(!status.ok && status.code).toBe("seat_ambiguous");
});
});