diff --git a/docs/installation.md b/docs/installation.md index 20743f95f0..13ce21d257 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 diff --git a/src/application/Setup.ts b/src/application/Setup.ts index 08a482c67e..87b782699c 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -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 }), }; }; diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 3990b35272..ddfe19e9f0 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -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 => + 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, diff --git a/src/application/SetupRegistrationPrewarm.test.ts b/src/application/SetupRegistrationPrewarm.test.ts new file mode 100644 index 0000000000..9b6d3288d7 --- /dev/null +++ b/src/application/SetupRegistrationPrewarm.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/src/application/SetupRegistrationPrewarm.ts b/src/application/SetupRegistrationPrewarm.ts new file mode 100644 index 0000000000..63cf3878f0 --- /dev/null +++ b/src/application/SetupRegistrationPrewarm.ts @@ -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; + /** 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; + +/** + * 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, + launchCommand: RegistrationRunnerLauncher = launchRegistrationRunnerCommand, +): Promise => { + 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 => { + 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 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.`; +}; diff --git a/src/application/SetupTypes.ts b/src/application/SetupTypes.ts index a817425bd7..3d32906de1 100644 --- a/src/application/SetupTypes.ts +++ b/src/application/SetupTypes.ts @@ -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>; installSkill(clientIds: readonly string[]): Promise; doctor(scope?: DoctorScope): Promise>>; + /** 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; } /** 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. */ diff --git a/src/cli/interactiveSetup.ts b/src/cli/interactiveSetup.ts index 9e73025e69..2d2690abaf 100644 --- a/src/cli/interactiveSetup.ts +++ b/src/cli/interactiveSetup.ts @@ -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." diff --git a/tests/boundary/filesystem/setupRegistrationPrewarm.test.ts b/tests/boundary/filesystem/setupRegistrationPrewarm.test.ts new file mode 100644 index 0000000000..63fa10e838 --- /dev/null +++ b/tests/boundary/filesystem/setupRegistrationPrewarm.test.ts @@ -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(); + }); +});