fix(setup): materialize the pinned package-runner cache before the first client start (#7032)

* fix(setup): materialize the pinned package-runner cache before the first client start

Package runners key their package cache by the requested spec, so the
entry installed for the @latest invocation that ran setup cannot serve
the pinned rea-agents@<version> registration. The first client start
installed that entry inside the client's MCP startup deadline and timed
out (three consecutive 30 s timeouts observed in one client log; a
controlled cold install with every tarball already cached took 22 s).

Setup now materializes the pinned entry once after applying
registrations: best-effort, bounded by a timeout, launched from a
neutral working directory, and non-fatal on failure. The setup result
carries the prewarm summary, interactive output prints a warning when
the step fails, and direct launcher registrations keep skipping it.

Fixes #7027

* fix(setup): gate prewarm to applied registrations and supervise the runner tree

Address review findings on the prewarm step:

- Gate the prewarm on at least one applied client registration
  (configured_<client>), so an empty plan or a skill-only run never
  installs into the package-runner cache; the step now only follows
  registrations the user approved in this transaction.
- Replace execFileOutput with a supervised spawn: on timeout the whole
  runner tree stops through cleanupWindowsProcessTree on Windows and a
  process-group kill on POSIX, because killing only the direct child
  leaves npm holding the inherited output pipes past the deadline.
- Emit started and completed progress events around the prewarm so an
  interactive cold install no longer looks like a frozen setup.

* fix(setup): forward terminal interrupts to the detached prewarm runner

A detached POSIX package-runner group no longer receives the terminal's
Ctrl-C, so setup could exit while the runner kept installing in the
background. The supervised launch now registers SIGINT and SIGTERM
forwarding for the duration of the prewarm: the runner tree is stopped
through the same family cleanup as the timeout, and the default
termination is restored afterwards. An interrupted launch reports a
failure reason instead of a hang.
This commit is contained in:
Xinjian Wang
2026-10-25 03:32:58 +08:00
committed by GitHub
parent 0354759370
commit 6a96f4fa4a
8 changed files with 680 additions and 1 deletions
+7
View File
@@ -1462,6 +1462,13 @@ setup runs through `bunx`, and pins it to the exact version that performed
setup. Run current setup to refresh an older registration, then restart the
client.
Package runners key their package cache by the requested spec, so the entry
installed for the `@latest` invocation that ran setup cannot serve the pinned
registration. Setup therefore materializes the pinned entry once, best-effort
with a bounded timeout, before finishing. When that step cannot run, for
example offline, the first client start performs the one-time install inside
the client's MCP startup deadline and may need one more restart.
## Package runners and global installation
A package-runner invocation can use a REA version installed in the current project. To
+30
View File
@@ -35,6 +35,7 @@ import { setupCleanupError, setupCleanupReason } from "./SetupCleanupOwner.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import type { SetupSkillInstallError } from "./SetupSkill.js";
import type { SetupHopperInstallFailure } from "./SetupInstallFailure.js";
import type { SetupRegistrationPrewarm } from "./SetupRegistrationPrewarm.js";
/** Discover, approve, and apply setup actions idempotently. */
export const runSetup = async (
@@ -378,6 +379,34 @@ const runSetupWorkflow = async (
return withHopperFailure(await failSkill(skillFailure));
}
}
// The prewarm only follows client registrations the user approved; an empty
// plan or a skill-only run must not install anything.
const prewarmEligible = appliedActions.some((id) =>
id.startsWith("configured_"),
);
let prewarm: SetupRegistrationPrewarm | undefined;
if (prewarmEligible && host.prewarmRegistrationRunner !== undefined) {
emitProgress(options, {
actionId: "prewarm_registration_runner",
label: "REA package-runner cache",
state: "started",
detail: "Preparing the pinned package entry",
});
prewarm = await host.prewarmRegistrationRunner();
if (prewarm.status === "failed")
emitProgress(options, {
actionId: "prewarm_registration_runner",
label: "REA package-runner cache",
state: "warning",
detail: prewarm.reason,
});
else if (prewarm.status === "warmed")
emitProgress(options, {
actionId: "prewarm_registration_runner",
label: "REA package-runner cache",
state: "completed",
});
}
const doctor = summarizeDoctor(await host.doctor(readinessScope));
const remediation = combineRemediation(
hopperFailure?.remediation ??
@@ -400,6 +429,7 @@ const runSetupWorkflow = async (
clientStates,
...(remediation === undefined ? {} : { remediation }),
...(hopperFailure === undefined ? {} : { code: hopperFailure.code }),
...(prewarm === undefined ? {} : { prewarm }),
};
};
+12 -1
View File
@@ -15,6 +15,10 @@ import {
packageRunnerRegistrationCommand,
type PackageRunner,
} from "./ClientRegistrationIdentity.js";
import {
materializeRegistrationRunnerCache,
type SetupRegistrationPrewarm,
} from "./SetupRegistrationPrewarm.js";
import {
SUPPORTED_NODE_VERSION_PROSE,
supportsNodeVersion,
@@ -173,6 +177,12 @@ export const systemSetupHost = (
await linuxHopperHost.close?.();
},
};
const registration = setupRegistration(platform, homeDirectory, environment);
const prewarmRegistrationRunner = (): Promise<SetupRegistrationPrewarm> =>
materializeRegistrationRunnerCache(
registration.registrationCommand,
environment,
);
const cleanup = new SetupCleanupOwner([
macCleanup,
{
@@ -194,7 +204,8 @@ export const systemSetupHost = (
: {}),
skillDestinations: (clientIds) =>
skillDestinations(homeDirectory, clientIds, environment, platform),
...setupRegistration(platform, homeDirectory, environment),
...registration,
prewarmRegistrationRunner,
nodeVersion: process.versions.node,
macosVersion: () => doctorHost.macosVersion(),
linuxDistribution,
@@ -0,0 +1,181 @@
import { describe, expect, it } from "vitest";
import { PRODUCT_IDENTITY } from "../identity.js";
import {
launchRegistrationRunnerCommand,
materializeRegistrationRunnerCache,
registrationPrewarmCommand,
type RegistrationRunnerLaunch,
type RegistrationRunnerLauncher,
} from "./SetupRegistrationPrewarm.js";
const specifier = PRODUCT_IDENTITY.registrationPackageSpecifier;
const launch = (
overrides: Partial<RegistrationRunnerLaunch> = {},
): RegistrationRunnerLaunch => ({
exitCode: 0,
signal: null,
stdout: "",
stderr: "",
timedOut: false,
...overrides,
});
describe("registrationPrewarmCommand", () => {
it("replaces the server subcommand of an npx registration", () => {
expect(registrationPrewarmCommand(["npx", "-y", specifier, "mcp"])).toEqual(
["npx", "-y", specifier, "--version"],
);
});
it("keeps the Windows command wrapper", () => {
expect(
registrationPrewarmCommand([
"cmd.exe",
"/d",
"/c",
"npx",
"-y",
specifier,
"mcp",
]),
).toEqual(["cmd.exe", "/d", "/c", "npx", "-y", specifier, "--version"]);
});
it("replaces the server subcommand of a bunx registration", () => {
expect(registrationPrewarmCommand(["bunx", specifier, "mcp"])).toEqual([
"bunx",
specifier,
"--version",
]);
});
it("keeps direct launchers and non-server commands undefined", () => {
expect(registrationPrewarmCommand(["/opt/rea/rea", "mcp"])).toBeUndefined();
expect(
registrationPrewarmCommand(["npx", "-y", specifier, "doctor"]),
).toBeUndefined();
expect(registrationPrewarmCommand([])).toBeUndefined();
});
});
describe("materializeRegistrationRunnerCache", () => {
it("skips direct launchers without launching", async () => {
let launched = 0;
const launcher: RegistrationRunnerLauncher = async () => {
launched += 1;
return launch();
};
const result = await materializeRegistrationRunnerCache(
["/opt/rea/rea", "mcp"],
{},
launcher,
);
expect(result).toEqual({ status: "skipped", reason: "direct_launcher" });
expect(launched).toBe(0);
});
it("materializes the pinned spec once and reports warmed", async () => {
const commands: (readonly string[])[] = [];
const launcher: RegistrationRunnerLauncher = async (
command,
arguments_,
options,
) => {
commands.push([command, ...arguments_]);
expect(options.timeoutMs).toBeGreaterThan(0);
expect(options.env.REA_PREWARM_PROBE).toBe("1");
expect(options.cwd.length).toBeGreaterThan(0);
return launch({ stdout: "6.7.0\n" });
};
const result = await materializeRegistrationRunnerCache(
["npx", "-y", specifier, "mcp"],
{ REA_PREWARM_PROBE: "1" },
launcher,
);
expect(result).toEqual({ status: "warmed" });
expect(commands).toEqual([["npx", "-y", specifier, "--version"]]);
});
it("reports a bounded failure reason for a non-zero exit", async () => {
const result = await materializeRegistrationRunnerCache(
["npx", "-y", specifier, "mcp"],
{},
async () => launch({ exitCode: 1, stderr: "spawn EPERM\n" }),
);
expect(result.status).toBe("failed");
if (result.status !== "failed") return;
expect(result.reason).toContain("exit 1");
expect(result.reason).toContain("spawn EPERM");
expect(result.reason).not.toMatch(/[\n\r]/u);
expect(result.reason).toContain("first client start");
});
it("reports a timeout failure without command output facts", async () => {
const result = await materializeRegistrationRunnerCache(
["npx", "-y", specifier, "mcp"],
{},
async () => launch({ exitCode: null, timedOut: true }),
);
expect(result.status).toBe("failed");
if (result.status !== "failed") return;
expect(result.reason).toContain("timed out");
});
it("reports a spawn failure", async () => {
const result = await materializeRegistrationRunnerCache(
["npx", "-y", specifier, "mcp"],
{},
async () => launch({ exitCode: null, spawnError: "spawn npx ENOENT" }),
);
expect(result.status).toBe("failed");
if (result.status !== "failed") return;
expect(result.reason).toContain("spawn npx ENOENT");
});
it("reports a forwarded interrupt as a failure", async () => {
const result = await materializeRegistrationRunnerCache(
["npx", "-y", specifier, "mcp"],
{},
async () => launch({ exitCode: null, interruptedSignal: "SIGINT" }),
);
expect(result.status).toBe("failed");
if (result.status !== "failed") return;
expect(result.reason).toContain("stopped by SIGINT");
});
});
describe("launchRegistrationRunnerCommand", () => {
it("captures a successful command exit", async () => {
const result = await launchRegistrationRunnerCommand(
process.execPath,
["--version"],
{
env: process.env,
cwd: process.cwd(),
timeoutMs: 30_000,
platform: process.platform,
},
);
expect(result.exitCode).toBe(0);
expect(result.timedOut).toBe(false);
expect(result.stdout.trim().length).toBeGreaterThan(0);
});
it("stops the runner tree at the deadline and reports the timeout", async () => {
const startedAt = Date.now();
const result = await launchRegistrationRunnerCommand(
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{
env: process.env,
cwd: process.cwd(),
timeoutMs: 300,
platform: process.platform,
},
);
expect(result.timedOut).toBe(true);
expect(Date.now() - startedAt).toBeLessThan(10_000);
});
});
+207
View File
@@ -0,0 +1,207 @@
import { spawn, type ChildProcess } from "node:child_process";
import { tmpdir } from "node:os";
import { cleanupWindowsProcessTree } from "../process/ProcessOwnership.js";
import { inspectClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
/**
* Best-effort outcome of materializing the pinned package-runner cache entry.
* A failure never blocks setup; the first client start then performs the
* one-time install inside its own startup deadline.
*/
export type SetupRegistrationPrewarm =
| { readonly status: "skipped"; readonly reason: "direct_launcher" }
| { readonly status: "warmed" }
| { readonly status: "failed"; readonly reason: string };
/** One cold package install at setup time; client startup deadlines stay untouched. */
const PREWARM_TIMEOUT_MS = 180_000;
const PREWARM_CAPTURE_LIMIT = 64 * 1024;
const PREWARM_REASON_LIMIT = 300;
/** Facts of one supervised package-runner launch. */
export interface RegistrationRunnerLaunch {
readonly exitCode: string | number | null;
readonly signal: string | null;
readonly stdout: string;
readonly stderr: string;
readonly timedOut: boolean;
readonly spawnError?: string;
/** Terminal interrupt that was forwarded to the runner tree, if any. */
readonly interruptedSignal?: string;
}
/** Launch shape shared with the supervised spawn so tests can observe it. */
export type RegistrationRunnerLauncher = (
command: string,
arguments_: readonly string[],
options: {
readonly env: Readonly<NodeJS.ProcessEnv>;
/** Neutral directory; inside a REA source checkout the package runner
* would resolve the local project instead of the published entry. */
readonly cwd: string;
readonly timeoutMs: number;
readonly platform: NodeJS.Platform;
},
) => Promise<RegistrationRunnerLaunch>;
/**
* Resolve the cache-materializing variant of a registration command. The
* package-runner cache entry is keyed by the requested spec, so a terminating
* subcommand with the same spec warms the entry the client will launch.
* Direct launchers keep no package-runner cache; they return undefined.
*/
export const registrationPrewarmCommand = (
command: readonly string[],
): readonly string[] | undefined => {
if (
command.length === 0 ||
command[command.length - 1] !== "mcp" ||
inspectClientRegistrationCommand(command, undefined).package === undefined
)
return undefined;
return [...command.slice(0, -1), "--version"];
};
/** Materialize the pinned package-runner cache entry once, best-effort. */
export const materializeRegistrationRunnerCache = async (
command: readonly string[],
environment: Readonly<NodeJS.ProcessEnv>,
launchCommand: RegistrationRunnerLauncher = launchRegistrationRunnerCommand,
): Promise<SetupRegistrationPrewarm> => {
const prewarm = registrationPrewarmCommand(command);
if (prewarm === undefined)
return { status: "skipped", reason: "direct_launcher" };
const launch = await launchCommand(prewarm[0] ?? "", prewarm.slice(1), {
env: environment,
cwd: tmpdir(),
timeoutMs: PREWARM_TIMEOUT_MS,
platform: process.platform,
});
return launch.timedOut ||
launch.spawnError !== undefined ||
launch.interruptedSignal !== undefined ||
launch.exitCode !== 0
? { status: "failed", reason: prewarmFailureReason(launch) }
: { status: "warmed" };
};
/**
* Stop the whole runner tree: killing only the direct child leaves npm, which
* inherited the output pipes, holding them open past the deadline.
*/
const stopRunnerTree = (
child: ChildProcess,
platform: NodeJS.Platform,
): Promise<void> => {
const pid = child.pid;
if (pid === undefined) return Promise.resolve();
if (platform === "win32")
return cleanupWindowsProcessTree(pid).then(() => undefined);
return new Promise((resolve) => {
try {
process.kill(-pid, "SIGKILL");
} catch {
// The process group may already be gone.
}
resolve();
});
};
export const launchRegistrationRunnerCommand: RegistrationRunnerLauncher = (
command,
arguments_,
options,
) =>
new Promise((resolve) => {
const child = spawn(command, [...arguments_], {
env: options.env,
cwd: options.cwd,
stdio: ["ignore", "pipe", "pipe"],
// POSIX runs the runner in its own group so one signal stops the tree;
// an interrupt must therefore be forwarded below, because the detached
// group no longer receives the terminal's signal.
...(options.platform === "win32" ? {} : { detached: true }),
});
const stdout = boundedStream(child.stdout);
const stderr = boundedStream(child.stderr);
let timedOut = false;
let spawnError: string | undefined;
let interruptedSignal: string | undefined;
const timer = setTimeout(() => {
timedOut = true;
void stopRunnerTree(child, options.platform).finally(() => {
child.kill("SIGKILL");
});
}, options.timeoutMs);
const detachSignalForwarding = () => {
for (const [signal, handler] of signalHandlers)
process.removeListener(signal, handler);
};
const signalHandlers = new Map<NodeJS.Signals, () => void>(
FORWARD_SIGNALS.map((signal) => [
signal,
() => {
if (interruptedSignal !== undefined) return;
interruptedSignal = signal;
clearTimeout(timer);
void stopRunnerTree(child, options.platform).finally(() => {
child.kill("SIGKILL");
detachSignalForwarding();
// Restore default termination for the interrupt that arrived.
process.kill(process.pid, signal);
});
},
]),
);
for (const [signal, handler] of signalHandlers) process.on(signal, handler);
child.on("error", (error: NodeJS.ErrnoException) => {
spawnError = error.message;
});
child.on("close", (code, signal) => {
clearTimeout(timer);
detachSignalForwarding();
resolve({
exitCode: code ?? signal,
signal: signal ?? null,
stdout: stdout(),
stderr: stderr(),
timedOut,
...(spawnError === undefined ? {} : { spawnError }),
...(interruptedSignal === undefined ? {} : { interruptedSignal }),
});
});
});
/** Terminal interrupts that must also stop a detached package-runner tree. */
const FORWARD_SIGNALS: readonly NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
const boundedStream = (
stream: NodeJS.ReadableStream | null,
): (() => string) => {
let captured = "";
stream?.on("data", (chunk: Buffer) => {
if (captured.length < PREWARM_CAPTURE_LIMIT)
captured += chunk.toString("utf8").slice(0, PREWARM_CAPTURE_LIMIT);
});
return () => captured;
};
const prewarmFailureReason = (launch: RegistrationRunnerLaunch): string => {
const observed =
launch.spawnError !== undefined
? launch.spawnError
: launch.interruptedSignal !== undefined
? `the package runner was stopped by ${launch.interruptedSignal}`
: launch.timedOut
? `the package runner timed out after the bounded prewarm window`
: `exit ${launch.exitCode === null ? "unknown" : String(launch.exitCode)}${
launch.stderr.trim() === "" ? "" : `: ${launch.stderr.trim()}`
}`;
const normalized = observed
.replace(/\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\([0-9A-Za-z]|[@-Z\\^_])/gu, "")
.replace(/[\u0000-\u001f\u007f]+/gu, " ")
.replace(/\s+/gu, " ")
.trim();
return `The pinned package-runner entry could not be materialized before the first client start (${normalized.slice(0, PREWARM_REASON_LIMIT)}); the first client start will install it inside its startup deadline.`;
};
+8
View File
@@ -13,6 +13,7 @@ import type {
AnalysisError,
} from "../domain/analysisErrorBase.js";
import type { SetupClient } from "./SupportedClients.js";
import type { SetupRegistrationPrewarm } from "./SetupRegistrationPrewarm.js";
import type {
SetupFailureCode,
SetupHopperInstallResult,
@@ -137,6 +138,10 @@ export interface SetupHost {
): Promise<Result<void, SetupSkillInstallError>>;
installSkill(clientIds: readonly string[]): Promise<SkillInstallResult>;
doctor(scope?: DoctorScope): Promise<Awaited<ReturnType<typeof runDoctor>>>;
/** Best-effort materialization of the pinned package-runner cache entry
* before the first client start, so a one-time dependency install cannot
* consume a client's MCP startup deadline. Absent hosts skip the step. */
prewarmRegistrationRunner?(): Promise<SetupRegistrationPrewarm>;
}
/** Structured setup outcome carrying remediation instead of prompting. */
export interface SetupResult {
@@ -156,6 +161,9 @@ export interface SetupResult {
readonly code?: SetupFailureCode;
/** Identified resources still retained after finalization; absent after confirmed release. */
readonly cleanup?: AnalysisCleanupObservation | undefined;
/** Best-effort pinned package-runner cache materialization; absent when the
* host offers no prewarm capability or setup finished before finalization. */
readonly prewarm?: SetupRegistrationPrewarm;
}
/** Whether the requested setup actions still need approval or human repair. */
+2
View File
@@ -100,6 +100,8 @@ export const renderInteractiveSetupResult = (
.map(([client]) => clientDisplayNames.get(client) ?? client);
const readyClients = readyAgentClients(result, changedClients);
renderReadyCapabilities(result, readyClients, environment);
if (result.prewarm?.status === "failed")
writeLine(`! ${result.prewarm.reason}`);
outro(
changedClients.length === 0
? "REA is ready for local app analysis."
@@ -0,0 +1,233 @@
import { mkdir, rm } from "node:fs/promises";
import { dirname } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createDoctorHostFixture } from "../../../src/application/Doctor.fixture.js";
import { systemSetupHost } from "../../../src/application/SetupHost.js";
import { runSetup } from "../../../src/application/Setup.js";
import { options } from "../../../src/application/Setup.fixture.js";
import type { SetupRegistrationPrewarm } from "../../../src/application/SetupRegistrationPrewarm.js";
import { supportedClients } from "../../../src/application/SupportedClients.js";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
/**
* A package-runner launch environment that also satisfies the fixture's
* Hopper launcher probe, so setup reports readiness instead of proposing a
* Hopper installation.
*/
const packageRunnerEnvironment = {
npm_command: "exec",
npm_config_user_agent: "npm/11.16.0 node/v24.14.0",
HOPPER_LAUNCHER_PATH: "/fixture/Hopper",
} as const;
describe("setup registration package-runner prewarm", () => {
it("materializes the pinned runner cache once after configuring clients", async () => {
const root = await createTestTempDirectory("rea-setup-prewarm-");
roots.push(root);
const client = supportedClients(root, "darwin", {}).find(
(candidate) => candidate.name === "cursor",
);
if (client === undefined) throw new Error("Cursor registration is missing");
await mkdir(dirname(client.configPath), { recursive: true });
const doctor = createDoctorHostFixture({
clientRegistrations: () =>
readClientRegistrationStatuses(root, undefined, {
platform: "darwin",
environment: {},
}),
providerInspections: () =>
Promise.resolve([
{
id: "hopper",
configured: true,
available: false,
providerVersion: "6",
registrationEnvironment: {},
checks: [
{
name: "hopper-license",
ok: false,
code: "unavailable",
detail: "Runtime license is unavailable",
classification: "missing_analysis_engine",
remediation: null,
},
],
},
]),
});
let prewarmCalls = 0;
const prewarmResult: SetupRegistrationPrewarm = { status: "warmed" };
const host = {
...systemSetupHost(doctor, packageRunnerEnvironment),
// The host reports the developer machine's Node; pin a supported one
// so readiness does not depend on the local runtime.
nodeVersion: "24.22.1",
detectedClients: () => Promise.resolve([client]),
supportedClients: () => Promise.resolve([client]),
prewarmRegistrationRunner: () => {
prewarmCalls += 1;
return Promise.resolve(prewarmResult);
},
};
const result = await runSetup(
{
...options(true),
clientIds: ["cursor"],
installSkill: false,
proposeHopper: false,
readinessScope: { clients: [], providers: [], skill: false },
},
host,
);
expect(result.status).toBe("ready");
expect(result.clients.cursor).toMatchObject({ status: "configured" });
expect(prewarmCalls).toBe(1);
expect(result.prewarm).toEqual(prewarmResult);
});
it("keeps setup ready when the prewarm fails, with a warning progress event", async () => {
const root = await createTestTempDirectory("rea-setup-prewarm-fail-");
roots.push(root);
const client = supportedClients(root, "darwin", {}).find(
(candidate) => candidate.name === "cursor",
);
if (client === undefined) throw new Error("Cursor registration is missing");
await mkdir(dirname(client.configPath), { recursive: true });
const doctor = createDoctorHostFixture({
clientRegistrations: () =>
readClientRegistrationStatuses(root, undefined, {
platform: "darwin",
environment: {},
}),
providerInspections: () => Promise.resolve([]),
});
const progress: { state: string; detail?: string }[] = [];
const host = {
...systemSetupHost(doctor, packageRunnerEnvironment),
// The host reports the developer machine's Node; pin a supported one
// so readiness does not depend on the local runtime.
nodeVersion: "24.22.1",
detectedClients: () => Promise.resolve([client]),
supportedClients: () => Promise.resolve([client]),
prewarmRegistrationRunner: () =>
Promise.resolve({
status: "failed" as const,
reason: "The pinned package-runner entry could not be materialized",
}),
};
const result = await runSetup(
{
...options(true),
clientIds: ["cursor"],
installSkill: false,
proposeHopper: false,
readinessScope: { clients: [], providers: [], skill: false },
onProgress: (event) =>
progress.push({
state: event.state,
...(event.detail === undefined ? {} : { detail: event.detail }),
}),
},
host,
);
expect(result.status).toBe("ready");
expect(result.prewarm).toMatchObject({ status: "failed" });
expect(progress).toContainEqual({
state: "warning",
detail: "The pinned package-runner entry could not be materialized",
});
});
it("omits the prewarm summary when the host offers no capability", async () => {
const root = await createTestTempDirectory("rea-setup-prewarm-absent-");
roots.push(root);
const client = supportedClients(root, "darwin", {}).find(
(candidate) => candidate.name === "cursor",
);
if (client === undefined) throw new Error("Cursor registration is missing");
await mkdir(dirname(client.configPath), { recursive: true });
const doctor = createDoctorHostFixture({
clientRegistrations: () =>
readClientRegistrationStatuses(root, undefined, {
platform: "darwin",
environment: {},
}),
providerInspections: () => Promise.resolve([]),
});
const { prewarmRegistrationRunner: absent, ...hostWithoutPrewarm } =
systemSetupHost(doctor, packageRunnerEnvironment);
void absent;
const host = {
...hostWithoutPrewarm,
// The host reports the developer machine's Node; pin a supported one
// so readiness does not depend on the local runtime.
nodeVersion: "24.22.1",
detectedClients: () => Promise.resolve([client]),
supportedClients: () => Promise.resolve([client]),
};
const result = await runSetup(
{
...options(true),
clientIds: ["cursor"],
installSkill: false,
proposeHopper: false,
readinessScope: { clients: [], providers: [], skill: false },
},
host,
);
expect(result.status).toBe("ready");
expect(result.prewarm).toBeUndefined();
});
it("skips the prewarm when no client registration was applied", async () => {
const root = await createTestTempDirectory("rea-setup-prewarm-skip-");
roots.push(root);
const client = supportedClients(root, "darwin", {}).find(
(candidate) => candidate.name === "cursor",
);
if (client === undefined) throw new Error("Cursor registration is missing");
await mkdir(dirname(client.configPath), { recursive: true });
const doctor = createDoctorHostFixture({
clientRegistrations: () =>
readClientRegistrationStatuses(root, undefined, {
platform: "darwin",
environment: {},
}),
providerInspections: () => Promise.resolve([]),
});
let prewarmCalls = 0;
const host = {
...systemSetupHost(doctor, packageRunnerEnvironment),
nodeVersion: "24.22.1",
detectedClients: () => Promise.resolve([client]),
supportedClients: () => Promise.resolve([client]),
prewarmRegistrationRunner: () => {
prewarmCalls += 1;
return Promise.resolve({ status: "warmed" as const });
},
};
const result = await runSetup(
{
...options(true),
clientIds: [],
installSkill: false,
proposeHopper: false,
readinessScope: { clients: [], providers: [], skill: false },
},
host,
);
expect(result.status).toBe("ready");
expect(prewarmCalls).toBe(0);
expect(result.prewarm).toBeUndefined();
});
});