fix: make Windows updates and CE personas reliable (#2340)
## Summary Windows installs with slow native dependencies now get five minutes to finish, and a real timeout is reported as an actionable terminal retry instead of a wall of preceding npm deprecation warnings. Registry `ETIMEDOUT` errors keep their network diagnosis, including after the legacy-bin `--force` retry. Compound Engineering personas are now included in the published CLI bundle, with complete source-to-staged coverage for all persona definitions and a clear startup error if the bundled assets are missing or empty. The PostgreSQL statement visible in the report was validated by the existing real-Postgres schema reapply test. Its actual `caused by` detail was truncated, so this PR deliberately makes no speculative database change. ## Validation - Dashboard updater tests: 22 passed - CLI updater tests: 16 passed - CE persona installer tests: 7 passed - Published bundle persona assertion: passed against every source persona - CLI and CE plugin typechecks: passed - Changed production/config lint and strict changeset validation: passed - Real PostgreSQL schema reapply integration test: passed --- [](https://github.com/EveryInc/compound-engineering-plugin) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Windows CLI and dashboard updates now allow up to five minutes for installation and restore Compound Engineering agent personas during npm installs. * Update failures now surface clearer, terminal timeout guidance (while preserving specific network connection diagnostics) and avoid misleading “deprecated”/generic timeout text. * Persona assets are reliably included in plugin builds and bunded persona installation now errors clearly when definitions are missing or empty. * **Tests** * Expanded update and bundling coverage for the new 5-minute timeout and error-handling scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
.changeset/fix-windows-update-ce-assets.md
Normal file
7
.changeset/fix-windows-update-ce-assets.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Make Windows updates actionable and restore Compound Engineering agent personas in npm installs.
|
||||
category: fix
|
||||
dev: Extends npm install timeouts to five minutes, preserves timeout causes, and stages CE persona assets.
|
||||
@@ -313,6 +313,35 @@ describe("CLI bundle output", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("dist/plugins/fusion-plugin-compound-engineering/ ships agent persona definitions", () => {
|
||||
const sourceAgentsRoot = join(
|
||||
workspaceRoot,
|
||||
"plugins",
|
||||
"fusion-plugin-compound-engineering",
|
||||
"src",
|
||||
"agents",
|
||||
);
|
||||
const stagedAgentsRoot = join(
|
||||
cliRoot,
|
||||
"dist",
|
||||
"plugins",
|
||||
"fusion-plugin-compound-engineering",
|
||||
"agents",
|
||||
);
|
||||
const sourceAgentFiles = readdirSync(sourceAgentsRoot)
|
||||
.filter((file) => file.endsWith(".md"))
|
||||
.sort();
|
||||
const stagedAgentFiles = readdirSync(stagedAgentsRoot)
|
||||
.filter((file) => file.endsWith(".md"))
|
||||
.sort();
|
||||
|
||||
expect(stagedAgentFiles).toEqual(sourceAgentFiles);
|
||||
for (const agentFile of stagedAgentFiles) {
|
||||
const stagedAgentPath = join(stagedAgentsRoot, agentFile);
|
||||
expect(readFileSync(stagedAgentPath, "utf-8")).toMatch(/^---[\s\S]*?name:\s*\S+/);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not create skills directories for bundled plugins without skill sources", () => {
|
||||
const pluginId = "fusion-plugin-roadmap";
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("runUpdate", () => {
|
||||
|
||||
await runUpdate();
|
||||
|
||||
expect(execAsyncMock).toHaveBeenCalledWith("npm install -g @runfusion/fusion@latest", expect.objectContaining({ timeout: 120_000 }));
|
||||
expect(execAsyncMock).toHaveBeenCalledWith("npm install -g @runfusion/fusion@latest", expect.objectContaining({ timeout: 300_000 }));
|
||||
expect(logSpy).toHaveBeenCalledWith("Update complete.");
|
||||
});
|
||||
|
||||
@@ -184,6 +184,59 @@ describe("runUpdate", () => {
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error installing update: network down");
|
||||
});
|
||||
|
||||
it("reports a timeout instead of npm deprecation warnings", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
|
||||
execAsyncMock.mockRejectedValue(
|
||||
Object.assign(new Error("Command failed"), {
|
||||
killed: true,
|
||||
signal: "SIGTERM",
|
||||
stderr: "npm warn deprecated prebuild-install@7.1.3: No longer maintained.",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(runUpdate()).rejects.toThrow("process.exit:1");
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/timed out after 5 minutes.*terminal/i));
|
||||
expect(errorSpy.mock.calls.flat().join("\n")).toContain(
|
||||
"npm install -g @runfusion/fusion@latest",
|
||||
);
|
||||
expect(errorSpy.mock.calls.flat().join("\n")).not.toContain("npm install --force");
|
||||
expect(errorSpy.mock.calls.flat().join("\n")).not.toContain("deprecated");
|
||||
});
|
||||
|
||||
it("reports a timeout when the forced collision retry stalls", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
|
||||
execAsyncMock
|
||||
.mockRejectedValueOnce(new Error("npm ERR! code EEXIST\nnpm ERR! path /usr/local/bin/fn\nnpm ERR! File exists"))
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("Command failed"), {
|
||||
killed: true,
|
||||
stderr: "npm warn deprecated prebuild-install@7.1.3: No longer maintained.",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(runUpdate()).rejects.toThrow("process.exit:1");
|
||||
|
||||
expect(execAsyncMock).toHaveBeenCalledTimes(2);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/timed out after 5 minutes.*terminal/i));
|
||||
expect(errorSpy.mock.calls.flat().join("\n")).toContain(
|
||||
"npm install --force -g @runfusion/fusion@latest",
|
||||
);
|
||||
expect(errorSpy.mock.calls.flat().join("\n")).not.toContain("deprecated");
|
||||
});
|
||||
|
||||
it("preserves a registry ETIMEDOUT diagnosis", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
|
||||
execAsyncMock.mockRejectedValue(
|
||||
Object.assign(new Error("connect ETIMEDOUT 10.0.0.1:443"), { killed: false }),
|
||||
);
|
||||
|
||||
await expect(runUpdate()).rejects.toThrow("process.exit:1");
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("connect ETIMEDOUT"));
|
||||
expect(errorSpy.mock.calls.flat().join("\n")).not.toMatch(/timed out after 5 minutes/i);
|
||||
});
|
||||
|
||||
|
||||
it("uses identical comparison semantics for CLI update notifications", async () => {
|
||||
/*
|
||||
|
||||
@@ -9,6 +9,7 @@ const execAsync = promisify(exec);
|
||||
const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion";
|
||||
const INSTALL_COMMAND = "npm install -g @runfusion/fusion@latest";
|
||||
const LOCAL_INSTALL_COMMAND = "npm install @runfusion/fusion@latest";
|
||||
const INSTALL_TIMEOUT_MS = 300_000;
|
||||
|
||||
export type RunUpdateOptions = {
|
||||
check?: boolean;
|
||||
@@ -98,7 +99,27 @@ function getInstallCommand(globalInstall: boolean, force = false): string {
|
||||
return force ? baseCommand.replace("npm install", "npm install --force") : baseCommand;
|
||||
}
|
||||
|
||||
type InstallError = Error & { stdout?: string; stderr?: string };
|
||||
type InstallError = Error & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
killed?: boolean;
|
||||
};
|
||||
|
||||
function isInstallTimeoutError(error: unknown): boolean {
|
||||
const installError = error as InstallError;
|
||||
/*
|
||||
FNXC:UpdateInstall 2026-07-19-09:50:
|
||||
Native npm dependencies can take longer than two minutes to install on Windows. Allow five minutes, and classify only an exec-killed process as that ceiling so registry ETIMEDOUT failures retain their real diagnosis.
|
||||
*/
|
||||
return installError?.killed === true;
|
||||
}
|
||||
|
||||
function installTimeoutError(globalInstall: boolean, force = false): Error {
|
||||
const command = getInstallCommand(globalInstall, force);
|
||||
return new Error(
|
||||
`Update timed out after ${INSTALL_TIMEOUT_MS / 60_000} minutes. Retry from a terminal with: ${command}`,
|
||||
);
|
||||
}
|
||||
|
||||
function isBinCollisionInstallError(error: unknown): boolean {
|
||||
const installError = error as InstallError;
|
||||
@@ -143,11 +164,14 @@ function printCollisionRemediation(binaryPath: string | null): void {
|
||||
async function installLatest(globalInstall: boolean, resolveBinaryPath: () => string | null = detectRunningBinaryPath): Promise<void> {
|
||||
try {
|
||||
await execAsync(getInstallCommand(globalInstall), {
|
||||
timeout: 120_000,
|
||||
timeout: INSTALL_TIMEOUT_MS,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isInstallTimeoutError(error)) {
|
||||
throw installTimeoutError(globalInstall);
|
||||
}
|
||||
if (!isBinCollisionInstallError(error)) {
|
||||
throw error;
|
||||
}
|
||||
@@ -156,11 +180,14 @@ async function installLatest(globalInstall: boolean, resolveBinaryPath: () => st
|
||||
|
||||
try {
|
||||
await execAsync(getInstallCommand(globalInstall, true), {
|
||||
timeout: 120_000,
|
||||
timeout: INSTALL_TIMEOUT_MS,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return;
|
||||
} catch (forceError) {
|
||||
if (isInstallTimeoutError(forceError)) {
|
||||
throw installTimeoutError(globalInstall, true);
|
||||
}
|
||||
printCollisionRemediation(resolveBinaryPath());
|
||||
throw forceError;
|
||||
}
|
||||
|
||||
@@ -237,6 +237,17 @@ async function bundlePluginEntry({ pluginId, srcDir, destDir, withMcpAsset = fal
|
||||
console.log(`Staged plugin skills for ${pluginId} to dist/plugins/${pluginId}/skills`);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:BundledPluginAssets 2026-07-19-09:50:
|
||||
Published plugins must stage persona definitions beside bundled.js so Compound Engineering skills can resolve every reviewer and research agent after an npm install.
|
||||
*/
|
||||
const agentsSourceDir = join(srcDir, "src", "agents");
|
||||
if (existsSync(agentsSourceDir)) {
|
||||
const agentsDestDir = join(destDir, "agents");
|
||||
cpSync(agentsSourceDir, agentsDestDir, { recursive: true });
|
||||
console.log(`Staged plugin agents for ${pluginId} to dist/plugins/${pluginId}/agents`);
|
||||
}
|
||||
|
||||
if (withMcpAsset) {
|
||||
const mcpServerAsset = join(srcDir, "src", "mcp-schema-server.cjs");
|
||||
if (!existsSync(mcpServerAsset)) {
|
||||
|
||||
@@ -191,7 +191,7 @@ describe("update-check", () => {
|
||||
const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir });
|
||||
|
||||
expect(execFake).toHaveBeenCalledWith("npm install -g @runfusion/fusion@latest", {
|
||||
timeout: 120_000,
|
||||
timeout: 300_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true });
|
||||
@@ -227,6 +227,58 @@ describe("update-check", () => {
|
||||
expect(execFake).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("performUpdateInstall reports a timeout instead of npm deprecation warnings", async () => {
|
||||
const execFake = vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error("Command failed"), {
|
||||
killed: true,
|
||||
signal: "SIGTERM",
|
||||
stderr: "npm warn deprecated prebuild-install@7.1.3: No longer maintained.",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir });
|
||||
|
||||
expect(result).toEqual({
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: "2.0.0",
|
||||
updated: false,
|
||||
error: expect.stringMatching(/timed out after 5 minutes.*terminal/i),
|
||||
});
|
||||
expect(result.error).toContain("npm install -g @runfusion/fusion@latest");
|
||||
expect(result.error).not.toContain("npm install --force");
|
||||
expect(result.error).not.toContain("deprecated");
|
||||
});
|
||||
|
||||
it("performUpdateInstall reports a timeout when the forced collision retry stalls", async () => {
|
||||
const collision = new Error("npm ERR! code EEXIST\nnpm ERR! path /usr/local/bin/fn\nnpm ERR! File exists");
|
||||
const timeout = Object.assign(new Error("Command failed"), {
|
||||
killed: true,
|
||||
stderr: "npm warn deprecated prebuild-install@7.1.3: No longer maintained.",
|
||||
});
|
||||
const execFake = vi.fn().mockRejectedValueOnce(collision).mockRejectedValueOnce(timeout);
|
||||
|
||||
const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir });
|
||||
|
||||
expect(execFake).toHaveBeenCalledTimes(2);
|
||||
expect(result.error).toMatch(/timed out after 5 minutes.*terminal/i);
|
||||
expect(result.error).toContain("npm install --force -g @runfusion/fusion@latest");
|
||||
expect(result.error).not.toContain("deprecated");
|
||||
});
|
||||
|
||||
it("performUpdateInstall preserves a registry ETIMEDOUT diagnosis", async () => {
|
||||
const execFake = vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error("Command failed"), {
|
||||
killed: false,
|
||||
stderr: "npm error network request failed, reason: connect ETIMEDOUT 10.0.0.1:443",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir });
|
||||
|
||||
expect(result.error).toContain("connect ETIMEDOUT");
|
||||
expect(result.error).not.toMatch(/timed out after 5 minutes/i);
|
||||
});
|
||||
|
||||
// FNXC:UpdateInstallPermissions 2026-07-10-14:00: a root-owned global dir
|
||||
// (from `sudo npm i -g`) makes the non-root in-app updater fail with EACCES/
|
||||
// EPERM. It must surface actionable remediation, not raw npm stderr, and must
|
||||
|
||||
@@ -9,7 +9,7 @@ const CACHE_FILENAME = "update-check.json";
|
||||
const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion";
|
||||
const INSTALL_COMMAND = "npm install -g @runfusion/fusion@latest";
|
||||
const FORCE_INSTALL_COMMAND = "npm install --force -g @runfusion/fusion@latest";
|
||||
const INSTALL_TIMEOUT_MS = 120_000;
|
||||
const INSTALL_TIMEOUT_MS = 300_000;
|
||||
const INSTALL_MAX_BUFFER = 10 * 1024 * 1024;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -39,7 +39,12 @@ type ExecInstall = (
|
||||
options: { timeout: number; maxBuffer: number },
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
type InstallError = Error & { stdout?: string; stderr?: string };
|
||||
type InstallError = Error & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: string | number | null;
|
||||
killed?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cache TTL in ms for the given frequency. Frequencies that don't expire by
|
||||
@@ -105,6 +110,19 @@ function getInstallErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isInstallTimeoutError(error: unknown): boolean {
|
||||
const installError = error as InstallError;
|
||||
return installError?.killed === true;
|
||||
}
|
||||
|
||||
function getInstallTimeoutMessage(force = false): string {
|
||||
const command = force ? FORCE_INSTALL_COMMAND : INSTALL_COMMAND;
|
||||
return (
|
||||
`Update timed out after ${INSTALL_TIMEOUT_MS / 60_000} minutes. ` +
|
||||
`Close Fusion and retry from a terminal with: ${command}`
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:UpdateInstallPermissions 2026-07-10-14:00:
|
||||
The in-app "Update now" button runs `npm install -g @runfusion/fusion@latest` as the
|
||||
@@ -116,7 +134,7 @@ EACCES, mirroring the CLI's Homebrew-path awareness. (`--force` cannot grant wri
|
||||
permission, so we do not retry it for this class — unlike bin-collision errors.)
|
||||
*/
|
||||
function isPermissionInstallError(error: unknown): boolean {
|
||||
const installError = error as InstallError & { code?: string };
|
||||
const installError = error as InstallError;
|
||||
if (installError?.code === "EACCES" || installError?.code === "EPERM") return true;
|
||||
const message = [installError?.message, installError?.stderr, installError?.stdout]
|
||||
.filter((part): part is string => typeof part === "string" && part.length > 0)
|
||||
@@ -238,6 +256,19 @@ export async function performUpdateInstall(
|
||||
updated: true,
|
||||
};
|
||||
} catch (error) {
|
||||
/*
|
||||
FNXC:UpdateInstall 2026-07-19-09:50:
|
||||
Native npm dependencies can take longer than two minutes to install on Windows. Allow five minutes, and when exec kills a slow install, report the timeout metadata before npm's preceding deprecation warnings.
|
||||
*/
|
||||
if (isInstallTimeoutError(error)) {
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updated: false,
|
||||
error: getInstallTimeoutMessage(),
|
||||
};
|
||||
}
|
||||
|
||||
// FNXC:UpdateInstallPermissions 2026-07-10-14:00: a root-owned global dir
|
||||
// (from `sudo npm i -g`) yields EACCES/EPERM the non-root updater cannot
|
||||
// recover from — return actionable guidance rather than raw npm stderr.
|
||||
@@ -272,7 +303,9 @@ export async function performUpdateInstall(
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updated: false,
|
||||
error: getInstallErrorMessage(forceError),
|
||||
error: isInstallTimeoutError(forceError)
|
||||
? getInstallTimeoutMessage(true)
|
||||
: getInstallErrorMessage(forceError),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,25 @@ describe("compound engineering bundled agent-persona install", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("fails loudly when a published package omits all bundled persona defs", () => {
|
||||
const missingSourceRoot = join(tmp, "missing-agents");
|
||||
|
||||
expect(() => installBundledCeAgents({
|
||||
sourceRoot: missingSourceRoot,
|
||||
targetRoot: join(tmp, ".fusion-ce-agents"),
|
||||
})).toThrow(/bundled agent persona source.*missing/i);
|
||||
});
|
||||
|
||||
it("fails loudly when the bundled persona directory is empty", () => {
|
||||
const emptySourceRoot = join(tmp, "empty-agents");
|
||||
mkdirSync(emptySourceRoot);
|
||||
|
||||
expect(() => installBundledCeAgents({
|
||||
sourceRoot: emptySourceRoot,
|
||||
targetRoot: join(tmp, ".fusion-ce-agents"),
|
||||
})).toThrow(/contains no markdown definitions/i);
|
||||
});
|
||||
|
||||
it("is idempotent when the plugin-local install provenance is current", () => {
|
||||
const targetRoot = join(tmp, ".fusion-ce-agents");
|
||||
const first = installBundledCeAgents({ targetRoot });
|
||||
|
||||
@@ -141,9 +141,23 @@ export function installBundledCeAgents(
|
||||
const sourceRoot = options.sourceRoot ? resolve(options.sourceRoot) : resolveBundledAgentsRoot();
|
||||
const installIsCurrent = isCurrentInstalledProvenance(targetRoot);
|
||||
|
||||
const sourceFiles = existsSync(sourceRoot)
|
||||
? readdirSync(sourceRoot).filter((f) => f.endsWith(".md"))
|
||||
: [];
|
||||
let sourceFiles: string[];
|
||||
/*
|
||||
FNXC:CompoundEngineeringAgents 2026-07-19-09:50:
|
||||
A published package without bundled persona definitions is corrupt. Fail at plugin startup instead of silently installing zero agents and breaking later skill fanout.
|
||||
*/
|
||||
try {
|
||||
sourceFiles = readdirSync(sourceRoot).filter((file) => file.endsWith(".md"));
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||
throw new Error(`Bundled agent persona source directory is missing: ${sourceRoot}`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (sourceFiles.length === 0) {
|
||||
throw new Error(`Bundled agent persona source directory contains no markdown definitions: ${sourceRoot}`);
|
||||
}
|
||||
|
||||
const results = sourceFiles.map<CeAgentInstallResult>((file) => {
|
||||
const agentId = file.replace(/\.md$/, "");
|
||||
|
||||
Reference in New Issue
Block a user