mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.`;
|
||||
};
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user