feat(cli): rig launch takes a rig name as well as its ID (#1312)

`rig launch` used to need a rig's ID; a name was passed through unresolved and the daemon answered not-found. Now it resolves the handle first, locally and with `--host` or a selected host, on the single, subset, plan and first-start retry routes: an exact ID wins (archived IDs and ID-looking names included), then a unique active name; an ambiguous name stops before anything is sent and lists the candidate IDs; a missing handle, or a lookup that fails or returns an unusable list, sends the raw handle exactly as before. Each launch makes that one lookup first. `rig down --host` uses the same remote lookup, so its results change only for host lists that hold malformed rows: an exact ID or unique active name no longer crashes on an unrelated null row and posts the down for that rig; a missing or archived-only name, or an ambiguous one, with a null row gives the existing error (read from source, not measured); a response that isn't a list gives a readable error and sends nothing; a name carried only by a row without a rig ID is not found and sends nothing; and a row without a rig ID that shares a name still counts toward that name's ambiguity, as before. Local `rig down` and `rig add` are unchanged. The PR also updates the CLI reference, the openrig-user and topology skills and the capability page, and regenerates the generated skill-edge digest.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-15 01:57:10 -07:00
1 parent 63f7b4caab
commit 8daf6a43df
11 files changed
+403 -35

No files matched your search

+1 -1
View File
@@ -801,7 +801,7 @@ the sections that were not read are `null`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig launch <rigId> [nodeRef]` | — | `--seats <ids>`<br>`--hold-reason <reason>`<br>`--snapshot-id <id>`<br>`--retry-startup-from <member-file>`<br>`--rig-root <path>`<br>`--plan`<br>`--json`<br>`--host <id>` |
| `rig launch <rig> [nodeRef]` | — | `--seats <ids>`<br>`--hold-reason <reason>`<br>`--snapshot-id <id>`<br>`--retry-startup-from <member-file>`<br>`--rig-root <path>`<br>`--plan`<br>`--json`<br>`--host <id>` |
### remove
+56 -8
View File
@@ -7,6 +7,7 @@ import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-life
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { answerShowsAction, isPlanAnswer, notAPlanMessage, planSupportRefusal } from "../launch-plan-guard.js";
import { resolveRigHandle } from "../rig-handle.js";
type LaunchResponse = {
ok: boolean;
@@ -48,6 +49,21 @@ function printSnapshotSelection(selection: LaunchResponse["snapshotSelection"]):
if (newer?.snapshotId) console.log(`Newer usable alternative: ${newer.snapshotId}${newer.kind ? ` (${newer.kind})` : ""}`);
}
/** A rig name that matches more than one active rig stops before any request: it would fail as "not found" anyway,
* and the candidates let the person re-run with the right id. */
function refuseAmbiguousRig(handle: string, ids: string[], json: boolean | undefined, host?: string, count = ids.length): void {
const fact = `'${handle}' matches ${count} rigs${host ? ` on host ${host}` : ""}.`;
const consequence = "Nothing was launched.";
const action = `Re-run with the specific rig id: ${ids.join(" | ")}`;
if (json) console.log(JSON.stringify({ ok: false, code: "rig_ambiguous", error: { fact, consequence, action, candidates: ids } }));
else {
console.error(`Error: ${fact}`);
console.error(` ${consequence}`);
console.error(` ${action}`);
}
process.exitCode = 1;
}
/** Build the launch command, allowing local launches that start seats a longer wait than previews. */
export function launchCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("launch").description("Launch or relaunch a node in a running rig");
@@ -60,7 +76,7 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
}
cmd
.argument("<rigId>", "Target rig ID")
.argument("<rig>", "Rig name or ID")
.argument("[nodeRef]", "Node logical ID or node ID (single target)")
.option("--seats <ids>", "Comma-separated logical IDs for subset launch")
.option("--hold-reason <reason>", "Reason for holding non-target seats")
@@ -70,7 +86,7 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
.option("--plan", "Show subset selection and non-target effects without mutation")
.option("--json", "JSON output")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml")
.action(async (rigId: string, nodeRef: string | undefined, opts: { json?: boolean; holdReason?: string; seats?: string; host?: string; snapshotId?: string; plan?: boolean; retryStartupFrom?: string; rigRoot?: string }) => {
.action(async (rigHandle: string, nodeRef: string | undefined, opts: { json?: boolean; holdReason?: string; seats?: string; host?: string; snapshotId?: string; plan?: boolean; retryStartupFrom?: string; rigRoot?: string }) => {
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
// else the persisted selection feeds the SHIPPED --host path; no
// selection = today exactly.
@@ -118,15 +134,21 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
return;
}
// The rig is named by its id or by its name; resolution runs once, after the input checks, right before the
// request. An exact id wins (archived rigs included), then one active rig of that name. A missing handle or a
// lookup that can't be read keeps today's path: the handle goes to the daemon as an id.
let rigId = rigHandle;
let rigLabel = rigHandle;
if (opts.host) {
const { runRemoteHttpOp } = await import("../remote-host-ops.js");
const { runRemoteHttpOp, lookupRemoteRigId } = await import("../remote-host-ops.js");
const seatList = opts.seats
? opts.seats.split(",").map((s) => s.trim()).filter(Boolean)
: (isSingleNodePlan && trimmedNodeRef ? [trimmedNodeRef] : []);
let apiPath: string;
let body: unknown;
let route: "subset" | "single";
if (seatList.length > 0) {
apiPath = `/api/rigs/${encodeURIComponent(rigId)}/nodes/launch-subset`;
route = "subset";
body = {
seats: seatList,
...(opts.holdReason ? { holdReason: opts.holdReason } : {}),
@@ -145,7 +167,7 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
process.exitCode = 1;
return;
}
apiPath = `/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(nodeRef)}/launch`;
route = "single";
body = retryBody ?? (opts.snapshotId ? { snapshotId: opts.snapshotId } : {});
} else {
console.error("Either a node reference or --seats is required for launch --host");
@@ -166,6 +188,16 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
return;
}
}
// After the plan-support check, so a plan the daemon can't honour costs no lookup.
const lookup = await lookupRemoteRigId(opts.host, rigHandle, deps);
if (lookup.kind === "ambiguous") {
refuseAmbiguousRig(rigHandle, lookup.ids, opts.json, opts.host, lookup.count);
return;
}
if (lookup.kind === "resolved") rigId = lookup.rigId;
const apiPath = route === "subset"
? `/api/rigs/${encodeURIComponent(rigId)}/nodes/launch-subset`
: `/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(nodeRef!)}/launch`;
const result = await runRemoteHttpOp(opts.host, "POST", apiPath, body, deps, opts);
const notAPlan = Boolean(opts.plan && !isPlanAnswer(result.data) && answerShowsAction(result.ok, result.data));
if (notAPlan) {
@@ -208,6 +240,20 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
? opts.seats.split(",").map((s) => s.trim()).filter(Boolean)
: (isSingleNodePlan && trimmedNodeRef ? [trimmedNodeRef] : []);
const resolveLocalRig = async (): Promise<boolean> => {
// A rig list that can't be matched (it throws on a malformed entry) keeps the raw handle, like a failed read.
const resolution = await resolveRigHandle(client, rigHandle).catch(() => ({ kind: "passthrough" as const, handle: rigHandle }));
if (resolution.kind === "ambiguous") {
refuseAmbiguousRig(rigHandle, resolution.ids, opts.json);
return false;
}
if (resolution.kind === "resolved") {
rigId = resolution.id;
if (resolution.byName) rigLabel = `${rigHandle} (${resolution.id})`;
}
return true;
};
if (seatList.length > 0) {
const body: { seats: string[]; holdReason?: string; snapshotId?: string; plan?: boolean; nonTargetMode?: "unchanged" | "detach_and_hold" } = { seats: seatList };
if (opts.holdReason) body.holdReason = opts.holdReason;
@@ -232,6 +278,7 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
}
body.plan = true;
}
if (!(await resolveLocalRig())) return;
const res = await client.post<LaunchResponse>(`/api/rigs/${encodeURIComponent(rigId)}/nodes/launch-subset`, body,
opts.plan ? undefined : { timeoutMs: 120_000 });
const notAPlan = Boolean(opts.plan && !isPlanAnswer(res.data) && answerShowsAction(res.status >= 200 && res.status < 300, res.data));
@@ -321,6 +368,7 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
return;
}
if (!(await resolveLocalRig())) return;
const body: Record<string, unknown> = retryBody ?? {};
if (opts.snapshotId) body.snapshotId = opts.snapshotId;
@@ -345,12 +393,12 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
for (const w of res.data.warnings ?? []) console.warn(`Warning: ${w}`);
};
if (res.data.code === "already_running" || (res.data.alreadyRunning && res.data.alreadyRunning.length > 0)) {
console.log(`Node ${logicalId} is already running in rig ${rigId} (not relaunched)`);
console.log(`Node ${logicalId} is already running in rig ${rigLabel} (not relaunched)`);
printLaunchWarnings();
return;
}
const sessionSuffix = res.data.sessionName ? ` (${res.data.sessionName})` : "";
console.log(`Launched node ${logicalId} in rig ${rigId}${sessionSuffix}`);
console.log(`Launched node ${logicalId} in rig ${rigLabel}${sessionSuffix}`);
printLaunchWarnings();
});
+37 -13
View File
@@ -100,23 +100,47 @@ export async function runRemoteHttpOp(
}
}
/** How a rig handle matched a host's rig list: an exact id first (archived rigs included), else one active rig of
* that name. Non-object rows (null, primitives) take no part, so they can't hide or block a match. A row without a
* string `rigId` can't be a target, but an active one still counts toward a name's ambiguity, and a name only it
* carries is not found. `unavailable` means the list itself couldn't be read or wasn't a list, so nothing is known. */
export type RemoteRigLookup =
| { kind: "resolved"; rigId: string; byName: boolean }
/** `count` is every active row with that name; `ids` the ones that can be targeted. */
| { kind: "ambiguous"; count: number; ids: string[] }
| { kind: "not_found" }
| { kind: "unavailable"; error: string | undefined };
export async function lookupRemoteRigId(hostId: string, handle: string, deps: RemoteHostDeps): Promise<RemoteRigLookup> {
const psResult = await runRemoteHttpOp(hostId, "GET", "/api/ps?includeArchived=true", undefined, deps, {});
if (!psResult.ok) return { kind: "unavailable", error: psResult.error };
if (!Array.isArray(psResult.data)) return { kind: "unavailable", error: "the host's rig list was not readable" };
const rows = (psResult.data as unknown[]).filter(
(r): r is { rigId?: unknown; name?: unknown; archivedAt?: string | null } => r !== null && typeof r === "object",
);
const targetable = (r: { rigId?: unknown }): r is { rigId: string } => typeof r.rigId === "string";
const exactId = rows.find((r) => targetable(r) && r.rigId === handle);
if (exactId && targetable(exactId)) return { kind: "resolved", rigId: exactId.rigId, byName: false };
const byName = rows.filter((r) => r.name === handle && !r.archivedAt);
if (byName.length > 1) return { kind: "ambiguous", count: byName.length, ids: byName.filter(targetable).map((r) => r.rigId) };
const only = byName[0];
if (only && targetable(only)) return { kind: "resolved", rigId: only.rigId, byName: true };
return { kind: "not_found" };
}
export async function resolveRemoteRigId(
hostId: string,
handle: string,
deps: RemoteHostDeps,
): Promise<{ ok: true; rigId: string } | { ok: false; error: string }> {
const psResult = await runRemoteHttpOp(hostId, "GET", "/api/ps?includeArchived=true", undefined, deps, {});
if (!psResult.ok) return { ok: false, error: `cannot resolve rig on host ${hostId}: ${psResult.error}` };
const rigs = psResult.data as Array<{ rigId: string; name: string; archivedAt?: string | null }>;
const exactId = rigs.find((r) => r.rigId === handle);
if (exactId) return { ok: true, rigId: exactId.rigId };
const byName = rigs.filter((r) => r.name === handle && !r.archivedAt);
if (byName.length === 1) return { ok: true, rigId: byName[0]!.rigId };
if (byName.length > 1) {
return { ok: false, error: `ambiguous rig name "${handle}" on host ${hostId}: ${byName.length} active rigs share that name. Use the rig id instead.` };
const lookup = await lookupRemoteRigId(hostId, handle, deps);
switch (lookup.kind) {
case "resolved": return { ok: true, rigId: lookup.rigId };
case "unavailable": return { ok: false, error: `cannot resolve rig on host ${hostId}: ${lookup.error}` };
case "ambiguous":
return { ok: false, error: `ambiguous rig name "${handle}" on host ${hostId}: ${lookup.count} active rigs share that name. Use the rig id instead.` };
case "not_found": return { ok: false, error: `rig "${handle}" not found on host ${hostId}` };
}
return { ok: false, error: `rig "${handle}" not found on host ${hostId}` };
}
+283
View File
@@ -0,0 +1,283 @@
// `rig launch` addresses a rig by its id or by its name, on every route, locally and on a selected host.
// An exact id wins (archived rigs included, and over another rig *named* like that id); then one active rig of that
// name. A name matching several active rigs stops before any request. A missing handle, or a rig lookup that fails
// or can't be read, keeps the earlier behaviour: the handle goes to the daemon as an id.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { launchCommand } from "../src/commands/launch.js";
import { lookupRemoteRigId, resolveRemoteRigId } from "../src/remote-host-ops.js";
import { downCommand } from "../src/commands/down.js";
const ALPHA = "01ALPHA000000000000000000A";
const ARCHIVED = "01ARCHIVED0000000000000000";
const DUP1 = "01DUP10000000000000000000A";
const DUP2 = "01DUP20000000000000000000B";
const TARGET = "01TARGET000000000000000000";
const NAMED_LIKE_TARGET = "01NAMEDLIKETARGET000000000";
/** The same synthetic rigs, in the local summary's shape and the remote ps shape. */
const RIGS = [
{ id: ALPHA, name: "alpha", archivedAt: null },
{ id: ARCHIVED, name: "old", archivedAt: "2026-10-01T00:00:00Z" },
{ id: DUP1, name: "dup", archivedAt: null },
{ id: DUP2, name: "dup", archivedAt: null },
{ id: TARGET, name: "target", archivedAt: null },
{ id: NAMED_LIKE_TARGET, name: TARGET, archivedAt: null },
];
type Answer = { status: number; data: unknown };
type Lookup = "rigs" | "fail" | "malformed" | "throws";
/** A daemon stub: answers the rig lookup per `lookup`, records every launch POST, and acknowledges it. */
function daemon(lookup: Lookup, shape: "summary" | "ps") {
const posts: Array<{ path: string; body: unknown }> = [];
const gets: string[] = [];
const client = {
get: vi.fn(async (path: string): Promise<Answer> => {
gets.push(path);
if (path.startsWith("/api/rigs/summary") || path.startsWith("/api/ps")) {
if (lookup === "throws") throw new Error("connect ECONNREFUSED");
if (lookup === "fail") return { status: 503, data: { error: "busy" } };
if (lookup === "malformed") return { status: 200, data: { rigs: "not a list" } };
return { status: 200, data: shape === "summary" ? RIGS : RIGS.map(({ id, ...r }) => ({ rigId: id, ...r })) };
}
// Plan-support reads (launch-plan-guard): a current daemon.
if (path === "/healthz") return { status: 200, data: { status: "ok", semver: "0.7.0" } };
return { status: 200, data: { version: "0.7.0" } };
}),
post: vi.fn(async (path: string, body: unknown): Promise<Answer> => {
posts.push({ path, body });
if (path.endsWith("/launch-subset")) {
const plan = (body as { plan?: boolean }).plan;
return { status: 201, data: plan
? { ok: true, planOnly: true, nonTargetEffects: { mode: "unchanged", affected: [] } }
: { ok: true, launched: [{ nodeId: "n1", logicalId: "worker", status: "fresh" }], held: [], alreadyRunning: [] } };
}
return { status: 201, data: { ok: true, nodeId: "n1", logicalId: "worker", sessionName: "worker@rig" } };
}),
};
return { client, posts, gets };
}
function deps(client: unknown) {
return {
lifecycleDeps: {
spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(() => true),
readFile: vi.fn(() => JSON.stringify({ pid: 1, port: 3000, db: "t.sqlite", startedAt: new Date().toISOString() })),
writeFile: vi.fn(), removeFile: vi.fn(), exists: vi.fn(() => true), mkdirp: vi.fn(), openForAppend: vi.fn(() => 1), isProcessAlive: vi.fn(() => true),
},
clientFactory: () => client,
hostRegistryLoader: () => ({ ok: true, registry: { hosts: [{ id: "h1", transport: "http", url: "http://h1.invalid:7433" }] } }),
} as never;
}
let out: string[];
let err: string[];
beforeEach(() => {
out = []; err = [];
vi.stubEnv("OPENRIG_HOST_SELECTED", "local");
vi.spyOn(console, "log").mockImplementation((...a) => { out.push(a.join(" ")); });
vi.spyOn(console, "error").mockImplementation((...a) => { err.push(a.join(" ")); });
vi.spyOn(console, "warn").mockImplementation(() => {});
process.exitCode = undefined;
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); process.exitCode = undefined; });
const retryFile = (() => {
const dir = mkdtempSync(join(tmpdir(), "launch-by-name-"));
const file = join(dir, "member.yaml");
writeFileSync(file, "member:\n id: worker\n runtime: claude-code\n");
return { file, root: dir };
})();
/** Every launch route, as argv after the rig handle, with the launch path it must POST to. */
const ROUTES = [
{ route: "single", args: ["worker"], path: (id: string) => `/api/rigs/${id}/nodes/worker/launch` },
{ route: "subset", args: ["--seats", "worker"], path: (id: string) => `/api/rigs/${id}/nodes/launch-subset` },
{ route: "subset plan", args: ["--plan", "--seats", "worker"], path: (id: string) => `/api/rigs/${id}/nodes/launch-subset` },
{ route: "single-seat plan", args: ["worker", "--plan"], path: (id: string) => `/api/rigs/${id}/nodes/launch-subset` },
{ route: "first-start retry", args: ["worker", "--retry-startup-from", retryFile.file, "--rig-root", retryFile.root], path: (id: string) => `/api/rigs/${id}/nodes/worker/launch` },
] as const;
/** Handles and the rig each one must reach (`null`: the raw handle goes to the daemon, as before). */
const HANDLES: Array<{ name: string; handle: string; reaches: string | null }> = [
{ name: "exact id", handle: ALPHA, reaches: ALPHA },
{ name: "unique active name", handle: "alpha", reaches: ALPHA },
{ name: "archived rig's id", handle: ARCHIVED, reaches: ARCHIVED },
{ name: "archived rig's name", handle: "old", reaches: null },
{ name: "an id that is also another rig's name", handle: TARGET, reaches: TARGET },
{ name: "missing", handle: "nope", reaches: null },
];
async function launch(d: ReturnType<typeof daemon>, argv: string[]) {
await launchCommand(deps(d.client)).parseAsync(["node", "rig", ...argv]);
}
for (const where of ["local", "explicit --host", "selected host"] as const) {
describe(`rig launch by rig name or id (${where})`, () => {
const shape = where === "local" ? "summary" : "ps";
const hostArgs = where === "explicit --host" ? ["--host", "h1"] : [];
beforeEach(() => { if (where === "selected host") vi.stubEnv("OPENRIG_HOST_SELECTED", "h1"); });
for (const r of ROUTES) {
for (const h of HANDLES) {
it(`${r.route}: ${h.name}`, async () => {
const d = daemon("rigs", shape);
await launch(d, [h.handle, ...r.args, ...hostArgs]);
expect(d.posts.map((p) => p.path)).toEqual([r.path(encodeURIComponent(h.reaches ?? h.handle))]);
expect(d.gets.filter((g) => g.startsWith(shape === "summary" ? "/api/rigs/summary" : "/api/ps"))).toHaveLength(1);
expect(process.exitCode ?? 0).toBe(0);
});
}
it(`${r.route}: an ambiguous name stops before any request and lists the candidates`, async () => {
const d = daemon("rigs", shape);
await launch(d, ["dup", ...r.args, ...hostArgs]);
expect(d.posts).toEqual([]);
expect(process.exitCode).toBe(1);
expect(err.join("\n")).toContain("'dup' matches 2 rigs");
expect(err.join("\n")).toContain(`${DUP1} | ${DUP2}`);
expect(err.join("\n")).toContain("Nothing was launched.");
});
for (const lookup of ["fail", "malformed", "throws"] as const) {
it(`${r.route}: a lookup that ${lookup === "fail" ? "fails" : lookup === "malformed" ? "can't be read" : "can't connect"} keeps the raw handle`, async () => {
const d = daemon(lookup, shape);
await launch(d, ["alpha", ...r.args, ...hostArgs]);
expect(d.posts.map((p) => p.path)).toEqual([r.path("alpha")]);
});
}
}
it("--json reports an ambiguous name as data, with the candidates", async () => {
const d = daemon("rigs", shape);
await launch(d, ["dup", "worker", "--json", ...hostArgs]);
expect(JSON.parse(out.join("\n"))).toMatchObject({ ok: false, code: "rig_ambiguous", error: { candidates: [DUP1, DUP2] } });
});
});
}
describe("rig launch by name: what a person sees locally", () => {
it("names the rig and its id after a launch by name, and the id alone after a launch by id", async () => {
await launch(daemon("rigs", "summary"), ["alpha", "worker"]);
expect(out).toContain(`Launched node worker in rig alpha (${ALPHA}) (worker@rig)`);
out.length = 0;
await launch(daemon("rigs", "summary"), [ALPHA, "worker"]);
expect(out).toContain(`Launched node worker in rig ${ALPHA} (worker@rig)`);
});
it("does not look the rig up for a request it refuses on its own input", async () => {
const d = daemon("rigs", "summary");
await launch(d, ["alpha"]);
expect(d.gets).toEqual([]);
expect(d.posts).toEqual([]);
});
});
/** A rig list with one malformed row (null, a primitive, a row without an id), before or after the real rows. */
function listWithBadRow(bad: unknown, where: "before" | "after", shape: "summary" | "ps"): unknown[] {
const rows = shape === "summary" ? RIGS : RIGS.map(({ id, ...r }) => ({ rigId: id, ...r }));
return where === "before" ? [bad, ...rows] : [...rows, bad];
}
function daemonWithRows(rows: unknown[]) {
const d = daemon("rigs", "summary");
const get = d.client.get;
d.client.get = vi.fn(async (path: string) =>
path.startsWith("/api/rigs/summary") || path.startsWith("/api/ps") ? (d.gets.push(path), { status: 200, data: rows }) : get(path));
return d;
}
const BAD_ROWS: Array<[string, unknown]> = [["null", null], ["a primitive", 7], ["a row without an id", { name: "zzz" }]];
describe("a malformed row in the rig list never refuses a valid id, and doesn't hide a match", () => {
for (const [badName, bad] of BAD_ROWS) {
for (const where of ["before", "after"] as const) {
for (const r of ROUTES) {
it(`local ${r.route}, ${badName} ${where}: by id launches that id`, async () => {
const d = daemonWithRows(listWithBadRow(bad, where, "summary"));
await launch(d, [ALPHA, ...r.args]);
expect(d.posts.map((p) => p.path)).toEqual([r.path(ALPHA)]);
});
it(`--host ${r.route}, ${badName} ${where}: by id and by name reach the rig`, async () => {
for (const handle of [ALPHA, "alpha"]) {
const d = daemonWithRows(listWithBadRow(bad, where, "ps"));
await launch(d, [handle, ...r.args, "--host", "h1"]);
expect(d.posts.map((p) => p.path)).toEqual([r.path(ALPHA)]);
}
});
}
it(`local, ${badName} ${where}: by name resolves over the readable rows, or keeps the raw name`, async () => {
const d = daemonWithRows(listWithBadRow(bad, where, "summary"));
await launch(d, ["alpha", "worker"]);
// The shared local resolver throws on a null row; the launch then keeps the raw handle, as before this change.
expect(d.posts.map((p) => p.path)).toEqual([`/api/rigs/${bad === null ? "alpha" : ALPHA}/nodes/worker/launch`]);
});
it(`rig down --host, ${badName} ${where}: by id and by name tear down that rig`, async () => {
for (const handle of [ALPHA, "alpha"]) {
const d = daemonWithRows(listWithBadRow(bad, where, "ps"));
await downCommand(deps(d.client)).parseAsync(["node", "rig", handle, "--host", "h1", "--json"]);
expect(d.posts).toEqual([{ path: "/api/down", body: expect.objectContaining({ rigId: ALPHA }) }]);
}
});
}
}
});
describe("a host's row without an id still counts toward a name, but is never a target", () => {
const ghost = { name: "alpha", archivedAt: null }; // an active row named like alpha, with no rigId
for (const host of ["explicit", "selected"] as const) {
const hostArgs = host === "explicit" ? ["--host", "h1"] : [];
beforeEach(() => { if (host === "selected") vi.stubEnv("OPENRIG_HOST_SELECTED", "h1"); });
for (const where of ["before", "after"] as const) {
it(`rig down --host (${host}), ${where}: the name stays ambiguous and nothing is sent`, async () => {
const d = daemonWithRows(listWithBadRow(ghost, where, "ps"));
await downCommand(deps(d.client)).parseAsync(["node", "rig", "alpha", ...hostArgs, "--json"]);
expect(d.posts).toEqual([]);
expect(process.exitCode).toBe(1);
expect(JSON.parse(out.join("\n"))).toEqual({ ok: false,
error: 'ambiguous rig name "alpha" on host h1: 2 active rigs share that name. Use the rig id instead.' });
});
it(`rig launch (${host}), ${where}: the name stays ambiguous and nothing is sent`, async () => {
const d = daemonWithRows(listWithBadRow(ghost, where, "ps"));
await launch(d, ["alpha", "worker", ...hostArgs]);
expect(d.posts).toEqual([]);
expect(err.join("\n")).toContain("'alpha' matches 2 rigs on host h1.");
});
it(`(${host}), ${where}: the exact id still selects the valid rig`, async () => {
const down = daemonWithRows(listWithBadRow(ghost, where, "ps"));
await downCommand(deps(down.client)).parseAsync(["node", "rig", ALPHA, ...hostArgs, "--json"]);
expect(down.posts).toEqual([{ path: "/api/down", body: expect.objectContaining({ rigId: ALPHA }) }]);
const up = daemonWithRows(listWithBadRow(ghost, where, "ps"));
await launch(up, [ALPHA, "worker", ...hostArgs]);
expect(up.posts.map((p) => p.path)).toEqual([`/api/rigs/${ALPHA}/nodes/worker/launch`]);
});
}
it(`rig down --host (${host}): a name only an id-less row carries is not found, and nothing is sent`, async () => {
const d = daemonWithRows(listWithBadRow({ name: "ghost", archivedAt: null }, "after", "ps"));
await downCommand(deps(d.client)).parseAsync(["node", "rig", "ghost", ...hostArgs, "--json"]);
expect(d.posts).toEqual([]);
expect(JSON.parse(out.join("\n"))).toEqual({ ok: false, error: 'rig "ghost" not found on host h1' });
});
}
});
describe("remote rig lookup keeps rig down --host's results", () => {
const remote = (lookup: Lookup) => deps(daemon(lookup, "ps").client) as never;
it("distinguishes each outcome", async () => {
expect(await lookupRemoteRigId("h1", "alpha", remote("rigs"))).toEqual({ kind: "resolved", rigId: ALPHA, byName: true });
expect(await lookupRemoteRigId("h1", ALPHA, remote("rigs"))).toEqual({ kind: "resolved", rigId: ALPHA, byName: false });
expect(await lookupRemoteRigId("h1", "dup", remote("rigs"))).toEqual({ kind: "ambiguous", count: 2, ids: [DUP1, DUP2] });
expect(await lookupRemoteRigId("h1", "nope", remote("rigs"))).toEqual({ kind: "not_found" });
expect(await lookupRemoteRigId("h1", "alpha", remote("fail"))).toMatchObject({ kind: "unavailable" });
expect(await lookupRemoteRigId("h1", "alpha", remote("malformed"))).toMatchObject({ kind: "unavailable" });
});
it("resolveRemoteRigId returns the same messages as before", async () => {
expect(await resolveRemoteRigId("h1", "alpha", remote("rigs"))).toEqual({ ok: true, rigId: ALPHA });
expect(await resolveRemoteRigId("h1", "dup", remote("rigs"))).toEqual({ ok: false,
error: 'ambiguous rig name "dup" on host h1: 2 active rigs share that name. Use the rig id instead.' });
expect(await resolveRemoteRigId("h1", "nope", remote("rigs"))).toEqual({ ok: false, error: 'rig "nope" not found on host h1' });
expect(await resolveRemoteRigId("h1", "alpha", remote("fail"))).toEqual({ ok: false, error: "cannot resolve rig on host h1: HTTP 503" });
});
});
+11
View File
@@ -18,6 +18,15 @@ function deps(client: DaemonClient): StatusDeps {
};
}
/** The launch resolves the rig handle with one summary read first; answer it at once with no rigs, so the handle
* goes on as an id and only the launch request itself is slow. */
function answerRigLookup(req: http.IncomingMessage, res: http.ServerResponse): boolean {
if (req.method !== "GET" || !req.url?.startsWith("/api/rigs/summary")) return false;
res.writeHead(200, { "Content-Type": "application/json" });
res.end("[]");
return true;
}
let server: http.Server | undefined;
beforeEach(() => {
vi.stubEnv("OPENRIG_HOST_SELECTED", "local");
@@ -42,6 +51,7 @@ describe("local launch request deadlines", () => {
let requests = 0;
let requestBody = "";
server = http.createServer(async (req, res) => {
if (answerRigLookup(req, res)) return;
for await (const chunk of req) requestBody += chunk.toString();
requests++;
res.writeHead(201, { "Content-Type": "application/json" });
@@ -61,6 +71,7 @@ describe("local launch request deadlines", () => {
it("waits for a single-node launch response beyond the default five seconds without retrying", async () => {
let requests = 0;
server = http.createServer(async (req, res) => {
if (answerRigLookup(req, res)) return;
for await (const _chunk of req) { /* drain */ }
requests++;
res.writeHead(201, { "Content-Type": "application/json" });
+3 -2
View File
@@ -489,8 +489,9 @@ describe("rig launch --host HTTP", () => {
const { stdout, exitCode } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "launch", "rig-1", "--seats", "dev.impl", "--plan", "--host", "host-b", "--json"]);
});
expect(client._calls.map((c) => `${c.method} ${c.path}`)).toEqual([`GET ${HEALTH}`, `POST ${SUBSET}`]);
expect(client._calls[1]!.body).toMatchObject({ seats: ["dev.impl"], plan: true });
// The rig lookup runs after the plan-support check; this host has no rig list, so the handle goes on as an id.
expect(client._calls.map((c) => `${c.method} ${c.path}`)).toEqual([`GET ${HEALTH}`, "GET /api/ps?includeArchived=true", `POST ${SUBSET}`]);
expect(client._calls[2]!.body).toMatchObject({ seats: ["dev.impl"], plan: true });
expect(exitCode).toBeUndefined();
expect(JSON.parse(stdout.join(""))).toMatchObject({ ok: true, data: { planOnly: true } });
});
@@ -1281,8 +1281,8 @@ This lets ordinary agents ask the manager for OpenRig help instead of every agen
```bash
rig grow <rig-id> <member...> [--pod <pod> | --new-pod <pod>] [--runtime <runtime>] [--cwd <path>] [--json]
rig expand <rig-id> <pod-fragment-path> [--rig-root <path>] [--json]
rig launch <rigId> <nodeRef> [--json]
rig launch <rigId> --seats <a,b,c> [--hold-reason <text>] [--json]
rig launch <rig> <nodeRef> [--json]
rig launch <rig> --seats <a,b,c> [--hold-reason <text>] [--json]
rig remove <rigId> <nodeRef> [--json]
rig shrink <rigId> <podRef> [--json]
rig unclaim <sessionRef> [--json]
@@ -1295,8 +1295,9 @@ or `rig add` (one member) when a seat needs an explicit model, a permission poli
different agent spec or role profile, per-seat runtime or cwd, or startup files.
Node-granular managed partial restore (v0.3.4+):
- `rig launch <rigId> <nodeRef>` relaunches a single seat by logical id or node id through orchestration.
- `rig launch <rigId> --seats <a,b,c>` relaunches a comma-separated subset of seats.
- `rig launch <rig> <nodeRef>` relaunches a single seat by logical id or node id through orchestration. `<rig>` is the
rig's name or ID; a name shared by more than one active rig stops with the candidate IDs.
- `rig launch <rig> --seats <a,b,c>` relaunches a comma-separated subset of seats.
- `--hold-reason <text>` records a reason for holding non-target seats during the partial launch.
- This is a SUPPORTED managed path. The prior `pod_aware_launch_unsupported` dead-end is retired; pod-aware narrow launch now goes through this surface rather than ad-hoc rebuilds.
@@ -367,8 +367,8 @@ not have to tear one down to change it, and you rarely have to start from nothin
human mode it first prints one line with each agent's last known activity, or “unknown”: a
best-effort snapshot, not a record of what was interrupted. With `--host`, a remote teardown
that reports errors exits 2.
- **`rig launch <rigId> [seat]`** — one seat is down; start just that one, without disturbing the
rest. It takes the rig ID from `rig ps --json`; a rig name gives `rig_not_found`. Add `--plan` to preview it first, locally or with `--host`; a plan is never sent to a
- **`rig launch <rig> [seat]`** — one seat is down; start just that one, without disturbing the
rest. It takes the rig's name or ID; a name shared by more than one active rig stops and lists their IDs. Add `--plan` to preview it first, locally or with `--host`; a plan is never sent to a
daemon older than 0.5.9, which would ignore it and launch. A seat whose agent exited to a shell in
its kept pane reads `attention_required` (exit 1), not already running: resume it, or
`rig seat launch <session> --fresh --stop`.
@@ -112,7 +112,7 @@ Per `cli-reference.md` v0.2.0:
- `rig grow <rig-id> <member...> [--pod <pod> | --new-pod <pod>] [--runtime <runtime>] [--cwd <path>]` (added after v0.2.0; check `rig grow --help`)
- `rig expand <rig-id> <pod-fragment-path>` (with optional `session_source`)
- `rig shrink <rigId> <podRef>`
- `rig launch <rigId> <nodeRef>`
- `rig launch <rig> <nodeRef>` (the rig's name or ID)
- `rig remove <rigId> <nodeRef>`
- `rig discover [--draft]`
- `rig bind <discoveredId> --rig <rigId> (--node <id> | --pod <ns> --member <name>)`
+3 -3
View File
@@ -22,7 +22,7 @@
"core/rig-lifecycle/SKILL.md": "8346e01d532795b24c3dba63413ca662c7b44cc5eed39ee2e85568580f40adca",
"core/session-source-fork/SKILL.md": "c5381d130e0a58c2dd6669770a391314b0db0797df07641cf2d3f9804fac21a2",
"core/specification-system/SKILL.md": "5226d6e8b1362df89ba3d5afd82e4b4add355870132ca4b140d7f9f4324ffdc1",
"core/topology-mutation-and-seat-management/SKILL.md": "77db0e4cbaf09a78c86f59624cc10975ccd00231814cc1d9001fcfbd5a750f9f",
"core/topology-mutation-and-seat-management/SKILL.md": "527ca074432ce6513fb25cd27e8c709dabe0c91361b8999e09b272f3813abe62",
"core/watchdog/SKILL.md": "24b2e756e3e9d067fa93a4b5fa5f48a5cce511e8b1a5b360b1c39bc09bc95d82",
"pm/backlog-capture/SKILL.md": "345c2b47fc85e48c15c0d42bbb48fb27dd3b0ceec36bf59dc6ed20daaea47a84",
"pm/context-builder/SKILL.md": "0134c9b0a9f196565109f7f51b6ec78d5c3e4b5982a0d7105a93b29952625b3c",
@@ -82,7 +82,7 @@
"openrig-operating-model/templates/SOP.md": "1eaf0daf4f9bbf0715e6a5aaf0f29f030af5fe8b8a10232488ee97c507afa6dc",
"openrig-operating-model/templates/SPEC.md": "f126536daa59cea3f1cfb0898d83c7cdf99f6392f6f155ea64fe55187dd0f5f1",
"openrig-skills/SKILL.md": "ed2de7fe238dcfeda0e108e3cd8c447a6c9f2d16869bbdbac404e0a1621cd6f6",
"openrig-user/SKILL.md": "f5acb06f98c225c6be1212572ea266af9a73a949f86249d684a3767d41edc0be",
"openrig-user/SKILL.md": "f24074e15219fc51162805dbc065baa594f5c36a35259d7ab1203d097a9bdb92",
"orienting-to-an-inherited-seat/CHANGELOG.md": "4d0970e75e5c15b00a48d6a7150376d646257777264fd1b9a191165a8d5a6e84",
"orienting-to-an-inherited-seat/SKILL.md": "65300ce0e299cce0148a8bacd51b0475ac1b59cd77a51a9bb2db9d43bda6e208",
"queue-handoff/SKILL.md": "d4d7e3e8772886b3771daffd60523b5f79040309b892c8be1afd47d1f7287f7b",
@@ -119,7 +119,7 @@
"core/rig-lifecycle/SKILL.md": "8346e01d532795b24c3dba63413ca662c7b44cc5eed39ee2e85568580f40adca",
"core/session-source-fork/SKILL.md": "c5381d130e0a58c2dd6669770a391314b0db0797df07641cf2d3f9804fac21a2",
"core/specification-system/SKILL.md": "5226d6e8b1362df89ba3d5afd82e4b4add355870132ca4b140d7f9f4324ffdc1",
"core/topology-mutation-and-seat-management/SKILL.md": "77db0e4cbaf09a78c86f59624cc10975ccd00231814cc1d9001fcfbd5a750f9f",
"core/topology-mutation-and-seat-management/SKILL.md": "527ca074432ce6513fb25cd27e8c709dabe0c91361b8999e09b272f3813abe62",
"core/watchdog/SKILL.md": "24b2e756e3e9d067fa93a4b5fa5f48a5cce511e8b1a5b360b1c39bc09bc95d82",
"pm/backlog-capture/SKILL.md": "345c2b47fc85e48c15c0d42bbb48fb27dd3b0ceec36bf59dc6ed20daaea47a84",
"pm/context-builder/SKILL.md": "0134c9b0a9f196565109f7f51b6ec78d5c3e4b5982a0d7105a93b29952625b3c",
@@ -112,7 +112,7 @@ Per `cli-reference.md` v0.2.0:
- `rig grow <rig-id> <member...> [--pod <pod> | --new-pod <pod>] [--runtime <runtime>] [--cwd <path>]` (added after v0.2.0; check `rig grow --help`)
- `rig expand <rig-id> <pod-fragment-path>` (with optional `session_source`)
- `rig shrink <rigId> <podRef>`
- `rig launch <rigId> <nodeRef>`
- `rig launch <rig> <nodeRef>` (the rig's name or ID)
- `rig remove <rigId> <nodeRef>`
- `rig discover [--draft]`
- `rig bind <discoveredId> --rig <rigId> (--node <id> | --pod <ns> --member <name>)`