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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-02 08:24:54 -07:00
parent 53d5bb1cba
commit e4349eed70
4 changed files with 225 additions and 7 deletions

View File

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

View File

@@ -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<string>();
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"]]);
});
});

View File

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

View File

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