mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6bf761731 | ||
|
|
5e70c2026f |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user