Compare commits

..
Author SHA1 Message Date
Mike Schwarz c92b4f3c93 fix(delivery): ignore Claude's drawn cursor blank (#2933)
For #2925. On an idle Claude seat with the typing guard set to draft-aware, every automatic delivery was held as draft_input_busy while rig capture read the input box as empty. The guard's strict reader counted Claude's own drawn cursor, a single inverse-video blank at the start of the composer, as typed text; the capture annotation already trimmed it. The strict reader now recognises that one drawn blank at the start of a one-row Claude composer and reads the box as empty. Real draft text, ordinary spaces, a moved cursor and multi-row input keep the existing protection, and the explicit draft-aware mode's handling of unreadable input is unchanged. No new refusal or timeout. Reproduced in an isolated 68x31 tmux 3.6 pane with the reported frame: unchanged main held the send, and with the change the message submits once while a real draft stays untouched. It doesn't explain the reported delay of about fifteen minutes before the first hold.
2026-11-10 19:05:22 -08:00
Mike Schwarz 85b4ccf27a docs: clarify when seats receive new guidance (#2930)
Three corrections to the agent startup guide's section on how a running seat gets new guidance. A send can be retained rather than delivered, so the guide no longer promises next-turn delivery or relative order. A managed refocus after compaction waits for an eligible prompt, which may not be the immediate next one. And rig walk reads each named file and sends its contents, unlike a pointer to a file that the seat still has to read. Finding a file, a hook picking it up and the agent reading it stay separate facts. Docs only.
2026-11-10 18:37:30 -08:00
15 changed files with 72 additions and 109 deletions

No files matched your search

+12 -10
View File
@@ -428,22 +428,24 @@ running seat only through the action in the last column.
### New guidance for a running seat
These reach a running seat without a restart, in the order they reach the agent:
There are three ways for a seat to pick up new guidance:
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.
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`.
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. 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.
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.
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 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.
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.
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,7 +103,6 @@ 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";
@@ -446,7 +445,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 (seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(blocker.nodeId)?.attachmentType)) === "external") continue;
if (this.sessionRegistry.getBindingForNode(blocker.nodeId)?.attachmentType === "external_cli") continue;
const reason = `${blocker.message} ${blocker.remediation}`;
heldForStartup.set(blocker.nodeId, heldForStartup.has(blocker.nodeId) ? `${heldForStartup.get(blocker.nodeId)} ${reason}` : reason);
}
@@ -714,7 +713,7 @@ export class RestoreOrchestrator {
continue;
}
// An external CLI's claim has no tmux session to probe; restoreNodeWithCompensation leaves it attached.
const external = seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(node.id)?.attachmentType)) === "external";
const external = this.sessionRegistry.getBindingForNode(node.id)?.attachmentType === "external_cli";
const sessions = external ? [] : this.sessionRegistry.getSessionsForRig(rigId)
.filter((s) => s.nodeId === node.id && s.status === "running");
@@ -829,7 +828,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 (seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(node.id)?.attachmentType)) === "external") continue;
if (this.sessionRegistry.getBindingForNode(node.id)?.attachmentType === "external_cli") continue;
const sessions = this.sessionRegistry.getSessionsForRig(rigId)
.filter((s) => s.nodeId === node.id && s.status === "running");
@@ -905,7 +904,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 (seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(session.nodeId)?.attachmentType)) === "external") continue;
if (this.sessionRegistry.getBindingForNode(session.nodeId)?.attachmentType === "external_cli") continue;
try {
// Replacement must preserve unknown transport state before any reconciliation write.
if (selectedIds) {
@@ -1111,7 +1110,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 && seatProcessOwner(resolveSeatKind(external.attachmentType)) === "external") {
if (external?.attachmentType === "external_cli") {
return { nodeId, logicalId: node.logicalId, status: "left_attached", error: leftAttachedReason(external.externalSessionName ?? node.logicalId) };
}
@@ -21,7 +21,6 @@ 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
@@ -322,7 +321,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 = seatProcessOwner(resolveSeatKind(node.binding?.attachmentType)) === "external";
const attached = node.binding?.attachmentType === "external_cli";
const { intendedAction, reason } = attached
? { intendedAction: "leave-attached" as const, reason: leftAttachedReason(node.binding?.externalSessionName ?? node.logicalId) }
: runtimeError
+1 -2
View File
@@ -16,7 +16,6 @@ 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;
@@ -189,7 +188,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 (seatProcessOwner(resolveSeatKind(this.deps.sessionRegistry.getBindingForNode(session.nodeId)?.attachmentType)) === "external") {
if (this.deps.sessionRegistry.getBindingForNode(session.nodeId)?.attachmentType === "external_cli") {
external.add(session.nodeId);
(result.leftRunning ??= []).push(session.sessionName);
continue;
-8
View File
@@ -23,11 +23,3 @@ 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,7 +54,6 @@ 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,
@@ -1428,7 +1427,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 = seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(nodeId)?.attachmentType)) === "external";
const externalCli = this.sessionRegistry.getBindingForNode(nodeId)?.attachmentType === "external_cli";
return {
ok: false, code: "claimed_session",
message: `Seat "${seatRef}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
@@ -2293,7 +2292,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,
seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(seat.nodeId)?.attachmentType)) === "external");
this.sessionRegistry.getBindingForNode(seat.nodeId)?.attachmentType === "external_cli");
}
private describe(resolved: ResolvedSeat): SeatDescriptor {
@@ -449,14 +449,18 @@ function closesFramedPromptAbove(lines: string[], open: number, indent: number,
const codexFooter = (line: string): boolean => isComposerFooter(line)
|| /(?:\b\d+% context left\b|\bContext \[)/.test(line);
/** Preserve faint styling so autocomplete ghosts can be separated from authored
* text. Color channel values must never be interpreted as style codes. */
/** Preserve the styling used for autocomplete ghosts and the drawn cursor.
* Color channel values must never be interpreted as style codes. */
function styledTextLines(screen: string) {
let faint = false;
let faint = false, inverse = false;
return screen.replace(/\r\n/g, "\n").split("\n").map(raw => {
let text = "", offset = 0;
const faintCells: boolean[] = [];
const append = (value: string) => { text += value; faintCells.push(...Array<boolean>(value.length).fill(faint)); };
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));
};
for (const match of raw.matchAll(/\x1b\[([0-9;:]*)m/g)) {
append(raw.slice(offset, match.index));
const codes = match[1]!.split(";");
@@ -467,15 +471,18 @@ 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 = false; break; }
if (!skip || i + skip !== codes.length - 1) { faint = inverse = false; break; }
i += skip;
} else if (code === "" || code === "0" || code === "22") faint = false;
} else if (code === "" || code === "0") faint = inverse = false;
else if (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 };
return { text, faintCells, inverseCells };
});
}
@@ -570,7 +577,11 @@ 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;
const empty = atStart && rows.length === 1 && rows[0] === "";
// 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 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,6 +162,15 @@ 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 { inspectComposerInput } from "../src/domain/session-transport.js";
import { annotateInputBox, 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("submits a multiline paste under a combined update footer through real tmux", async () => {
it.each(["update-banner", "cursor-block"])("submits a multiline paste with %s and keeps a real draft through tmux", async variant => {
const scratch = mkdtempSync(join(tmpdir(), "draft-banner-"));
const socket = join(scratch, "s");
const env = { ...process.env }; delete env.TMUX; delete env.TMUX_TMPDIR;
@@ -143,8 +143,9 @@ 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, "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();
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();
await vi.waitFor(() => expect(state().input).toBe(""), { timeout: 5000 });
migrate(db, ALL_MIGRATIONS);
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
@@ -157,7 +158,14 @@ 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");
await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
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"));
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 });
@@ -165,7 +173,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 });
.toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
expect(state()).toEqual({ input: "person's draft", submissions: [text] });
} finally {
await transport?.guardedDelivery?.stop();
+7 -3
View File
@@ -5,19 +5,23 @@ 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 (runtime === "claude") rows.push("────────────────────────────────────────");
if (cursorBlock) while (rows.length < 25) rows.push("");
if (runtime === "claude") rows.push(rule);
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" : "");
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : cursorBlock ? "\x1b[7m \x1b[0m" : "");
const body = displayed.split("\n");
rows.push(`${runtime === "claude" ? "❯\u00a0" : "› "}${body[0]}`, ...body.slice(1).map(line => ` ${line}`));
rows.push(...(runtime === "claude" ? ["────────────────────────────────────────", "? for shortcuts"] : ["", "fixture · 90% context left"]));
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"]));
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,23 +1135,6 @@ 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,16 +117,6 @@ 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,20 +980,6 @@ 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,17 +512,6 @@ 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"));
+1 -8
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, seatProcessOwner } from "../src/domain/seat-kinds.js";
import { resolveSeatKind, seatInboundTransport } from "../src/domain/seat-kinds.js";
describe("resolveSeatKind", () => {
it.each([
@@ -21,11 +21,4 @@ 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");
});
});