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:
7
.changeset/merge-lockfile-auto-heal.md
Normal file
7
.changeset/merge-lockfile-auto-heal.md
Normal 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).
|
||||
@@ -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"]]);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user