From e4349eed70143f6ce3952273401b1403a913b784 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 2 Jul 2026 08:24:54 -0700 Subject: [PATCH] fix(merger): auto-heal outdated lockfile in dependency sync When a task adds/removes a dependency without regenerating the lockfile, the inferred `pnpm install --frozen-lockfile` (and yarn/bun equivalents) in the AI-merge clean room fails with ERR_PNPM_OUTDATED_LOCKFILE, dead- ending the merge. Detect that specific frozen-refusal and retry once non-frozen (pnpm gets explicit --no-frozen-lockfile to override any CI default), regenerating the lockfile and recomputing the install marker. A configured worktreeInitCommand keeps its authoritative frozen intent and still hard-fails. Surfaced via the merge:ai-deps-sync run-audit event. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/merge-lockfile-auto-heal.md | 7 + ...erge-dependency-sync-lockfile-heal.test.ts | 137 ++++++++++++++++++ packages/engine/src/merge-dependency-sync.ts | 84 ++++++++++- packages/engine/src/merger-ai.ts | 4 + 4 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 .changeset/merge-lockfile-auto-heal.md create mode 100644 packages/engine/src/__tests__/merge-dependency-sync-lockfile-heal.test.ts diff --git a/.changeset/merge-lockfile-auto-heal.md b/.changeset/merge-lockfile-auto-heal.md new file mode 100644 index 0000000000..cab2460012 --- /dev/null +++ b/.changeset/merge-lockfile-auto-heal.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Merges no longer fail when a task adds a dependency without updating the lockfile. +category: fix +dev: In merge-dependency-sync.ts, an inferred frozen install (pnpm/yarn/bun) that fails with an outdated-lockfile error now retries once non-frozen (pnpm gets explicit --no-frozen-lockfile) to regenerate the lockfile in the clean-room worktree, recomputing the install marker. Configured worktreeInitCommand keeps its authoritative frozen intent and still hard-fails. Surfaced via the merge:ai-deps-sync run-audit event (healed/healedCommand). diff --git a/packages/engine/src/__tests__/merge-dependency-sync-lockfile-heal.test.ts b/packages/engine/src/__tests__/merge-dependency-sync-lockfile-heal.test.ts new file mode 100644 index 0000000000..7238cd7a62 --- /dev/null +++ b/packages/engine/src/__tests__/merge-dependency-sync-lockfile-heal.test.ts @@ -0,0 +1,137 @@ +import { afterAll, describe, expect, it } from "vitest"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + buildNonFrozenRetryCommand, + computeLockfileHash, + installWorktreeDependencies, + isOutdatedLockfileError, + readInstallMarker, +} from "../merge-dependency-sync.js"; + +/* +FNXC:AIMerge 2026-07-02-14:05 (lockfile auto-heal): +Fast unit coverage for the inferred frozen-lockfile → non-frozen retry recovery. A task that adds a +dependency without regenerating the lockfile makes `pnpm install --frozen-lockfile` fail with +ERR_PNPM_OUTDATED_LOCKFILE; the merger must recover by re-running non-frozen instead of aborting the merge. +Uses a fake `pnpm` bin (no git, no runAiMerge) to stay off the slow lane (FN-5048). +*/ + +const RM = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const; +const tracked = new Set(); +afterAll(() => { + for (const d of tracked) { + try { rmSync(d, RM); } catch { /* best effort */ } + } +}); + +function tmp(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tracked.add(dir); + return dir; +} + +/** + * Install a fake `pnpm` that logs each invocation's args and, when `--frozen-lockfile` is present, exits + * non-zero with the canonical pnpm outdated-lockfile stderr. `--no-frozen-lockfile` succeeds. Returns the + * prior PATH so the caller can restore it. + */ +function installFakePnpm(logPath: string): string { + const binDir = tmp("fusion-heal-fake-bin-"); + const script = join(binDir, "pnpm"); + writeFileSync( + script, + `#!/usr/bin/env node +const fs = require('fs'); +const args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify(logPath)}, JSON.stringify(args) + '\\n'); +if (args.includes('--frozen-lockfile')) { + process.stderr.write('ERR_PNPM_OUTDATED_LOCKFILE Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with package.json\\n'); + process.exit(1); +} +process.exit(0); +`, + ); + chmodSync(script, 0o755); + const previousPath = process.env.PATH ?? ""; + process.env.PATH = `${binDir}${delimiter}${previousPath}`; + return previousPath; +} + +function readLog(path: string): string[][] { + return readFileSync(path, "utf-8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)); +} + +describe("buildNonFrozenRetryCommand", () => { + it("negates pnpm frozen flag explicitly (overrides CI default)", () => { + expect(buildNonFrozenRetryCommand("pnpm install --frozen-lockfile")).toBe("pnpm install --no-frozen-lockfile"); + }); + it("drops the frozen flag for yarn and bun", () => { + expect(buildNonFrozenRetryCommand("yarn install --frozen-lockfile")).toBe("yarn install"); + expect(buildNonFrozenRetryCommand("bun install --frozen-lockfile")).toBe("bun install"); + }); + it("returns null when there is no frozen flag to heal", () => { + expect(buildNonFrozenRetryCommand("npm install")).toBeNull(); + expect(buildNonFrozenRetryCommand("pnpm install")).toBeNull(); + }); +}); + +describe("isOutdatedLockfileError", () => { + it("matches pnpm/yarn/bun frozen-refusal signatures", () => { + expect(isOutdatedLockfileError("ERR_PNPM_OUTDATED_LOCKFILE cannot install")).toBe(true); + expect(isOutdatedLockfileError("Your lockfile needs to be updated")).toBe(true); + expect(isOutdatedLockfileError("error: lockfile had changes, but lockfile is frozen")).toBe(true); + }); + it("does not match unrelated install failures", () => { + expect(isOutdatedLockfileError("ENOTFOUND registry.npmjs.org")).toBe(false); + expect(isOutdatedLockfileError("EACCES: permission denied")).toBe(false); + }); +}); + +describe("installWorktreeDependencies lockfile auto-heal", () => { + it("retries non-frozen and heals when an inferred frozen install hits an outdated lockfile", async () => { + const dir = tmp("fusion-heal-repo-"); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfile: {}\n"); + mkdirSync(join(dir, "node_modules"), { recursive: true }); // a real install creates this; the marker lives under it + const logPath = join(tmp("fusion-heal-log-"), "install.log"); + const previousPath = installFakePnpm(logPath); + try { + const result = await installWorktreeDependencies({ cwd: dir, taskId: "FN-1" }); + expect(result.healed).toBe(true); + expect(result.healedCommand).toBe("pnpm install --no-frozen-lockfile"); + expect(result.installCommand).toBe("pnpm install --frozen-lockfile"); + expect(result.skipped).toBe(false); + // Marker reflects the current lockfile so the next merge can legitimately skip when unchanged. + expect(readInstallMarker(dir)).toBe(computeLockfileHash(dir)); + } finally { + process.env.PATH = previousPath; + } + + const calls = readLog(logPath); + expect(calls).toHaveLength(2); + expect(calls[0]).toEqual(["install", "--frozen-lockfile"]); + expect(calls[1]).toEqual(["install", "--no-frozen-lockfile"]); + }); + + it("does NOT auto-heal a configured worktreeInitCommand — frozen intent is authoritative", async () => { + const dir = tmp("fusion-heal-configured-"); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfile: {}\n"); + const logPath = join(tmp("fusion-heal-log-"), "install.log"); + const previousPath = installFakePnpm(logPath); + try { + await expect( + installWorktreeDependencies({ + cwd: dir, + taskId: "FN-1", + settings: { worktreeInitCommand: "pnpm install --frozen-lockfile" } as any, + }), + ).rejects.toThrow(/Dependency sync failed for FN-1.*OUTDATED_LOCKFILE/); + } finally { + process.env.PATH = previousPath; + } + // Only the single frozen attempt ran; no non-frozen retry. + expect(readLog(logPath)).toEqual([["install", "--frozen-lockfile"]]); + }); +}); diff --git a/packages/engine/src/merge-dependency-sync.ts b/packages/engine/src/merge-dependency-sync.ts index 32eda02aae..678159151f 100644 --- a/packages/engine/src/merge-dependency-sync.ts +++ b/packages/engine/src/merge-dependency-sync.ts @@ -20,6 +20,14 @@ export interface WorktreeDependencySyncResult { configured: boolean; skipped: boolean; skipReason?: "no-command" | "lockfile-marker-match"; + /** + * FNXC:AIMerge 2026-07-02-14:05 (lockfile auto-heal): + * True when the inferred frozen-lockfile install failed with an outdated-lockfile error and Fusion + * recovered by re-running the non-frozen variant (regenerating the lockfile inside the clean-room + * worktree). `healedCommand` records what actually reran. Callers surface this in run-audit. + */ + healed: boolean; + healedCommand?: string; durationMs: number; } @@ -92,6 +100,38 @@ function throwIfDependencySyncAborted(signal: AbortSignal | undefined, taskId: s throw err; } +function extractCommandErrorDetails(error: unknown): string { + const maybeCommandError = error as { stderr?: unknown; stdout?: unknown; message?: unknown }; + return String(maybeCommandError.stderr || maybeCommandError.stdout || maybeCommandError.message || error); +} + +/** + * FNXC:AIMerge 2026-07-02-14:05 (lockfile auto-heal): + * A task that adds/removes a dependency but does not regenerate the lockfile makes the inferred frozen + * install fail (pnpm `ERR_PNPM_OUTDATED_LOCKFILE`; yarn/bun equivalents). That is the normal outcome of a + * legitimate dependency change, not corruption, so detect it and retry non-frozen instead of dead-ending + * the merge. Match the frozen-refusal signatures across pnpm/yarn/bun. + */ +export function isOutdatedLockfileError(details: string): boolean { + return /ERR_PNPM_OUTDATED_LOCKFILE|OUTDATED_LOCKFILE|frozen-lockfile|lockfile is frozen|lockfile had changes, but lockfile is frozen|lockfile needs to be updated|Your lockfile needs to be updated/i.test( + details, + ); +} + +/** + * FNXC:AIMerge 2026-07-02-14:05 (lockfile auto-heal): + * Build the non-frozen retry for an inferred frozen install. pnpm gets the explicit `--no-frozen-lockfile` + * negation so a CI-default `frozen-lockfile=true` is overridden deterministically; yarn/bun simply drop the + * flag. Returns null when the command carries no frozen flag (nothing to heal). + */ +export function buildNonFrozenRetryCommand(installCommand: string): string | null { + if (!installCommand.includes("--frozen-lockfile")) return null; + if (/^\s*pnpm\b/.test(installCommand)) { + return installCommand.replace(/--frozen-lockfile/g, "--no-frozen-lockfile"); + } + return installCommand.replace(/\s*--frozen-lockfile/g, "").trim(); +} + /** * FNXC:AIMerge 2026-06-13-20:18: * Temporary AI-merge clean-room worktrees must install workspace dependencies before merge/review verification runs inside them. A configured worktreeInitCommand is the authoritative bootstrap and always runs; inferred lockfile installs may skip only when the node_modules install marker matches the current lockfile hash. @@ -104,7 +144,7 @@ export async function installWorktreeDependencies(options: InstallWorktreeDepend const configured = configuredCommand !== null; if (!installCommand) { - return { installCommand: null, configured: false, skipped: true, skipReason: "no-command", durationMs: Date.now() - startedAt }; + return { installCommand: null, configured: false, skipped: true, skipReason: "no-command", healed: false, durationMs: Date.now() - startedAt }; } const shouldUseInstallMarker = !configured; @@ -117,6 +157,7 @@ export async function installWorktreeDependencies(options: InstallWorktreeDepend configured, skipped: true, skipReason: "lockfile-marker-match", + healed: false, durationMs: Date.now() - startedAt, }; } @@ -125,20 +166,49 @@ export async function installWorktreeDependencies(options: InstallWorktreeDepend logger?.log?.(`${taskId}: syncing dependencies ${context}`); await log?.(`Syncing dependencies ${context}: ${installCommand}`); - try { - await execAsync(installCommand, { + const runInstall = (command: string): Promise => + execAsync(command, { cwd, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024, timeout: INSTALL_TIMEOUT_MS, }); + + try { + await runInstall(installCommand); throwIfDependencySyncAborted(signal, taskId); if (lockHash) writeInstallMarker(cwd, lockHash); - return { installCommand, configured, skipped: false, durationMs: Date.now() - startedAt }; + return { installCommand, configured, skipped: false, healed: false, durationMs: Date.now() - startedAt }; } catch (error: unknown) { throwIfDependencySyncAborted(signal, taskId); - const maybeCommandError = error as { stderr?: unknown; stdout?: unknown; message?: unknown }; - const details = maybeCommandError.stderr || maybeCommandError.stdout || maybeCommandError.message || String(error); - throw new Error(`Dependency sync failed for ${taskId}: ${String(details)}`.trim()); + const details = extractCommandErrorDetails(error); + + /* + FNXC:AIMerge 2026-07-02-14:05 (lockfile auto-heal): + Only auto-heal an INFERRED frozen install (`!configured`) — a user-supplied worktreeInitCommand is + authoritative and its frozen intent is respected. On an outdated-lockfile refusal, retry once with the + non-frozen variant so a task's legitimate dependency add/remove regenerates the lockfile in the clean + room rather than aborting the merge. The regenerated lockfile changes the hash, so recompute the marker + from disk (writing the pre-heal hash would wrongly skip the next real change). + */ + const retryCommand = configured ? null : buildNonFrozenRetryCommand(installCommand); + if (retryCommand && isOutdatedLockfileError(details)) { + logger?.log?.(`${taskId}: lockfile out of date; retrying dependency sync without frozen lockfile`); + await log?.(`Dependency sync hit an outdated lockfile; retrying without frozen lockfile: ${retryCommand}`); + try { + await runInstall(retryCommand); + } catch (retryError: unknown) { + throwIfDependencySyncAborted(signal, taskId); + throw new Error( + `Dependency sync failed for ${taskId} (after non-frozen retry): ${extractCommandErrorDetails(retryError)}`.trim(), + ); + } + throwIfDependencySyncAborted(signal, taskId); + const healedHash = computeLockfileHash(cwd); + if (healedHash) writeInstallMarker(cwd, healedHash); + return { installCommand, configured, skipped: false, healed: true, healedCommand: retryCommand, durationMs: Date.now() - startedAt }; + } + + throw new Error(`Dependency sync failed for ${taskId}: ${details}`.trim()); } } diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index d6a9acc97e..3cc5e3b82b 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -703,6 +703,10 @@ export async function landOneRepo( configured: depsSyncResult.configured, skipped: depsSyncResult.skipped, skipReason: depsSyncResult.skipReason, + // FNXC:AIMerge 2026-07-02-14:05 (lockfile auto-heal): record when an outdated frozen lockfile + // was recovered by a non-frozen retry so operators can see deps drifted without failing merge. + healed: depsSyncResult.healed, + healedCommand: depsSyncResult.healedCommand, durationMs: depsSyncResult.durationMs, }, });