Compare commits

...
7 changed files with 551 additions and 6 deletions

No files matched your search

+18 -1
View File
@@ -794,7 +794,24 @@ Root: `rig`; declared option: `-V, --version`.
| 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 <rigId> [nodeRef]` | — | `--seats <ids>`<br>`--hold-reason <reason>`<br>`--snapshot-id <id>`<br>`--retry-startup-from <member-file>`<br>`--first-start-from <rig-file>`<br>`--rig-root <path>`<br>`--plan`<br>`--json`<br>`--host <id>` |
For an existing pod member that has never started, use
`rig launch <rigId> <nodeRef> --first-start-from <rig.yaml> --rig-root <absolute-root> --plan`.
The complete RigSpec supplies the selected member's inherited guidance; only that
existing node is eligible to launch. Its identity, source references, cwd and policy
must agree with the retained member. A mismatch requires separate source convergence.
The root and referenced files must already exist on the selected daemon host.
Omit `--plan` to print the resolved model, effort and permission plan, then execute
it once. `--json` emits a plan JSON line followed by a result JSON line; with
`--plan` it emits only the plan. Execution revalidates the source/state fingerprint.
An unset model means the runtime default is requested; the concrete native model
is unobserved. Persisted `rig seat set-model` and native permission choices apply.
Prior sessions, startup context or native history require the ordinary restore or
retry paths. Live or unknown occupancy refuses first start. No other seat, edge or
held state is changed. After a lost response, inspect the seat before recovery;
the command does not retry automatically.
### remove
+44 -3
View File
@@ -63,16 +63,57 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
.option("--hold-reason <reason>", "Reason for holding non-target seats")
.option("--snapshot-id <id>", "Use this exact restore-usable snapshot")
.option("--retry-startup-from <member-file>", "Retry a stopped first start that failed during projection, using its original member fragment")
.option("--rig-root <path>", "Original absolute source root for --retry-startup-from")
.option("--plan", "Show subset selection and non-target effects without mutation")
.option("--first-start-from <rig-file>", "Start one existing never-started member from an explicit complete RigSpec")
.option("--rig-root <path>", "Absolute daemon-host source root for explicit first start or first-start retry")
.option("--plan", "Show subset selection or explicit cold first-start configuration 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 (rigId: string, nodeRef: string | undefined, opts: { json?: boolean; holdReason?: string; seats?: string; host?: string; snapshotId?: string; plan?: boolean; retryStartupFrom?: string; firstStartFrom?: 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.
opts.host = resolveEffectiveHost(opts.host);
const deps = getDeps();
if (opts.firstStartFrom) {
if (!nodeRef || !opts.rigRoot || !isAbsolute(opts.rigRoot) || opts.retryStartupFrom || opts.seats || opts.snapshotId || opts.holdReason) {
console.error("Cold first start requires one node and an absolute --rig-root; do not combine it with retry, snapshot or subset options.");
process.exitCode = 1;
return;
}
let executing = false;
try {
const rigSpecYaml = readFileSync(opts.firstStartFrom, "utf8");
const path = `/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(nodeRef)}/launch/first-start`;
const client = opts.host ? null : await getClient(deps);
if (!opts.host && !client) { process.exitCode = 1; return; }
const post = async (body: unknown) => {
if (opts.host) {
const { runRemoteHttpOp } = await import("../remote-host-ops.js");
const result = await runRemoteHttpOp(opts.host, "POST", path, body, deps, opts);
if (!result.ok) throw new Error(JSON.stringify(result.data ?? result.error));
return result.data;
}
const response = await client!.post(path, body);
if (response.status >= 400) throw new Error(JSON.stringify(response.data));
return response.data;
};
const body = { rigSpecYaml, rigRoot: opts.rigRoot };
const planned = await post({ ...body, plan: true }) as { ok?: boolean; planOnly?: boolean; plan?: { fingerprint?: string } };
if (!planned?.ok || planned.planOnly !== true || !/^[a-f0-9]{64}$/.test(planned.plan?.fingerprint ?? "")) throw new Error("Daemon did not return a supported cold first-start plan; execution not attempted.");
// The receipt is visible BEFORE execution. --json emits two JSON lines
// (plan then result); --plan emits only the first, with no launch effects.
console.log(opts.json ? JSON.stringify(planned) : `First-start plan:\n${JSON.stringify(planned, null, 2)}`);
if (opts.plan) return;
executing = true;
const result = await post({ ...body, plan: false, fingerprint: planned.plan!.fingerprint }) as { ok?: boolean; status?: string };
console.log(JSON.stringify(result, null, opts.json ? undefined : 2));
if (!result?.ok || result.status !== "launched") process.exitCode = 1;
} catch (error) {
console.error(`${executing ? "First-start outcome unconfirmed; inspect the seat before recovery. No automatic retry." : "First start not executed."} ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}
return;
}
let retryBody: { retryStartupFrom: { member: Record<string, unknown>; rigRoot: string } } | undefined;
if (opts.retryStartupFrom || opts.rigRoot) {
if (!nodeRef || !opts.retryStartupFrom || !opts.rigRoot || !isAbsolute(opts.rigRoot) || opts.seats || opts.snapshotId || opts.plan || opts.holdReason) {
+34 -1
View File
@@ -2,7 +2,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { launchCommand } from "../src/commands/launch.js";
import { mkdtempSync, writeFileSync } from "node:fs";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -93,6 +93,39 @@ describe("rig launch --seats", () => {
expect(process.exitCode).toBeUndefined();
});
it.each(["plan", "execute", "lost-response", "unsupported", "changed-plan"])("cold first start: %s, no fallback or automatic retry", async mode => {
const directory = mkdtempSync(join(tmpdir(), "cold-start-cli-"));
const source = join(directory, "rig.yaml");
writeFileSync(source, "version: '0.2'\nname: cold\n");
const deps = makeDeps({});
const plan = { ok: true, planOnly: true, plan: { fingerprint: "a".repeat(64), model: { value: null, observation: "runtime default; concrete native model unobserved" } } };
deps._client.post.mockImplementation(async (_url, body) => {
const input = body as { plan: boolean; fingerprint?: string };
if (mode === "unsupported") return { status: 404, data: { ok: false } };
if (input.plan) return { status: 200, data: plan };
expect(logs.map(line => JSON.parse(line))).toEqual([plan]);
expect(input.fingerprint).toBe(plan.plan.fingerprint);
if (mode === "lost-response") throw new Error("Response unreadable; unknown outcome");
if (mode === "changed-plan") return { status: 409, data: { ok: false, code: "cold_start_plan_changed" } };
return { status: 201, data: { ok: true, status: "launched" } };
});
try {
await launchCommand(deps).parseAsync(["node", "rig", "rig-1", "dev.worker", "--first-start-from", source, "--rig-root", directory, "--json", ...(mode === "plan" ? ["--plan"] : [])]);
expect(deps._client.post).toHaveBeenCalledTimes(mode === "plan" || mode === "unsupported" ? 1 : 2);
expect(deps._client.post.mock.calls.every(([url]) => url.endsWith("/launch/first-start"))).toBe(true);
if (["lost-response", "unsupported", "changed-plan"].includes(mode)) expect(process.exitCode).toBe(1);
else expect(process.exitCode).toBeUndefined();
if (mode === "lost-response") expect(errors.join("\n")).toContain("outcome unconfirmed");
} finally { rmSync(directory, { recursive: true, force: true }); }
});
it.each([["--snapshot-id", "s"], ["--seats", "dev.worker"], ["--retry-startup-from", "member.yaml"], ["--hold-reason", "reason"]])("refuses ambiguous cold options %j without a request", async (...extra) => {
const deps = makeDeps({});
await launchCommand(deps).parseAsync(["node", "rig", "rig-1", "dev.worker", "--first-start-from", "/absent", "--rig-root", "/project", ...extra]);
expect(deps._client.post).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it.each([["--snapshot-id", "s"], ["--seats", "dev.pi"], ["--plan"], ["--hold-reason", "reason"]])("refuses ambiguous retry options %j before a request", async (...extra) => {
const deps = makeDeps({});
await launchCommand(deps).parseAsync(["node", "rig", "rig-1", "dev.pi", "--retry-startup-from", "/absent", "--rig-root", "/project", ...extra]);
@@ -1,4 +1,6 @@
import nodePath from "node:path";
import { createHash } from "node:crypto";
import { readdirSync, realpathSync } from "node:fs";
import type Database from "better-sqlite3";
import {
resolvePermissionPolicyAttachment,
@@ -307,6 +309,7 @@ import { RigSpecSchema as PodRigSpecSchema, VALID_EDGE_KINDS } from "./rigspec-s
import { rigPreflight, preflightValidatedSpec } from "./rigspec-preflight.js";
import { resolveAgentRef, type AgentResolverFsOps } from "./agent-resolver.js";
import { resolveNodeConfig } from "./profile-resolver.js";
import { NativePermissionStore } from "./native-permission-store.js";
import { resolveStartup } from "./startup-resolver.js";
import { planProjection, claudeConflictTargetPath, projectionConflictWarnings, filterProtectedProjections, type ProjectionPlan } from "./projection-planner.js";
import { ProjectionManifestStore } from "./projection-manifest-store.js";
@@ -410,6 +413,27 @@ export interface LaunchMaterializedNodeResult {
sessionName?: string;
}
export interface ColdFirstStartPlan {
rigId: string;
nodeId: string;
logicalId: string;
fingerprint: string;
runtime: string;
cwd: string;
codexConfigProfile: string | null;
model: { value: string | null; source: string; observation: string };
effort: { value: string | null; source: string };
permissions: {
memberRef: string | null;
rigRef: string | null;
launchPosture: string;
nativeOverride: ReturnType<NativePermissionStore["read"]>;
bindingOverride: ReturnType<NativePermissionStore["launchOverride"]>;
observation: string;
};
nonTargetEffects: "unchanged";
}
export type LaunchMaterializedOutcome =
| { ok: true; result: { nodes: LaunchMaterializedNodeResult[]; warnings?: string[] } }
| { ok: false; code: "validation_failed"; errors: string[] }
@@ -1139,6 +1163,143 @@ export class PodRigInstantiator {
};
}
/** Explicitly start one materialized, never-occupied member. Planning is read-only;
* execution requires the source/state fingerprint returned by planning. Never
* restore, materialize topology, or converge a different source on this path. */
async coldFirstStart(rigId: string, nodeId: string, rigSpecYaml: string, rigRoot: string, options: { plan: boolean; fingerprint?: string }): Promise<
{ ok: false; code: string; message: string } |
{ ok: true; planOnly: boolean; plan: ColdFirstStartPlan; status?: "launched"; sessionName?: string; warnings?: string[] }
> {
const refuse = (code: string, message: string) => ({ ok: false as const, code, message });
const guard = this.deps.tmuxAdapter?.deliveryGuard;
if (!guard) return refuse("cold_start_unavailable", "The node lifecycle guard is unavailable; no first start attempted.");
if (!guard.ownsLifecycle(nodeId)) {
return guard.lifecycle([nodeId], () => this.coldFirstStart(rigId, nodeId, rigSpecYaml, rigRoot, options))
.catch(error => refuse("cold_start_refused", error instanceof Error ? error.message : String(error)));
}
if (!nodePath.isAbsolute(rigRoot) || !this.deps.fsOps.exists(rigRoot)) return refuse("invalid_source_root", "Supply an existing absolute --rig-root on the daemon host.");
if (!options.plan && !options.fingerprint) return refuse("cold_start_plan_required", "Obtain a first-start plan before executing it.");
const prepare = () => {
const rig = this.deps.rigRepo.getRig(rigId);
const node = rig?.nodes.find(n => n.id === nodeId);
const podRow = node && this.deps.podRepo.getPodsForRig(rigId).find(p => p.id === node.podId);
if (!rig || !node || !podRow || node.runtime === "terminal") throw new Error("First start requires an existing agent member in a pod.");
if (this.db.prepare("SELECT 1 FROM rigs WHERE id = ? AND archived_at IS NOT NULL").get(rigId)) throw new Error("Archived rigs cannot be first-started.");
if (this.deps.sessionRegistry.getBindingForNode(nodeId)
|| this.deps.sessionRegistry.getSessionsForRig(rigId).some(s => s.nodeId === nodeId)
|| this.db.prepare("SELECT 1 FROM node_startup_context WHERE node_id = ?").get(nodeId)
|| this.db.prepare("SELECT 1 FROM occupant_tenures WHERE node_id = ?").get(nodeId)
|| this.db.prepare("SELECT 1 FROM events WHERE node_id = ? AND type IN ('node.launched', 'node.startup_ready', 'node.startup_failed')").get(nodeId)
|| node.occupantLifecycle || node.previousOccupant || node.handoverAt) {
throw new Error("Seat has binding, session, startup or occupancy history; use its existing restore/retry path.");
}
const raw = PodRigSpecCodec.parse(rigSpecYaml);
const validation = PodRigSpecSchema.validate(raw);
if (!validation.valid) throw new Error(validation.errors.join("; "));
const rigSpec = PodRigSpecSchema.normalize(raw as Record<string, unknown>);
const selected = this.findMemberContext(rigSpec, node.logicalId);
if (!selected) throw new Error("source_convergence_required: source does not contain the retained member.");
const { pod, member } = selected;
const same = (a: unknown, b: unknown) => (a ?? null) === (b ?? null);
const modelEvent = this.db.prepare("SELECT payload FROM events WHERE node_id = ? AND type = 'node.model_changed' ORDER BY seq DESC LIMIT 1").get(nodeId) as { payload: string } | undefined;
const modelSelected = modelEvent && JSON.parse(modelEvent.payload).to === node.model;
const rigPolicy = this.deps.rigRepo.getRigPermissionPolicy(rigId);
if (rigSpec.name !== rig.rig.name || pod.id !== podRow.namespace || pod.label !== podRow.label
|| !same(member.agentRef, node.agentRef) || !same(member.profile, node.profile) || !same(member.runtime, node.runtime)
|| !same(member.role, node.role) || !same(member.label, node.label) || !same(member.codexConfigProfile, node.codexConfigProfile)
|| !same(member.permissionPolicy, node.permissionPolicy) || !same(rigSpec.permissionPolicy, rigPolicy)
|| !same(resolveLaunchCwd(member.cwd, rigRoot), node.cwd) || !same(member.restorePolicy, node.restorePolicy)
|| !same(member.effort, node.effort) || (!modelSelected && !same(member.model, node.model))) {
throw new Error("source_convergence_required: explicit source disagrees with retained identity, cwd or configuration; nothing was changed.");
}
if (member.sessionSource || node.sessionSource) throw new Error("First start cannot fork, rebuild or consume a prior native session.");
const reads = new Map<string, string | null>();
const fsOps: AgentResolverFsOps = {
exists: path => this.deps.fsOps.exists(path),
readFile: path => { const bytes = this.deps.fsOps.readFile(path); reads.set(path, bytes); return bytes; },
};
const resolved = resolveAgentRef(member.agentRef, rigRoot, fsOps);
if (!resolved.ok) throw new Error(resolved.code === "validation_failed" ? resolved.errors.join("; ") : resolved.error);
if (node.resolvedSpecHash && node.resolvedSpecHash !== resolved.resolved.hash) throw new Error("source_convergence_required: retained agent source hash differs.");
const config = resolveNodeConfig({ baseSpec: resolved.resolved, importedSpecs: resolved.imports, collisions: resolved.collisions,
profileName: member.profile, specRoot: rigRoot, member, pod, rig: rigSpec, skillsRoot: this.resolveSkillsRoot(), ...this.systemWorldResolutionContext() });
if (!config.ok) throw new Error(config.errors.join("; "));
if (config.config.runtime !== node.runtime || config.config.cwd !== node.cwd) throw new Error("source_convergence_required: resolved runtime or cwd differs.");
if (modelSelected) config.config.model = node.model ?? undefined;
const ref = resolvePermissionPolicyRefValue(member.permissionPolicy, rigSpec.permissionPolicy);
const attachment = ref ? resolvePermissionPolicyAttachment(ref, rigRoot, fsOps) : undefined;
const nodePolicy = this.deps.rigRepo.getNodePolicyProvenance(nodeId);
const inheritedPolicy = this.deps.rigRepo.getRigPolicyProvenance(rigId);
if (attachment && nodePolicy && (attachment.launchPosture !== nodePolicy.launchPosture
|| !same(attachment.resolvedTarget, nodePolicy.resolvedTarget) || !same(attachment.declaringDir, nodePolicy.declaringDir))) {
throw new Error("source_convergence_required: resolved permission policy differs from retained provenance.");
}
const permissions = new NativePermissionStore(this.db);
const bindingOverride = permissions.launchOverride(nodeId, member.runtime);
const posture = bindingOverride.launchPosture ?? attachment?.launchPosture ?? nodePolicy?.launchPosture ?? inheritedPolicy?.launchPosture ?? "floor";
const projection = planProjection({ config: config.config, collisions: resolved.collisions, fsOps });
if (!projection.ok) throw new Error(projection.errors.join("; "));
const files = this.buildResolvedStartupFiles(resolved.resolved.spec, resolved.resolved.sourcePath,
resolved.resolved.spec.profiles[member.profile], rigSpec, rigRoot, pod, member);
// Bind the resolved source/guidance inputs, including optional-file absence.
for (const file of files) {
reads.set(file.absolutePath, fsOps.exists(file.absolutePath) ? fsOps.readFile(file.absolutePath) : null);
}
const directories = new Set<string>();
const capture = (path: string): void => {
if (!fsOps.exists(path)) { reads.set(path, null); return; }
try { fsOps.readFile(path); }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EISDIR") throw error;
const real = realpathSync(path);
if (directories.has(real)) return;
directories.add(real);
const names = readdirSync(path).sort();
reads.set(path, JSON.stringify(names));
for (const name of names) capture(nodePath.join(path, name));
}
};
for (const entry of projection.plan.entries) capture(entry.absolutePath);
const profile = resolved.resolved.spec.profiles[member.profile];
const plan: ColdFirstStartPlan = {
rigId, nodeId, logicalId: node.logicalId, fingerprint: "", runtime: member.runtime, cwd: config.config.cwd, codexConfigProfile: member.codexConfigProfile ?? null,
model: { value: config.config.model ?? null, source: modelSelected ? "seat set-model" : member.model ? "member" : profile?.preferences?.model ? "profile" : config.config.model ? "agent default" : "runtime default",
observation: config.config.model ? "requested; native selection unobserved" : "runtime default; concrete native model unobserved" },
effort: { value: config.config.effort ?? null, source: member.effort ? "member" : profile?.preferences?.effort ? "profile" : config.config.effort ? "agent default" : "runtime default" },
permissions: { memberRef: member.permissionPolicy ?? null, rigRef: rigPolicy, launchPosture: posture, nativeOverride: permissions.read(nodeId), bindingOverride,
observation: "requested posture; native effective permissions unobserved" },
nonTargetEffects: "unchanged",
};
plan.fingerprint = createHash("sha256").update(JSON.stringify({ rigSpecYaml, rigRoot, node, podRow, plan, config: config.config,
nodePolicy, inheritedPolicy, modelEvent, reads: [...reads].sort(([a], [b]) => a.localeCompare(b)) })).digest("hex");
return { rigSpec, pod, member, resolved, config, plan };
};
try {
const initial = prepare();
if (!options.plan && initial.plan.fingerprint !== options.fingerprint) return refuse("cold_start_plan_changed", "Source or seat configuration changed since planning; inspect a new plan. No launch attempted.");
// Preflight only this member; other declared members and edges remain untouched.
const preflight = await preflightValidatedSpec({ ...initial.rigSpec, pods: [{ ...initial.pod, members: [initial.member], edges: [] }], edges: [] }, {
rigRoot, fsOps: this.deps.fsOps, skillsRoot: this.resolveSkillsRoot(), ...this.systemWorldResolutionContext(),
inheritedPermissionPolicy: this.inheritedPermissionPolicy(rigId), exec: this.deps.exec, claudeActivityAssets: this.deps.claudeActivityAssets,
});
if (!preflight.ready) return refuse("cold_start_preflight_failed", preflight.errors.join("; "));
const sessionName = deriveCanonicalSessionName(initial.pod.id, initial.member.id, initial.rigSpec.name);
if ((await this.deps.tmuxAdapter?.probeSession(sessionName))?.state !== "absent") return refuse("cold_start_occupancy_unknown", `Session "${sessionName}" is live or unknown; no launch attempted.`);
const current = prepare();
if (initial.plan.fingerprint !== current.plan.fingerprint) return refuse("cold_start_plan_changed", "Source or seat state changed during preflight; no launch attempted.");
if (options.plan) return { ok: true, planOnly: true, plan: current.plan, warnings: preflight.warnings };
const result = await this.launchExistingAgentMember({ rigId, nodeId, qualifiedId: current.plan.logicalId, rigRoot,
rigSpec: current.rigSpec, pod: current.pod, member: current.member, resolveResult: current.resolved, configResult: current.config });
if (result.status !== "launched") return refuse(result.status, result.error ?? "First start did not reach readiness; inspect retained startup evidence before any recovery.");
return { ok: true, planOnly: false, plan: current.plan, status: "launched", sessionName: result.sessionName, warnings: result.warnings };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return refuse(message.startsWith("source_convergence_required:") ? "source_convergence_required" : "cold_start_refused", message);
}
}
/** Recover the legacy gap where projection failed before startup context was
* persisted. This is an explicit first-start retry, never a resume/fresh fallback.
* The operator supplies the original member source after exiting and cleaning
+19
View File
@@ -244,6 +244,25 @@ nodesRoutes.get("/:logicalId", async (c) => {
return c.json(detail);
});
// Separate mode URL: old daemons must return 404, never mistake a plan for restore.
nodesRoutes.post("/:logicalId/launch/first-start", async (c) => {
const rigId = c.req.param("rigId")!;
const ref = c.req.param("logicalId")!;
const rig = getDeps(c).rigRepo.getRig(rigId);
const node = rig?.nodes.find(n => n.logicalId === ref || n.id === ref);
if (!node) return c.json({ ok: false, code: "node_not_found", message: "Existing rig member not found." }, 404);
const body = await c.req.json().catch(() => null);
if (!body || typeof body.rigSpecYaml !== "string" || typeof body.rigRoot !== "string" || typeof body.plan !== "boolean"
|| (!body.plan && typeof body.fingerprint !== "string")
|| Object.keys(body).some(key => !["rigSpecYaml", "rigRoot", "plan", "fingerprint"].includes(key))) {
return c.json({ ok: false, code: "invalid_first_start", message: "Supply rigSpecYaml, absolute rigRoot, plan, and the planned fingerprint for execution." }, 400);
}
const instantiator = c.get("podInstantiator" as never) as PodRigInstantiator | undefined;
if (!instantiator) return c.json({ ok: false, code: "internal_error", message: "Pod instantiator unavailable" }, 500);
const result = await instantiator.coldFirstStart(rigId, node.id, body.rigSpecYaml, body.rigRoot, body);
return c.json(result, result.ok ? result.planOnly ? 200 : 201 : result.code === "failed" ? 500 : 409);
});
// POST /api/rigs/:rigId/nodes/:logicalId/launch
nodesRoutes.post("/:logicalId/launch", async (c) => {
const rigId = c.req.param("rigId")!;
@@ -0,0 +1,271 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createFullTestDb, createTestApp, mockTmuxAdapter } from "./helpers/test-app.js";
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import { SeatLifecycleService } from "../src/domain/seat-lifecycle-service.js";
import type { RuntimeAdapter } from "../src/domain/runtime-adapter.js";
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
import { seatDeliveryGuardSchema } from "../src/db/migrations/087_seat_delivery_guard.js";
import { serve } from "@hono/node-server";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const root = "/fixture/cold-start";
const closers: Array<() => void> = [];
afterEach(() => { for (const close of closers.splice(0)) close(); });
async function fixture(options: { model?: string; profileModel?: string; policy?: string; runtime?: "pi" | "codex" } = {}) {
const db = createFullTestDb();
db.exec(outboxEntriesSchema.sql);
db.exec(seatDeliveryGuardSchema.sql);
closers.push(() => db.close());
const tmux = mockTmuxAdapter();
tmux.deliveryGuard = new SeatDeliveryGuard(db, target => resolveGuardTarget(db, target));
tmux.probeSession = vi.fn(async () => ({ state: "absent" as const }));
const adapter: RuntimeAdapter = {
runtime: options.runtime ?? "pi", listInstalled: vi.fn(async () => []),
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
deliverStartup: vi.fn(async () => ({ delivered: 3, failed: [] })),
checkReady: vi.fn(async () => ({ ready: true })),
launchHarness: vi.fn(async binding => {
expect(db.prepare("SELECT 1 FROM node_startup_context WHERE node_id = ?").get(binding.nodeId)).toBeTruthy();
return { ok: true };
}),
};
const files: Record<string, string> = {
[root]: "",
[`${root}/agent/agent.yaml`]: `name: worker\nversion: '1.0.0'\nprofiles:\n default:\n preferences:\n effort: high\n${options.profileModel ? ` model: ${options.profileModel}\n` : ""}startup:\n files:\n - path: role.md\n delivery_hint: send_text\n required: true\n`,
[`${root}/agent/role.md`]: "The selected role.",
[`${root}/rig.md`]: "Rig guidance.", [`${root}/pod.md`]: "Pod guidance.",
};
const setup = createTestApp(db, { tmux, adapters: { [options.runtime ?? "pi"]: adapter }, podInstantiatorFsOps: {
exists: path => path in files,
readFile: path => { if (!(path in files)) throw new Error(`Missing ${path}`); return files[path]!; },
} });
const yaml = `version: '0.2'\nname: cold-start\n${options.policy ? `permission_policy: ${options.policy}\n` : ""}startup:\n files:\n - path: rig.md\n delivery_hint: send_text\npods:\n - id: dev\n label: Development\n startup:\n files:\n - path: pod.md\n delivery_hint: send_text\n members:\n - id: worker\n agent_ref: local:agent\n profile: default\n runtime: pi\n cwd: ${root}\n${options.model ? ` model: ${options.model}\n` : ""} - id: sibling\n agent_ref: builtin:terminal\n profile: none\n runtime: terminal\n cwd: ${root}\n edges:\n - from: worker\n to: sibling\n kind: collaborates_with\n`;
const source = options.runtime ? yaml.replace("runtime: pi", `runtime: ${options.runtime}`) : yaml;
const materialized = await setup.podInstantiator.materialize(source, root);
expect(materialized.ok, JSON.stringify(materialized)).toBe(true);
if (!materialized.ok) throw new Error("fixture did not materialize");
const rigId = materialized.result.rigId;
const node = setup.rigRepo.getRig(rigId)!.nodes.find(n => n.logicalId === "dev.worker")!;
const request = (plan = true, fingerprint?: string, spec = source, rigRoot = root) => setup.app.request(`/api/rigs/${rigId}/nodes/${node.logicalId}/launch/first-start`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rigSpecYaml: spec, rigRoot, plan, fingerprint }),
});
const plan = async () => {
const response = await request();
const body = await response.json();
expect(response.status, JSON.stringify(body)).toBe(200);
expect(body).toMatchObject({ ok: true, planOnly: true });
return body.plan;
};
return { ...setup, db, tmux, adapter, files, yaml: source, rigId, node, request, plan };
}
describe("explicit cold single-member first start", () => {
it("plans without writes, then starts only the original node with full inherited guidance", async () => {
const f = await fixture({ model: "provider/model", policy: "builtin:yolo" });
const before = f.rigRepo.getRig(f.rigId)!;
expect(f.sessionRegistry.getSessionsForRig(f.rigId)).toEqual([]);
const ordinary = await f.app.request(`/api/rigs/${f.rigId}/nodes/${f.node.logicalId}/launch`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
expect(await ordinary.json()).toMatchObject({ ok: false, code: "no_usable_snapshot" });
const changes = f.db.prepare("SELECT total_changes() AS n").get();
const plan = await f.plan();
expect(f.db.prepare("SELECT total_changes() AS n").get()).toEqual(changes);
expect(f.adapter.project).not.toHaveBeenCalled();
expect(plan).toMatchObject({ nodeId: f.node.id, model: { value: "provider/model", source: "member" }, effort: { value: "high", source: "profile" }, permissions: { rigRef: "builtin:yolo", launchPosture: "full_bypass" }, nonTargetEffects: "unchanged" });
const response = await f.request(false, plan.fingerprint);
expect(await response.json()).toMatchObject({ ok: true, status: "launched", plan });
expect(response.status).toBe(201);
const after = f.rigRepo.getRig(f.rigId)!;
expect(after.rig).toEqual(before.rig);
expect(after.edges).toEqual(before.edges);
expect(after.nodes.map(n => n.id)).toEqual(before.nodes.map(n => n.id));
expect(after.nodes.filter(n => n.id !== f.node.id)).toEqual(before.nodes.filter(n => n.id !== f.node.id));
expect(f.sessionRegistry.getSessionsForRig(f.rigId)).toHaveLength(1);
expect(f.adapter.launchHarness).toHaveBeenCalledTimes(1);
expect(vi.mocked(f.adapter.launchHarness).mock.calls[0]![0]).toMatchObject({ nodeId: f.node.id, model: "provider/model", effort: "high", launchPosture: "full_bypass" });
const delivered = vi.mocked(f.adapter.deliverStartup).mock.calls.flatMap(call => call[0]);
for (const path of ["agent/role.md", "rig.md", "pod.md"]) expect(delivered).toContainEqual(expect.objectContaining({ absolutePath: `${root}/${path}` }));
expect(f.tmux.killSession).not.toHaveBeenCalled();
// A lost reply never permits a second execution with the same plan.
expect((await f.request(false, plan.fingerprint)).status).toBe(409);
expect(f.adapter.launchHarness).toHaveBeenCalledTimes(1);
});
it.each(["present", "unknown"])("refuses %s occupancy without effects", async state => {
const f = await fixture();
vi.mocked(f.tmux.probeSession).mockResolvedValue(state === "present" ? { state: "present" } : { state: "transport_unavailable", cause: "fixture" });
const before = f.db.prepare("SELECT total_changes() AS n").get();
expect((await f.request()).status).toBe(409);
expect(f.db.prepare("SELECT total_changes() AS n").get()).toEqual(before);
expect(f.adapter.launchHarness).not.toHaveBeenCalled();
});
it("preserves non-target session, binding, held event, source and edge rows", async () => {
const f = await fixture();
const sibling = f.rigRepo.getRig(f.rigId)!.nodes.find(n => n.id !== f.node.id)!;
const old = f.sessionRegistry.registerSession(sibling.id, "dev-sibling@cold-start");
f.sessionRegistry.updateStatus(old.id, "exited");
f.sessionRegistry.updateBinding(sibling.id, { tmuxSession: "dev-sibling@cold-start", tmuxPane: "%77" });
f.eventBus.emit({ type: "node.held", rigId: f.rigId, nodeId: sibling.id, logicalId: sibling.logicalId, reason: "retained operator choice" });
const preserved = () => ({
nodes: f.db.prepare("SELECT * FROM nodes WHERE id != ? ORDER BY id").all(f.node.id),
sessions: f.db.prepare("SELECT * FROM sessions WHERE node_id != ? ORDER BY id").all(f.node.id),
bindings: f.db.prepare("SELECT * FROM bindings WHERE node_id != ? ORDER BY id").all(f.node.id),
held: f.db.prepare("SELECT * FROM events WHERE node_id = ? ORDER BY seq").all(sibling.id),
edges: f.db.prepare("SELECT * FROM edges ORDER BY id").all(),
files: JSON.stringify(f.files),
});
const before = preserved();
const plan = await f.plan();
expect((await f.request(false, plan.fingerprint)).status).toBe(201);
expect(preserved()).toEqual(before);
});
it("refuses a typing-protected seat without clearing its guard", async () => {
const f = await fixture();
await f.tmux.deliveryGuard!.set(f.node.id, true, "fixture", "person typing");
const before = f.db.prepare("SELECT total_changes() AS n").get();
const response = await f.request();
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ ok: false, code: "cold_start_refused" });
expect(f.db.prepare("SELECT total_changes() AS n").get()).toEqual(before);
expect(f.adapter.launchHarness).not.toHaveBeenCalled();
});
it.each(["session", "ready", "failed"])("refuses prior %s history without modifying it", async history => {
const f = await fixture();
if (history === "session") f.sessionRegistry.registerSession(f.node.id, "dev-worker@cold-start");
else if (history === "ready") f.eventBus.emit({ type: "node.startup_ready", rigId: f.rigId, nodeId: f.node.id });
else f.eventBus.emit({ type: "node.startup_failed", rigId: f.rigId, nodeId: f.node.id, error: "retained failure" });
const before = f.db.prepare("SELECT total_changes() AS n").get();
expect((await f.request()).status).toBe(409);
expect(f.db.prepare("SELECT total_changes() AS n").get()).toEqual(before);
expect(f.adapter.project).not.toHaveBeenCalled();
});
it.each(["root", "member", "policy", "model"])("requires convergence for a mismatched %s", async kind => {
const f = await fixture();
const source = kind === "member" ? f.yaml.replace("local:agent", "local:other") : kind === "policy" ? f.yaml.replace("name: cold-start", "name: cold-start\npermission_policy: builtin:yolo") : kind === "model" ? f.yaml.replace("runtime: pi", "runtime: pi\n model: other/model") : f.yaml;
const before = f.db.prepare("SELECT total_changes() AS n").get();
const response = await f.request(true, undefined, source, kind === "root" ? "/missing" : root);
expect(response.status).toBe(409);
expect(f.db.prepare("SELECT total_changes() AS n").get()).toEqual(before);
expect(f.adapter.project).not.toHaveBeenCalled();
});
it.each(["model", "source", "guidance"])("rejects stale %s plans, including changes during preflight", async kind => {
const f = await fixture();
const plan = await f.plan();
vi.mocked(f.tmux.probeSession).mockImplementation(async () => {
if (kind === "model") f.rigRepo.setNodeModel(f.node.id, "changed/model");
if (kind === "source") f.files[`${root}/agent/agent.yaml`] += "\n# changed";
if (kind === "guidance") f.files[`${root}/pod.md`] += " Changed.";
return { state: "absent" };
});
expect((await f.request(false, plan.fingerprint)).status).toBe(409);
expect(f.adapter.launchHarness).not.toHaveBeenCalled();
expect(f.adapter.project).not.toHaveBeenCalled();
});
it("serializes two requests for one cold node", async () => {
const f = await fixture();
const plan = await f.plan();
const responses = await Promise.all([f.request(false, plan.fingerprint), f.request(false, plan.fingerprint)]);
expect(responses.map(r => r.status).sort()).toEqual([201, 409]);
expect(f.adapter.launchHarness).toHaveBeenCalledTimes(1);
});
it("preserves an explicit seat set-model over the source default", async () => {
const f = await fixture({ model: "source/model" });
const lifecycle = new SeatLifecycleService({ ...f, tmuxAdapter: f.tmux });
expect((await lifecycle.setModel({ seatRef: "dev-worker@cold-start", model: "chosen/model", reason: "fixture selection" })).ok).toBe(true);
const plan = await f.plan();
expect(plan.model).toMatchObject({ value: "chosen/model", source: "seat set-model" });
expect((await f.request(false, plan.fingerprint)).status).toBe(201);
expect(f.rigRepo.getRig(f.rigId)!.nodes.find(n => n.id === f.node.id)!.model).toBe("chosen/model");
expect(vi.mocked(f.adapter.launchHarness).mock.calls[0]![0].model).toBe("chosen/model");
});
it.each([undefined, "inherited/model"])("reports requested or unobserved model honestly (%s)", async profileModel => {
const f = await fixture({ profileModel });
const plan = await f.plan();
expect(plan.model.value).toBe(profileModel ?? null);
expect(plan.model.source).toBe(profileModel ? "profile" : "runtime default");
if (!profileModel) expect(plan.model.observation).toContain("unobserved");
expect((await f.request(false, plan.fingerprint)).status).toBe(201);
expect(vi.mocked(f.adapter.launchHarness).mock.calls[0]![0].model).toBe(profileModel);
});
it("retains failed projection evidence and refuses an automatic fresh retry", async () => {
const f = await fixture();
const plan = await f.plan();
vi.mocked(f.adapter.project).mockResolvedValue({ projected: [], skipped: [], failed: [{ effectiveId: "role", error: "fixture projection failure" }] });
expect((await f.request(false, plan.fingerprint)).status).toBe(500);
expect(f.adapter.launchHarness).not.toHaveBeenCalled();
expect(f.db.prepare("SELECT 1 FROM events WHERE node_id = ? AND type = 'node.startup_failed'").get(f.node.id)).toBeTruthy();
expect((await f.request(false, plan.fingerprint)).status).toBe(409);
expect(f.adapter.project).toHaveBeenCalledTimes(1);
});
it.each(["context", "delivery"])("keeps normal %s failure evidence without another attempt", async phase => {
const f = await fixture();
const plan = await f.plan();
if (phase === "context") f.db.exec("CREATE TRIGGER fail_context BEFORE INSERT ON node_startup_context BEGIN SELECT RAISE(ABORT, 'fixture persistence failure'); END");
else vi.mocked(f.adapter.deliverStartup)
.mockResolvedValueOnce({ delivered: 0, failed: [] })
.mockResolvedValue({ delivered: 0, failed: [{ path: "role.md", error: "fixture delivery failure" }] });
const response = await f.request(false, plan.fingerprint);
expect((await response.json()).ok).toBe(false);
if (phase === "context") expect(f.adapter.launchHarness).not.toHaveBeenCalled();
else expect(f.adapter.launchHarness).toHaveBeenCalledTimes(1);
expect(f.db.prepare("SELECT 1 FROM events WHERE node_id = ? AND type = 'node.startup_failed'").get(f.node.id)).toBeTruthy();
expect((await f.request(false, plan.fingerprint)).status).toBe(409);
});
it("reports the existing native permission override and invalidates its changed plan", async () => {
const f = await fixture({ runtime: "codex", policy: "builtin:yolo" });
const store = new NativePermissionStore(f.db);
store.write(f.node.id, { runtime: "codex", mode: "floor" }, "fixture", "explicit selection");
const before = await f.plan();
expect(before.permissions).toMatchObject({ launchPosture: "floor", nativeOverride: { mode: "floor" } });
store.write(f.node.id, { runtime: "codex", mode: "full_bypass" }, "fixture", "changed selection");
expect((await f.request(false, before.fingerprint)).status).toBe(409);
const plan = await f.plan();
expect((await f.request(false, plan.fingerprint)).status).toBe(201);
expect(vi.mocked(f.adapter.launchHarness).mock.calls[0]![0].launchPosture).toBe(plan.permissions.launchPosture);
});
it("runs the compiled CLI against isolated HTTP/SQLite and prints its plan before native execution", async () => {
const f = await fixture();
const directory = mkdtempSync(join(tmpdir(), "cold-launch-cli-"));
const server = serve({ fetch: f.app.fetch, hostname: "127.0.0.1", port: 0 });
if (!server.listening) await new Promise<void>(resolve => server.once("listening", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("No fixture port");
const source = join(directory, "rig.yaml");
writeFileSync(source, f.yaml);
writeFileSync(join(directory, "daemon.json"), JSON.stringify({ pid: process.pid, port: address.port, db: ":memory:", startedAt: new Date().toISOString() }));
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENRIG_|RIGGED_|CODEX_HOME$)/.test(key)));
Object.assign(env, { HOME: directory, XDG_CONFIG_HOME: directory, OPENRIG_HOME: directory });
const args = [resolve(import.meta.dirname, "../../cli/dist/index.js"), "launch", f.rigId, f.node.logicalId, "--first-start-from", source, "--rig-root", root, "--json"];
try {
const preview = await promisify(execFile)(process.execPath, [...args, "--plan"], { env, timeout: 15000 });
expect(JSON.parse(preview.stdout)).toMatchObject({ ok: true, planOnly: true });
expect(f.adapter.launchHarness).not.toHaveBeenCalled();
const result = await promisify(execFile)(process.execPath, args, { env, timeout: 15000 });
const lines = result.stdout.trim().split("\n").map(line => JSON.parse(line));
expect(lines).toHaveLength(2);
expect(lines[0]).toMatchObject({ ok: true, planOnly: true });
expect(lines[1]).toMatchObject({ ok: true, planOnly: false, status: "launched", plan: lines[0].plan });
expect(f.adapter.launchHarness).toHaveBeenCalledTimes(1);
} finally {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
rmSync(directory, { recursive: true, force: true });
}
}, 35000);
});
@@ -389,7 +389,10 @@ describe("P17 — conflicts surface LOUDLY (never a silent overwrite)", () => {
const fsMod = require("node:fs") as typeof import("node:fs");
const src = fsMod.readFileSync(new URL("../src/domain/rigspec-instantiator.ts", import.meta.url), "utf8");
const assertWiring = (source: string) => {
const callBlock = /planProjection\(\{[\s\S]*?\n\s*\}\);/.exec(source)?.[0] ?? "";
// The read-only first-start planner also calls planProjection. This pin
// protects the effectful launch path's conflict detection and warnings.
const launchSource = source.slice(source.indexOf("private async launchExistingAgentMember("));
const callBlock = /planProjection\(\{[\s\S]*?\n\s*\}\);/.exec(launchSource)?.[0] ?? "";
// The resolver can branch by runtime. Target behavior is covered by
// codex-skill-projection.test.ts; this pin protects the wiring itself.
expect(callBlock, "planProjection call must inject resolveTargetPath").toMatch(