Compare commits

...
Author SHA1 Message Date
dev-driver 26e36bb60f refactor(daemon): decide a seat's lifecycle by kind at the seat boundary
Restore, its preview, teardown and a fresh launch's refusal guidance
each compared a binding's attachment type with "external_cli"
themselves, nine places in all. They now ask the seat boundary:
seatProcessOwner(resolveSeatKind(...)). A seat joined with `rig attach`
as an external CLI runs in someone else's program, so OpenRig neither
launches nor stops it and leaves it attached. Every other kind takes
OpenRig's lifecycle.

Nothing a person or agent sees changes. A stored kind that's neither
tmux nor external_cli keeps the path it takes today, OpenRig's own,
and the resolver names it unknown.
2026-11-11 03:01:31 +00:00
10 changed files with 81 additions and 10 deletions

No files matched your search

@@ -103,6 +103,7 @@ import { communityHarnessStopsRestore } from "./community-harness-presets.js";
import { seatAdapter } from "../adapters/community-harness-runtime-adapter.js";
import { pluginPathsOf } from "./claude-core-plugin-env.js";
import { seatLaunchAdditionsForLaunch, systemPromptPendingNoteForNode, type SeatLaunchAdditions } from "./seat-launch-additions.js";
import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
export { resolveActiveSnapshotSession } from "./active-occupant.js";
export type { ActiveSnapshotSessionResolution } from "./active-occupant.js";
@@ -445,7 +446,7 @@ export class RestoreOrchestrator {
for (const blocker of validation.blockers) {
if (!SEAT_STARTUP_BLOCKERS.has(blocker.code) || !blocker.nodeId) continue;
// A seat joined with `rig attach` as an external CLI isn't launched, so its startup files don't hold it.
if (this.sessionRegistry.getBindingForNode(blocker.nodeId)?.attachmentType === "external_cli") continue;
if (seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(blocker.nodeId)?.attachmentType)) === "external") continue;
const reason = `${blocker.message} ${blocker.remediation}`;
heldForStartup.set(blocker.nodeId, heldForStartup.has(blocker.nodeId) ? `${heldForStartup.get(blocker.nodeId)} ${reason}` : reason);
}
@@ -713,7 +714,7 @@ export class RestoreOrchestrator {
continue;
}
// An external CLI's claim has no tmux session to probe; restoreNodeWithCompensation leaves it attached.
const external = this.sessionRegistry.getBindingForNode(node.id)?.attachmentType === "external_cli";
const external = seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(node.id)?.attachmentType)) === "external";
const sessions = external ? [] : this.sessionRegistry.getSessionsForRig(rigId)
.filter((s) => s.nodeId === node.id && s.status === "running");
@@ -828,7 +829,7 @@ export class RestoreOrchestrator {
const holdReasonText = opts?.holdReason ?? "excluded_from_subset";
for (const node of nonTargetNodes) {
// A seat joined with `rig attach` as an external CLI has no tmux session to check, and restore leaves it attached.
if (this.sessionRegistry.getBindingForNode(node.id)?.attachmentType === "external_cli") continue;
if (seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(node.id)?.attachmentType)) === "external") continue;
const sessions = this.sessionRegistry.getSessionsForRig(rigId)
.filter((s) => s.nodeId === node.id && s.status === "running");
@@ -904,7 +905,7 @@ export class RestoreOrchestrator {
for (const session of this.sessionRegistry.getSessionsForRig(rigId)) {
if (session.status !== "running" || (selectedIds && !selectedIds.has(session.nodeId))) continue;
// An external CLI's claim has no tmux session; restore leaves it attached (restoreNodeWithCompensation).
if (this.sessionRegistry.getBindingForNode(session.nodeId)?.attachmentType === "external_cli") continue;
if (seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(session.nodeId)?.attachmentType)) === "external") continue;
try {
// Replacement must preserve unknown transport state before any reconciliation write.
if (selectedIds) {
@@ -1110,7 +1111,7 @@ export class RestoreOrchestrator {
// A seat joined with `rig attach` as an external CLI keeps its attachment: launching here would replace it.
const external = this.sessionRegistry.getBindingForNode(nodeId);
if (external?.attachmentType === "external_cli") {
if (external && seatProcessOwner(resolveSeatKind(external.attachmentType)) === "external") {
return { nodeId, logicalId: node.logicalId, status: "left_attached", error: leftAttachedReason(external.externalSessionName ?? node.logicalId) };
}
@@ -21,6 +21,7 @@ import { communityHarnessOf } from "./built-in-runtimes.js";
import { communityHarnessStopsRestore } from "./community-harness-presets.js";
import { sessionCommunityHarnessSelect } from "./session-registry.js";
import { assessSnapshotCurrentOccupants, stoppedSnapshotReason } from "./rehydrate-eligibility.js";
import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
/** OPR.0.4.3.20 FR-6 — a present token whose last verification is older than this
* is surfaced as `stale` (age-based staleness — the "stale while running" signal
@@ -321,7 +322,7 @@ export function buildRestorePlanPreview(
};
const stoppedCurrent = selectedHistory?.nodeId !== node.id && sessionRows.some(row => row.nodeId === savedNodeId && row.stoppedCurrent);
// Restore checks the live binding before anything else (restoreNodeWithCompensation), and so does its forecast.
const attached = node.binding?.attachmentType === "external_cli";
const attached = seatProcessOwner(resolveSeatKind(node.binding?.attachmentType)) === "external";
const { intendedAction, reason } = attached
? { intendedAction: "leave-attached" as const, reason: leftAttachedReason(node.binding?.externalSessionName ?? node.logicalId) }
: runtimeError
+2 -1
View File
@@ -16,6 +16,7 @@ import { findOtherSessionOwner } from "./session-owner.js";
import { communityHarnessOf } from "./built-in-runtimes.js";
import { SEAT_SKILLS_BOOTSTRAP_BLOCK_ID, SEAT_SKILLS_BOOTSTRAP_LINE, cleanSeatSkills, seatSkillPaths } from "./seat-skill-storage.js";
import { settleAutomaticRestore } from "./existing-rig-restore.js";
import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
export interface TeardownResult {
rigId: string;
@@ -188,7 +189,7 @@ export class RigTeardownOrchestrator {
for (const session of liveSessions) {
// OpenRig neither launches nor stops a seat joined with `rig attach` as an external CLI, and it has no tmux session:
// a missing one isn't a stop. Keep its claim, as the historical path below does for non-tmux bindings.
if (this.deps.sessionRegistry.getBindingForNode(session.nodeId)?.attachmentType === "external_cli") {
if (seatProcessOwner(resolveSeatKind(this.deps.sessionRegistry.getBindingForNode(session.nodeId)?.attachmentType)) === "external") {
external.add(session.nodeId);
(result.leftRunning ??= []).push(session.sessionName);
continue;
+8
View File
@@ -23,3 +23,11 @@ export function resolveSeatKind(stored: string | null | undefined): ResolvedSeat
export function seatInboundTransport(seat: ResolvedSeatKind): "tmux" | "none" {
return seat.kind === "external_cli" ? "none" : "tmux";
}
/** Who runs a seat's process, which decides whether OpenRig's lifecycle (restore, fresh launch, teardown) acts on it. An
* external CLI seat joined with `rig attach` runs in someone else's program: OpenRig neither launches nor stops it, and
* leaves it attached. An unknown stored kind keeps the path it takes today, OpenRig's own; as for the inbound transport,
* whether it should is a question for its owner. */
export function seatProcessOwner(seat: ResolvedSeatKind): "openrig" | "external" {
return seat.kind === "external_cli" ? "external" : "openrig";
}
@@ -54,6 +54,7 @@ import { CommunityHarnessRuntimeAdapter, seatAdapter } from "../adapters/communi
import { sessionCommunityHarnessSelect } from "./session-registry.js";
import { seatLaunchAdditionsReport } from "./seat-launch-additions.js";
import { seatSkillPaths } from "./seat-skill-storage.js";
import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
/**
* S5 (OPR.0.5.4.7) — the seat-lifecycle verb surface: set-model, single-seat stop,
@@ -1427,7 +1428,7 @@ export class SeatLifecycleService {
const claimed = rows.find(row => row.origin === "claimed");
if (claimed) {
const seats = this.rigRepo.getRig(seat.rigId)?.nodes.length ?? null;
const externalCli = this.sessionRegistry.getBindingForNode(nodeId)?.attachmentType === "external_cli";
const externalCli = seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(nodeId)?.attachmentType)) === "external";
return {
ok: false, code: "claimed_session",
message: `Seat "${seatRef}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
@@ -2292,7 +2293,7 @@ export class SeatLifecycleService {
/** The claimed-session routes for this seat's rig (see claimedSessionRoutes). */
private claimedRoutes(seat: SeatDescriptor, sessionName: string): string {
return claimedSessionRoutes(seat.rigName, sessionName, this.rigRepo.getRig(seat.rigId)?.nodes.length ?? null,
this.sessionRegistry.getBindingForNode(seat.nodeId)?.attachmentType === "external_cli");
seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(seat.nodeId)?.attachmentType)) === "external");
}
private describe(resolved: ResolvedSeat): SeatDescriptor {
@@ -1135,6 +1135,23 @@ describe("RestoreOrchestrator", () => {
])).toBe("fully_restored");
});
it("a full restore launches a seat whose stored kind is neither tmux nor external CLI on OpenRig's own path, as it does today", async () => {
const rig = rigRepo.createRig("r99");
const worker = rigRepo.addNode(rig.id, "worker", { role: "worker", runtime: "claude-code" });
const old = sessionRegistry.registerSession(worker.id, "r99-worker");
sessionRegistry.updateStatus(old.id, "running");
sessionRegistry.updateBinding(worker.id, { tmuxSession: "r99-worker" });
db.prepare("UPDATE bindings SET attachment_type = 'herdr' WHERE node_id = ?").run(worker.id);
const snap = snapshotCapture.captureSnapshot(rig.id, "manual");
const tmux = { ...mockTmux(), hasSession: vi.fn(async () => false) } as unknown as TmuxAdapter;
const result = await createOrchestrator({ tmux }).restore(snap.id, { freshLogicalIds: ["worker"] });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.result.nodes).toEqual([expect.objectContaining({ logicalId: "worker", status: "fresh-primed" })]);
});
it("a missing startup file doesn't hold a seat joined with rig attach as an external CLI, or the restore, since nothing launches it", async () => {
const rig = rigRepo.createRig("r99");
const desk = rigRepo.addNode(rig.id, "desk", { role: "worker", runtime: "claude-code" });
@@ -117,6 +117,16 @@ describe("FR-6 restore-plan token state", () => {
expect(node.runtimePrompt).toBeUndefined();
});
it("forecasts a seat whose stored kind is neither tmux nor external CLI on OpenRig's own path, as restore takes it today", () => {
const rig = {
rig: { id: "rig-1", name: "test-rig" },
nodes: [{ id: "n1", logicalId: "desk", runtime: "claude-code", binding: { attachmentType: "herdr", externalSessionName: null } }],
edges: [],
} as unknown as RigWithRelations;
const node = buildRestorePlanPreview(rig, null, [row("n1", { resumeType: null, resumeToken: null })]).nodes[0]!;
expect(node.intendedAction).not.toBe("leave-attached");
});
it("presents a never-occupied current seat as new, without a history warning", () => {
const rig = rigWith([{ id: "n1", logicalId: "a", runtime: "codex" }]);
const result = buildRestorePlanPreview(rig, null, []).nodes[0]!;
+14
View File
@@ -980,6 +980,20 @@ describe("RigTeardownOrchestrator", () => {
expect(result).toMatchObject({ sessionsKilled: 1, errors: [], deleted: del, leftRunning: ["desktop@test-rig"] });
});
it("stops a seat whose stored kind is neither tmux nor external CLI, as it does today: only an external CLI seat is left running", async () => {
const { rigId, nodeId } = seedRig();
sessionRegistry.updateBinding(nodeId, { tmuxSession: "r01-dev" });
db.prepare("UPDATE bindings SET attachment_type = 'herdr' WHERE node_id = ?").run(nodeId);
const tmux = mockTmux();
tmux.probeSession = vi.fn(async (name: string) => ({ state: name === "r01-dev" ? "present" : "absent" }));
const result = await buildTeardown(tmux).teardown(rigId, { delete: false });
expect(tmux.killSession).toHaveBeenCalledWith("r01-dev");
expect(result).toMatchObject({ sessionsKilled: 1, errors: [] });
expect(result.leftRunning).toBeUndefined();
});
it("names the community-harness seats whose pane it stopped, since the harness's own processes may outlive it", async () => {
const { rigId } = seedRigWithNode({ runtime: "claude-code", cwd: "/tmp" });
const hermes = rigRepo.addNode(rigId, "hermes", { runtime: "synth-harness" });
@@ -512,6 +512,17 @@ describe("SeatLifecycleService.launchFresh", () => {
expect(tmux.createSession).not.toHaveBeenCalled();
});
it("names only the release route for an adopted seat joined with rig attach as an external CLI, since rig down leaves it running", async () => {
const seat = seedSeat({ adopted: true });
sessionRegistry.updateBinding(seat.node.id, { attachmentType: "external_cli", externalSessionName: seat.sessionName });
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "fixture" });
expect(result).toMatchObject({ ok: false, code: "claimed_session" });
if (result.ok) throw new Error("expected refusal");
expect(result.guidance).toContain("rig unclaim 'dev-impl@fresh-rig'");
expect(result.guidance).not.toContain("rig down");
expect(tmux.killSession).not.toHaveBeenCalled();
});
it("keeps stop effects unknown when pane identity cannot be observed", async () => {
const seat = seedSeat();
vi.mocked(tmux.listPanes).mockRejectedValue(new Error("secret-pane-error"));
+8 -1
View File
@@ -1,6 +1,6 @@
// The seat boundary's resolver: today's two kinds, and a stored value that's neither read as unknown, never as tmux.
import { describe, it, expect } from "vitest";
import { resolveSeatKind, seatInboundTransport } from "../src/domain/seat-kinds.js";
import { resolveSeatKind, seatInboundTransport, seatProcessOwner } from "../src/domain/seat-kinds.js";
describe("resolveSeatKind", () => {
it.each([
@@ -21,4 +21,11 @@ describe("resolveSeatKind", () => {
// An unknown stored kind keeps the path it takes today; the resolver still names it unknown.
expect(seatInboundTransport({ kind: "unknown", stored: "herdr" })).toBe("tmux");
});
it("leaves an external CLI seat's process to its own program, and every other kind to OpenRig's lifecycle as today", () => {
expect(seatProcessOwner({ kind: "external_cli" })).toBe("external");
expect(seatProcessOwner({ kind: "tmux" })).toBe("openrig");
// As for the transport: an unknown stored kind keeps today's path, and the resolver still names it unknown.
expect(seatProcessOwner({ kind: "unknown", stored: "herdr" })).toBe("openrig");
});
});