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
15 changed files with 109 additions and 72 deletions

No files matched your search

+10 -12
View File
@@ -428,24 +428,22 @@ running seat only through the action in the last column.
### New guidance for a running seat
There are three ways for a seat to pick up new guidance:
These reach a running seat without a restart, in the order they reach the agent:
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. The seat's
interruption settings can hold a message; `rig send` may report `retained, not delivered`.
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. It's in the
conversation at the seat's next turn. The seat's reply is the only evidence that it read the file.
2. **The refocus hook**, where the seat runs OpenRig's `openrig-core` hooks (**Refocus** in `rig seat status` shows whether
the hook has recorded it). The hook's content comes from `OPENRIG_REFOCUS_CONTENT_REF` (a `rig context get` address),
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. After
compaction the hook waits for an eligible prompt. For managed Claude compaction it skips the acknowledgement boundary,
and other prompts can arrive first and be skipped while restore is pending. Claude can also refocus after the
conversation grows past the hook's size threshold.
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. It
reaches the agent at the next prompt after a compaction, and for Claude also once the conversation has grown past the
hook's size threshold.
3. **Its guidance file, loaded again:** for Claude at its next compaction, `/clear` or start, for Codex at its next start.
**Guidance** in `rig seat status` shows which of OpenRig's blocks were written after the session's recorded startup finish.
A file pointer leaves the read to the seat, under its own permission settings. `rig walk` reads the named files in the
calling process and sends their contents. A seat whose harness asks before reading a file outside its project (its own
`LEARNED.md` under the topology root, a plugin's skill) waits at that prompt until someone answers it, even though a hook
could deliver an excerpt of the same file. Finding a file, a hook picking it up, and the agent reading and using it are
separate facts.
A pointer or a message names a file; the seat still reads it itself, under its own permission settings. A seat whose
harness asks before reading a file outside its project (its own `LEARNED.md` under the topology root, a plugin's skill)
waits at that prompt until someone answers it, even though a hook could deliver an excerpt of the same file. Finding a
file, a hook picking up, and the agent reading and using it are separate facts.
Per harness: Claude Code's and Codex's permission choices are in the setup reference
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
@@ -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 {
@@ -449,18 +449,14 @@ function closesFramedPromptAbove(lines: string[], open: number, indent: number,
const codexFooter = (line: string): boolean => isComposerFooter(line)
|| /(?:\b\d+% context left\b|\bContext \[)/.test(line);
/** Preserve the styling used for autocomplete ghosts and the drawn cursor.
* Color channel values must never be interpreted as style codes. */
/** Preserve faint styling so autocomplete ghosts can be separated from authored
* text. Color channel values must never be interpreted as style codes. */
function styledTextLines(screen: string) {
let faint = false, inverse = false;
let faint = false;
return screen.replace(/\r\n/g, "\n").split("\n").map(raw => {
let text = "", offset = 0;
const faintCells: boolean[] = [], inverseCells: boolean[] = [];
const append = (value: string) => {
text += value;
faintCells.push(...Array<boolean>(value.length).fill(faint));
inverseCells.push(...Array<boolean>(value.length).fill(inverse));
};
const faintCells: boolean[] = [];
const append = (value: string) => { text += value; faintCells.push(...Array<boolean>(value.length).fill(faint)); };
for (const match of raw.matchAll(/\x1b\[([0-9;:]*)m/g)) {
append(raw.slice(offset, match.index));
const codes = match[1]!.split(";");
@@ -471,18 +467,15 @@ function styledTextLines(screen: string) {
const skip = codes[i + 1] === "5" ? 2 : codes[i + 1] === "2" ? (codes[i + 2] === "" ? 5 : 4) : 0;
// Color channels are not style codes. Ambiguous compound sequences
// provide no faint-style proof, rather than interpreting an RGB 2 as dim.
if (!skip || i + skip !== codes.length - 1) { faint = inverse = false; break; }
if (!skip || i + skip !== codes.length - 1) { faint = false; break; }
i += skip;
} else if (code === "" || code === "0") faint = inverse = false;
else if (code === "22") faint = false;
} else if (code === "" || code === "0" || code === "22") faint = false;
else if (code === "2") faint = true;
else if (code === "7") inverse = true;
else if (code === "27") inverse = false;
}
offset = match.index! + match[0].length;
}
append(raw.slice(offset));
return { text, faintCells, inverseCells };
return { text, faintCells };
});
}
@@ -577,11 +570,7 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
else if (cursorOffset >= 0 && !rows[cursorRow]!.slice(cursorOffset).trim()) visible[cursorRow] = rows[cursorRow]!.slice(0, cursorOffset);
}
const atStart = y === start && x === prefix;
// Claude can draw an inverse-video blank in the cursor cell (#2925). Only
// that single cell at the start is padding; ordinary/preceding spaces stay input.
const inverseCells = styled[start]!.inverseCells;
const cursorBlank = prompt.glyph === "❯" && rows[0] === " " && inverseCells[prefix] && !inverseCells[prefix - 1];
const empty = atStart && rows.length === 1 && (rows[0] === "" || cursorBlank);
const empty = atStart && rows.length === 1 && rows[0] === "";
const last = visible.length - 1;
const cursorAtEnd = y === start + last && x === prefix + stringWidth(visible[last] ?? "");
const label = visible.length === 1 && COMPOSER_COLLAPSED_PASTE_PATTERN.test(visible[0]!) ? visible[0]! : null;
@@ -162,15 +162,6 @@ describe("cursor-bound composer input", () => {
expect(inspectComposerInput(claude("\x1b[2;38;5;245mAsk Codex to do anything\x1b[0m", { x: 2, y: 2 })).state).toBe("empty");
});
it("recognizes only a single inverse-video Claude cursor blank at the prompt start", () => {
const cursor = { x: 2, y: 2 };
expect(inspectComposerInput(claude("\x1b[7m \x1b[0m", cursor))).toMatchObject({ state: "empty", rows: [""], cursorAtEnd: true });
for (const body of [" ", "\x1b[38;5;7m \x1b[0m", "\x1b[7m\x1b[27m ", "\x1b[7m \x1b[0mdraft"]) {
expect(inspectComposerInput(claude(body, cursor)).state).toBe("text");
}
expect(inspectComposerInput(claude("\x1b[7m \x1b[0m", { x: 3, y: 2 }))).toMatchObject({ state: "text", rows: [" "] });
});
it("requires the complete owned text and preserves significant spaces", () => {
const input = inspectComposerInput(claude("Run two commands\nthen report."));
expect(composerContainsOwnedText(input, "Run two commands\nthen report.")).toBe(true);
@@ -10,7 +10,7 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { migrate } from "../src/db/migrate.js";
import { annotateInputBox, inspectComposerInput } from "../src/domain/session-transport.js";
import { inspectComposerInput } from "../src/domain/session-transport.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
@@ -133,7 +133,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
}
}, 30_000);
it.each(["update-banner", "cursor-block"])("submits a multiline paste with %s and keeps a real draft through tmux", async variant => {
it("submits a multiline paste under a combined update footer through real tmux", async () => {
const scratch = mkdtempSync(join(tmpdir(), "draft-banner-"));
const socket = join(scratch, "s");
const env = { ...process.env }; delete env.TMUX; delete env.TMUX_TMPDIR;
@@ -143,9 +143,8 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
const db = new Database(":memory:");
let transport: SessionTransport | undefined;
try {
const command = [process.execPath, composer, "claude", stateFile, variant].map(shellQuote).join(" ");
const [width, height] = variant === "cursor-block" ? ["68", "31"] : ["120", "40"];
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", width, "-y", height, "-s", "worker@test", command])).trim();
const command = [process.execPath, composer, "claude", stateFile, "update-banner"].map(shellQuote).join(" ");
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", "120", "-y", "40", "-s", "worker@test", command])).trim();
await vi.waitFor(() => expect(state().input).toBe(""), { timeout: 5000 });
migrate(db, ALL_MIGRATIONS);
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
@@ -158,14 +157,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name)); adapter.deliveryGuard = guard;
transport = new SessionTransport({ db, tmuxAdapter: adapter, rigRepo: new RigRepository(db), sessionRegistry: new SessionRegistry(db) });
await guard.set("a", { mode: "draft-aware" }, "human@test", "protect fixture input");
if (variant === "cursor-block") {
await vi.waitFor(async () => {
const snapshot = await adapter.captureComposerSnapshot(pane);
expect(snapshot?.cursor).toEqual({ x: 2, y: 26, width: 68, height: 31 });
expect(snapshot!.screen.split("\n")[26]).toContain("❯\u00a0\x1b[7m ");
expect(annotateInputBox(snapshot!.screen, snapshot!.cursor, "claude-code")).toMatchObject({ status: "empty", confidence: "high" });
});
} else await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
const text = "first line\nsecond line\nlast line";
const result = await transport.send("worker@test", text, { deliveryId: "banner", actorSession: "sender@test", verify: false });
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, sent: true });
@@ -173,7 +165,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
await tmux(["send-keys", "-t", pane, "-l", "person's draft"]);
await vi.waitFor(() => expect(state().input).toBe("person's draft"));
expect(await transport.send("worker@test", "another message", { deliveryId: "draft", verify: false }))
.toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
.toMatchObject({ outcome: "retained", sent: false });
expect(state()).toEqual({ input: "person's draft", submissions: [text] });
} finally {
await transport?.guardedDelivery?.stop();
+3 -7
View File
@@ -5,23 +5,19 @@ import { StringDecoder } from "node:string_decoder";
import stringWidth from "string-width";
const [runtime, stateFile, footerVariant] = process.argv.slice(2);
const cursorBlock = footerVariant === "cursor-block";
const rule = cursorBlock ? `\x1b[38;5;244m${"─".repeat(68)}\x1b[39m` : "────────────────────────────────────────";
let input = "", pending = "", pasting = false, cursor = 0, ghost = false;
const submissions = [];
const decoder = new StringDecoder("utf8");
function render() {
const rows = ["Editable delivery fixture", ...submissions.slice(-3).flatMap(text => text.split("\n"))];
if (cursorBlock) while (rows.length < 25) rows.push("");
if (runtime === "claude") rows.push(rule);
if (runtime === "claude") rows.push("────────────────────────────────────────");
const start = rows.length;
const displayed = ghost && cursor === input.length ? `${input}\x1b[2m suggested follow-up\x1b[22m`
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : cursorBlock ? "\x1b[7m \x1b[0m" : "");
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : "");
const body = displayed.split("\n");
rows.push(`${runtime === "claude" ? "❯\u00a0" : "› "}${body[0]}`, ...body.slice(1).map(line => ` ${line}`));
rows.push(...(runtime === "claude" ? [rule, cursorBlock
? " \x1b[38;5;211m⏵⏵ bypass permissions on\x1b[38;5;246m (shift+tab to cycle)\x1b[0m" : "? for shortcuts"] : ["", "fixture · 90% context left"]));
rows.push(...(runtime === "claude" ? ["────────────────────────────────────────", "? for shortcuts"] : ["", "fixture · 90% context left"]));
if (runtime === "claude" && ghost) {
rows[rows.length - 1] = "⏵⏵ accept edits on (shift+tab to cycle) · ← for agents";
rows.push("✘ Auto-update failed: no write permission to npm prefix · Run claude doctor");
@@ -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");
});
});