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

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