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

---

[![Compound
Engineering](https://img.shields.io/badge/Built_with-Compound_Engineering-6366f1)](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:
gsxdsm
2026-07-19 12:16:12 -07:00
committed by GitHub
parent b2a7425c76
commit aa401123ea
9 changed files with 257 additions and 12 deletions

View 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.

View File

@@ -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";

View File

@@ -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 () => {
/*

View File

@@ -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;
}

View File

@@ -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)) {

View File

@@ -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

View File

@@ -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),
};
}
}

View File

@@ -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 });

View File

@@ -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$/, "");