fix(daemon): resolve launch profiles off the request thread (#2067)

## What a user gets

Applying a pod spec, launching a materialized member, or retrying a failed first start can still block the daemon while resolving profiles, even though preflight already uses a worker. These paths now await the existing profile resolver worker, so a slow skill scan or catalog read can leave the request thread available.

An apply reuses one lazy worker across members, without a scan cache: a later member still reads changes made by an earlier member's startup. Standalone launches and retries use a short-lived worker. Refs #1653, building on the preflight work in #1759 and #1873.

Worker rejections become member configuration failures. When every member fails, apply follows its existing service rollback and generation restoration. A materialized member returns a failed result; first-start retry refuses before changing retained seat/session state.

## How you verified it

Source: `1adc689a5797e23e4a4b0447c3ca9000e069b893`, based on `4e4cba36f9f454e927e9bd508f299bd09cc68886`.

- Three launch-path regressions failed against the original instantiator at the health/FIFO assertion. The test requires the FIFO to remain blocked when HTTP health responds, then checks the selected skill reaches projection. It also checks fresh reads and dependency order between members.
- Four worker-fault cases failed before the rejection handling: apply skipped service rollback, while materialized launch and retry threw. They now verify rollback, restoration of the replaced generation, and preservation of retained seat/session state. The complete worker file passes all 16 tests.
- Native Linux verification passed `npm run lint` and all 91 tests in `profile-resolution-worker.test.ts`, `spec-library-starters.test.ts`, `claude-activity-hook-delivery.test.ts`, and `rig-added-seat-culture.test.ts` (four files, no failures or skips). The tested staged tree matches this commit: `67bab58ce12b7019a300dadbe81b7dc5cf3f8d74`.
- The committed native Linux checkout passed `npm run build`, `npm run test:repo`, and the host-mode `up-relaunch-restores-a-two-seat-team.yaml` scenario (1/1). The scenario starts two stub members, stops them, and starts them again with the expected recorded membership and state.
- [Final PR CI](https://github.com/mvschwarz/openrig/actions/runs/40276062989) is still running. Its results will be added when the complete package suites finish.
- The staged portability report found no matching machine-specific values; the PR portability job also passed.

An initial full daemon run from a Windows-mounted WSL checkout was stopped after a file-mode mismatch and two timeout failures. The mode mismatch and preflight timeout reproduced with the base production code; the culture test passed individually on the base and initial async implementation. The first host-scenario attempt from that checkout also exceeded daemon startup readiness before reaching any scenario assertion; it passed after moving the same source to native Linux. The native Linux tests above use the existing assertions and deadlines. The interrupted run is not counted as a complete suite pass.

## Anything you were unsure about

The responsiveness regression uses the real instantiator and SQLite with a simulated runtime. The scenario DSL has no concurrent health assertion or filesystem gate, so the existing worker-test FIFO fixture exercises the blocking condition directly; the separate two-seat scenario checks CLI/daemon/tmux lifecycle plumbing. Native Claude Code/Codex behavior and other synchronous startup work are not established by these tests.

Worker creation and copying the resolution context add some overhead. The existing deleted-process-cwd fallback remains synchronous. No whole-daemon latency benchmark is claimed.

- [x] One concern per PR; no version bump; no `CHANGELOG.md` edit
- [x] Tests added or updated where the change is testable
- [x] I listed the checks I ran, their results, and any checks I could not run


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Improvements**
  * Health requests remain responsive while profiles are being resolved during member launches, materialized launches, and first-start retries.
  * Profile changes made during earlier members’ startup are picked up when resolving later members.
* **Bug Fixes**
  * Profile resolution failures are reported through the existing launch and retry failure handling, without launching affected members or leaving altered rig or session state.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Nash Aschenbath authored and GitHub committed 2026-10-25 23:47:00 -07:00
1 parent 3cb1d46e1f
commit 2a8d353380
4 files changed
+262 -18

No files matched your search

@@ -119,6 +119,37 @@ them imports Hono: **0**
- `startup-resolver.ts` — additive startup layering.
- `projection-planner.ts` — runtime resource projection planning.
### Profile resolution during launch
Preflight already resolves profiles in `profile-resolution-worker.ts`. Previously,
the pod-aware apply path, materialized-member launch and first-start retry called
the synchronous resolver again on the daemon's request thread. A fallback skill
scan or managed-catalog inspection could therefore delay every other request even
after preflight had finished ([#1653](https://github.com/mvschwarz/openrig/issues/1653)).
Those three launch paths now await the same resolver in a worker before projecting
resources or starting the member. Configuration precedence and launch order still
come from the existing resolver and instantiator.
An apply operation reuses one lazy worker across its members and terminates it on
success or failure. Unlike preflight, launch supplies no `scanScope`: each member
reads current files, including changes made by an earlier member's startup.
Standalone member launches and first-start retries use a short-lived worker for
their resolution. This adds worker startup and context-copy costs; it does not
reuse the preflight result or cache configuration between requests. The helper's
existing synchronous fallback for a deleted process working directory remains.
Worker errors become configuration-failure results. Apply can then follow the
existing all-members-failed cleanup, including service rollback and restoration
of replaced generations. Materialized launches report a failed member, and
first-start retries refuse before writing retained state.
`profile-resolution-worker.test.ts` holds a real fallback scan open with a FIFO,
requires an HTTP health response while that FIFO is still blocked, then checks
that the selected skill reaches projection. It covers all three launch paths and
a skill edit between members. These fixtures use SQLite and the real instantiator
with a simulated runtime; they do not establish native provider behavior or
responsiveness during other synchronous parts of startup.
## 4. Startup orchestration (the spec-startup contract)
`StartupOrchestrator.startNode` (`startup-orchestrator.ts:172`, a thin wrapper
@@ -5,10 +5,10 @@ import { resolveNodeConfig, type ResolutionContext, type ResolutionResult } from
// Keep the resolver's filesystem and catalog semantics in one place. In particular, a missing named skill can
// require a full library scan, and catalog inspection runs synchronous git commands. Neither belongs on the
// daemon's request thread during preflight (#1653). Reuse the imported module within a preflight. Its members share
// the raw catalog scan and the full skill-scan fallback, keyed by the context's scanScope and dropped when the
// preflight ends; the named-skill lookup and each member's own resolution read fresh. Never cache across
// preflights, or by a root directory's mtime.
// daemon's request thread during preflight or member launch (#1653). Reuse the imported module within an operation.
// Preflight members share raw catalog scans and full skill-scan fallbacks through the context's scanScope, dropped
// when preflight ends; named-skill lookups and each member's own resolution read fresh. Launch leaves scanScope
// unset so an earlier member's startup cannot make later reads stale. Never cache across operations or by root mtime.
const SOURCE = new URL(`data:text/javascript,${encodeURIComponent(`
import { parentPort, workerData } from "node:worker_threads";
let resolver;
@@ -22,7 +22,7 @@ parentPort.on("message", async ({ id, context }) => {
type Resolve = (context: ResolutionContext) => Promise<ResolutionResult>;
/** One lazy worker per preflight, never a persistent daemon-wide cache or one worker per member. */
/** One lazy worker per scoped operation, terminated on success or failure; never a daemon-wide cache. */
export async function withNodeConfigResolver<T>(run: (resolve: Resolve) => Promise<T>): Promise<T> {
let worker: Worker | undefined;
let failure: Error | undefined;
@@ -314,7 +314,8 @@ import { RigSpecCodec as PodRigSpecCodec } from "./rigspec-codec.js";
import { RigSpecSchema as PodRigSpecSchema, VALID_EDGE_KINDS } from "./rigspec-schema.js";
import { rigPreflight, preflightValidatedSpec } from "./rigspec-preflight.js";
import { resolveAgentRef, type AgentResolverFsOps } from "./agent-resolver.js";
import { resolveAdvisorModel, resolveNodeConfig, selectedPluginPaths } from "./profile-resolver.js";
import { resolveAdvisorModel, selectedPluginPaths, type ResolutionResult } from "./profile-resolver.js";
import { resolveNodeConfigAsync, withNodeConfigResolver } from "./profile-resolution-worker.js";
import { resolveStartup } from "./startup-resolver.js";
import { isCommunityHarnessRuntime } from "./built-in-runtimes.js";
import { planProjection, claudeConflictTargetPath, projectionConflictWarnings, filterProtectedProjections, type ProjectionPlan } from "./projection-planner.js";
@@ -494,6 +495,11 @@ class GenerationChanged extends Error {
}
}
/** Worker failures must reach the existing member-failure cleanup and retry refusal paths. */
function failedLaunchResolution(error: unknown): ResolutionResult {
return { ok: false, errors: [`Profile resolution failed: ${String(error)}`] };
}
export class PodRigInstantiator {
readonly db: Database.Database;
private deps: PodInstantiatorDeps;
@@ -1354,9 +1360,9 @@ export class PodRigInstantiator {
const resolved = resolveAgentRef(member.agentRef, rigRoot, this.deps.fsOps);
if (!resolved.ok) return refuse(resolved.code === "validation_failed" ? resolved.errors.join("; ") : resolved.error);
if (!node.resolvedSpecHash || resolved.resolved.hash !== node.resolvedSpecHash) return refuse("Agent source hash differs from the failed first start.");
const config = resolveNodeConfig({ baseSpec: resolved.resolved, importedSpecs: resolved.imports, collisions: resolved.collisions,
const config = await resolveNodeConfigAsync({ baseSpec: resolved.resolved, importedSpecs: resolved.imports, collisions: resolved.collisions,
profileName: member.profile, specRoot: rigRoot, member, pod, rig: this.withRigStartupLayers(rigSpec, this.rigStartupLayers(rigSpec, rigRoot, rigId)),
skillsRoot: this.resolveSkillsRoot(), ...this.systemWorldResolutionContext() });
skillsRoot: this.resolveSkillsRoot(), ...this.systemWorldResolutionContext() }).catch(failedLaunchResolution);
if (!config.ok) return refuse(config.errors.join("; "));
// A seat whose first start failed before Advisor support recorded no Advisor, though its unchanged agent spec (the
// hash above) may name one. Its retry keeps the launch it recorded: no Advisor, stored and launched alike. Any
@@ -1611,8 +1617,10 @@ export class PodRigInstantiator {
rollbackServices = hookResult.rollback;
}
// Phase 2: Process members in launch order
for (const { pod, member, podId, qualifiedId } of memberEntries) {
// Phase 2: Process members in launch order. Reuse one worker, but no scanScope:
// an earlier member's startup can change the skills a later member resolves.
await withNodeConfigResolver(async resolveConfig => {
for (const { pod, member, podId, qualifiedId } of memberEntries) {
// Terminal fast-path: skip agent resolution and profile resolution
if (member.agentRef === "builtin:terminal") {
@@ -1634,7 +1642,7 @@ export class PodRigInstantiator {
}
// Resolve node config (profile + precedence)
const configResult = resolveNodeConfig({
const configResult = await resolveConfig({
baseSpec: resolveResult.resolved,
importedSpecs: resolveResult.imports,
collisions: resolveResult.collisions,
@@ -1646,7 +1654,7 @@ export class PodRigInstantiator {
rig: rigSpec,
skillsRoot: this.resolveSkillsRoot(),
...this.systemWorldResolutionContext(),
});
}).catch(failedLaunchResolution);
if (!configResult.ok) {
nodeResults.push({ logicalId: qualifiedId, status: "failed", error: configResult.errors.join("; ") });
continue;
@@ -1739,6 +1747,7 @@ export class PodRigInstantiator {
sessionName: launched.sessionName,
});
}
});
// Create pod-local edges (after all members created)
for (const pod of rigSpec.pods) {
@@ -2331,7 +2340,7 @@ export class PodRigInstantiator {
* projection targets instead of protecting them. Absent = protect (safe default). */
force?: boolean;
resolveResult?: ReturnType<typeof resolveAgentRef> extends infer T ? T : never;
configResult?: ReturnType<typeof resolveNodeConfig> extends infer T ? T : never;
configResult?: ResolutionResult;
/** The first-start retry's member carries only retained fields, not the launch additions: keep the ones the first
* start recorded rather than read an empty request as "none". */
keepRecordedAdditions?: boolean;
@@ -2349,7 +2358,7 @@ export class PodRigInstantiator {
}
const rigLayers = this.rigStartupLayers(input.rigSpec, input.rigRoot, input.rigId);
const configResult = input.configResult ?? resolveNodeConfig({
const configResult = input.configResult ?? await resolveNodeConfigAsync({
baseSpec: resolveResult.resolved,
importedSpecs: resolveResult.imports,
collisions: resolveResult.collisions,
@@ -2361,7 +2370,7 @@ export class PodRigInstantiator {
rig: this.withRigStartupLayers(input.rigSpec, rigLayers),
skillsRoot: this.resolveSkillsRoot(),
...this.systemWorldResolutionContext(),
});
}).catch(failedLaunchResolution);
if (!configResult.ok) {
return { status: "failed", error: configResult.errors.join("; ") };
}
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createServer } from "node:http";
@@ -7,10 +7,15 @@ import { execFileSync, fork, spawnSync } from "node:child_process";
import { once } from "node:events";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { Worker } from "node:worker_threads";
import { resolveNodeConfig, type ResolutionContext } from "../src/domain/profile-resolver.js";
import { resolveNodeConfigAsync, withNodeConfigResolver } from "../src/domain/profile-resolution-worker.js";
import { preflightValidatedSpec } from "../src/domain/rigspec-preflight.js";
import { releaseScanCache } from "../src/domain/skill-discovery.js";
import { createFullTestDb, createTestApp, mockTmuxAdapter } from "./helpers/test-app.js";
import { RigSpecCodec } from "../src/domain/rigspec-codec.js";
import { SeatLifecycleService } from "../src/domain/seat-lifecycle-service.js";
import type { RuntimeAdapter } from "../src/domain/runtime-adapter.js";
const roots: string[] = [];
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
@@ -81,7 +86,7 @@ function startScanGate(root: string, deadlineMs = 5000) {
`);
const writer = fork(helper, [gate, String(deadlineMs)], { execArgv: [], stdio: ["ignore", "ignore", "pipe", "ipc"] });
const writerExited = once(writer, "exit");
return { writer, writerExited };
return { writer, writerExited, path: gate };
}
const MALFORMED = {
@@ -183,6 +188,205 @@ describe("profile resolution outside the daemon request thread", () => {
}
});
it.each(["instantiate", "materialized", "retry"])("serves health while %s resolves a member after preflight", async mode => {
const ctx = fixture();
const root = ctx.homedir!;
skill(ctx, "different-folder", "wanted");
mkdirSync(join(root, "agent"));
writeFileSync(join(root, "agent", "agent.yaml"), "name: worker\nversion: '1.0.0'\nresources:\n skills: []\nprofiles:\n default:\n uses:\n skills: [wanted]\n");
if (mode === "instantiate") {
ctx.pod.members.push({ ...ctx.member, id: "later" });
ctx.pod.edges.push({ kind: "delegates_to", from: "worker", to: "later" });
}
const yaml = RigSpecCodec.serialize(ctx.rig);
const db = createFullTestDb();
let projectionBlocked = mode === "retry";
const adapter: RuntimeAdapter = {
runtime: "codex",
listInstalled: vi.fn(async () => []),
project: vi.fn(async () => ({ projected: [], skipped: [], failed: projectionBlocked ? [{ effectiveId: "wanted", error: "fixture projection failure" }] : [] })),
deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
checkReady: vi.fn(async () => ({ ready: true })),
launchHarness: vi.fn(async () => {
if (mode === "instantiate") skill(ctx, "different-folder", "renamed");
return { ok: true };
}),
};
const tmux = mockTmuxAdapter();
const f = createTestApp(db, { tmux, adapters: { codex: adapter },
podInstantiatorFsOps: { exists: existsSync, readFile: path => readFileSync(path, "utf8") } });
const server = createServer((_req, res) => res.end("healthy"));
let gate: ReturnType<typeof startScanGate> | undefined;
let scanStarted: Promise<unknown> | undefined;
let armed!: () => void;
const gateArmed = new Promise<void>(resolve => { armed = resolve; });
const armGate = () => {
gate = startScanGate(root);
scanStarted = Promise.race([
once(gate.writer, "message"),
gate.writerExited.then(([code]) => { throw new Error(`Scan helper exited before opening the FIFO (code ${code})`); }),
]);
armed();
};
let launch: Promise<{ ok: boolean }> | undefined;
try {
// Preparing the rig must finish before the gate exists: this regression is in
// apply/first-start retry, not the preflight worker already covered above.
let rigId = "";
let nodeId = "";
if (mode !== "instantiate") {
const materialized = await f.podInstantiator.materialize(yaml, root);
expect(materialized.ok, JSON.stringify(materialized)).toBe(true);
if (!materialized.ok) throw new Error("Fixture did not materialize");
rigId = materialized.result.rigId;
nodeId = f.rigRepo.getRig(rigId)!.nodes[0]!.id;
}
if (mode === "retry") {
const failed = await f.podInstantiator.launchMaterialized(yaml, root, rigId);
expect(failed).toMatchObject({ ok: true, result: { nodes: [{ status: "failed" }] } });
expect(adapter.launchHarness).not.toHaveBeenCalled();
tmux.probeSession = vi.fn(async () => ({ state: "absent" as const }));
const lifecycle = new SeatLifecycleService({ ...f, db, tmuxAdapter: tmux });
expect((await lifecycle.cleanSeat({ seatRef: "dev-worker@test", reason: "Failed projection; fixture shell has exited" })).ok).toBe(true);
projectionBlocked = false;
}
server.listen(0, "127.0.0.1");
await once(server, "listening");
const { port } = server.address() as { port: number };
let completed = false;
launch = (async () => {
if (mode === "instantiate") {
return f.podInstantiator.instantiate(yaml, root, { prelaunchHook: async () => { armGate(); return { ok: true }; } });
}
armGate();
if (mode === "materialized") return f.podInstantiator.launchMaterialized(yaml, root, rigId);
return f.podInstantiator.retryFirstStart(rigId, nodeId, {
id: "worker", agent_ref: "local:agent", profile: "default", runtime: "codex", cwd: root,
}, root);
})().then(result => { completed = true; return result; });
const prematureCompletion = launch.then(() => { throw new Error("Launch completed before the scan was released"); });
await Promise.race([gateArmed, prematureCompletion]);
await Promise.race([scanStarted!, prematureCompletion]);
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
expect(await response.text()).toBe("healthy");
// A launch can still be pending after the helper's emergency timeout releases
// a synchronous scan. Require health to answer while the actual FIFO is held.
expect(statSync(gate!.path).isFIFO()).toBe(true);
expect(completed).toBe(false);
gate!.writer.send("release", () => {});
const result = await launch;
expect(result.ok, JSON.stringify(result)).toBe(true);
if (mode === "instantiate") expect(result).toMatchObject({ result: { nodes: [
{ logicalId: "dev.worker", status: "launched" },
{ logicalId: "dev.later", status: "failed", error: expect.stringContaining("wanted") },
] } });
expect(adapter.launchHarness).toHaveBeenCalledTimes(1);
expect(vi.mocked(adapter.project).mock.calls.at(-1)![0].entries).toEqual(expect.arrayContaining([
expect.objectContaining({ category: "skill", effectiveId: "wanted" }),
]));
} finally {
if (gate?.writer.connected) gate.writer.send("release", () => {});
await launch?.catch(() => undefined);
const exitCode = gate ? (await gate.writerExited)[0] : undefined;
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
db.close();
if (gate) expect(exitCode).toBe(0);
}
});
it.each([
["instantiate", "error"], ["instantiate", "exit"], ["materialized", "error"], ["retry", "error"],
] as const)("returns a launch failure when the worker faults during %s (%s)", async (mode, fault) => {
const ctx = fixture();
const root = ctx.homedir!;
mkdirSync(join(root, "agent"));
writeFileSync(join(root, "agent", "agent.yaml"), "name: worker\nversion: '1.0.0'\nresources:\n skills: []\nprofiles:\n default:\n uses:\n skills: []\n");
if (mode === "instantiate") ctx.pod.members.push({ ...ctx.member, id: "later" });
const yaml = RigSpecCodec.serialize(ctx.rig);
const db = createFullTestDb();
let projectionBlocked = mode === "retry";
const adapter: RuntimeAdapter = {
runtime: "codex",
listInstalled: vi.fn(async () => []),
project: vi.fn(async () => ({ projected: [], skipped: [], failed: projectionBlocked ? [{ effectiveId: "fixture", error: "fixture projection failure" }] : [] })),
deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
checkReady: vi.fn(async () => ({ ready: true })),
launchHarness: vi.fn(async () => ({ ok: true })),
};
const tmux = mockTmuxAdapter();
const f = createTestApp(db, { tmux, adapters: { codex: adapter },
podInstantiatorFsOps: { exists: existsSync, readFile: path => readFileSync(path, "utf8") } });
let armed = false;
let triggered = false;
const postMessage = Worker.prototype.postMessage;
const send = vi.spyOn(Worker.prototype, "postMessage").mockImplementation(function (this: Worker, ...args: Parameters<Worker["postMessage"]>) {
if (armed) {
armed = false;
triggered = true;
if (fault === "exit") { void this.terminate(); return; }
// Make the real worker throw in resolution, after preflight has succeeded.
const message = args[0];
args[0] = { ...message, context: { ...message.context, baseSpec: { ...message.context.baseSpec, spec: null } } };
}
postMessage.call(this, ...args);
});
try {
if (mode === "instantiate") {
const previous = f.rigRepo.createRig("test");
const rollback = vi.fn(async () => {});
tmux.probeSession = vi.fn(async () => ({ state: "absent" as const }));
const result = await f.podInstantiator.instantiate(yaml, root, { prelaunchHook: async () => {
expect(db.prepare("SELECT archived_at FROM rigs WHERE id = ?").get(previous.id))
.toEqual({ archived_at: expect.any(String) });
armed = true;
return { ok: true, rollback };
} }).catch(error => ({ ok: false as const, code: "unexpected_exception", message: String(error) }));
expect(rollback).toHaveBeenCalledTimes(1);
expect(db.prepare("SELECT id, archived_at FROM rigs").all()).toEqual([{ id: previous.id, archived_at: null }]);
expect(db.prepare("SELECT COUNT(*) AS count FROM pods").get()).toEqual({ count: 0 });
expect(result).toMatchObject({ ok: false, code: "instantiate_error", message: expect.stringContaining("Profile resolution failed:") });
if (result.ok) throw new Error("Worker failure unexpectedly launched a rig");
expect(result.message).toContain("dev.worker:");
expect(result.message).toContain("dev.later:");
expect(result.message).toContain(fault === "exit" ? "Profile resolution worker exited" : "profiles");
} else {
const materialized = await f.podInstantiator.materialize(yaml, root);
expect(materialized.ok).toBe(true);
if (!materialized.ok) throw new Error("Fixture did not materialize");
const rigId = materialized.result.rigId;
const nodeId = f.rigRepo.getRig(rigId)!.nodes[0]!.id;
if (mode === "retry") {
expect(await f.podInstantiator.launchMaterialized(yaml, root, rigId))
.toMatchObject({ ok: true, result: { nodes: [{ status: "failed" }] } });
tmux.probeSession = vi.fn(async () => ({ state: "absent" as const }));
const lifecycle = new SeatLifecycleService({ ...f, db, tmuxAdapter: tmux });
expect((await lifecycle.cleanSeat({ seatRef: "dev-worker@test", reason: "Failed projection; fixture shell has exited" })).ok).toBe(true);
projectionBlocked = false;
}
const before = f.rigRepo.getRig(rigId);
const sessions = f.sessionRegistry.getSessionsForRig(rigId);
armed = true;
if (mode === "retry") {
expect(await f.podInstantiator.retryFirstStart(rigId, nodeId, {
id: "worker", agent_ref: "local:agent", profile: "default", runtime: "codex", cwd: root,
}, root)).toMatchObject({ ok: false, code: "first_start_retry_refused", message: expect.stringContaining("Profile resolution failed:") });
} else {
expect(await f.podInstantiator.launchMaterialized(yaml, root, rigId))
.toMatchObject({ ok: true, result: { nodes: [{ status: "failed", error: expect.stringContaining("Profile resolution failed:") }] } });
}
expect(f.rigRepo.getRig(rigId)).toEqual(before);
expect(f.sessionRegistry.getSessionsForRig(rigId)).toEqual(sessions);
expect(f.sessionRegistry.getBindingForNode(nodeId)).toBeNull();
}
expect(triggered).toBe(true);
expect(adapter.launchHarness).not.toHaveBeenCalled();
} finally {
send.mockRestore();
db.close();
}
});
it.each(["deleted", "deleted-cached"])("preserves fresh reads across repeated resolutions with a %s cwd", mode => {
const ctx = fixture();
skill(ctx, "different-folder", "wanted");