Files
fusion/packages/engine/src/merge-dependency-sync.ts
gsxdsm e4349eed70 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>
2026-07-02 11:38:32 -07:00

215 lines
9.1 KiB
TypeScript

import { exec } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
import type { Settings } from "@fusion/core";
const execAsync = promisify(exec);
export const INSTALL_MARKER_RELPATH = join("node_modules", ".fusion-install-marker");
const LOCKFILE_CANDIDATES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb", "bun.lock"];
const INSTALL_TIMEOUT_MS = 300_000;
export interface WorktreeDependencySyncLogger {
log?: (message: string) => void;
}
export interface WorktreeDependencySyncResult {
installCommand: string | null;
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;
}
export interface InstallWorktreeDependenciesOptions {
cwd: string;
settings?: Settings | null;
taskId: string;
signal?: AbortSignal;
log?: (message: string) => Promise<void> | void;
logger?: WorktreeDependencySyncLogger;
context?: string;
}
export function hasInstallState(rootDir: string): boolean {
return existsSync(join(rootDir, "node_modules")) || existsSync(join(rootDir, ".pnp.cjs"));
}
export function getConfiguredWorktreeInitCommand(settings?: Pick<Settings, "worktreeInitCommand"> | null): string | null {
const trimmed = settings?.worktreeInitCommand?.trim();
return trimmed ? trimmed : null;
}
export function getDependencySyncCommand(rootDir: string, settings?: Settings | null): string | null {
const configuredCommand = getConfiguredWorktreeInitCommand(settings);
if (configuredCommand) return configuredCommand;
if (existsSync(join(rootDir, "pnpm-lock.yaml"))) return "pnpm install --frozen-lockfile";
if (existsSync(join(rootDir, "package-lock.json"))) return "npm install";
if (existsSync(join(rootDir, "yarn.lock"))) return "yarn install --frozen-lockfile";
if (existsSync(join(rootDir, "bun.lock")) || existsSync(join(rootDir, "bun.lockb"))) {
return "bun install --frozen-lockfile";
}
return null;
}
export function computeLockfileHash(rootDir: string): string | null {
for (const name of LOCKFILE_CANDIDATES) {
const p = join(rootDir, name);
if (existsSync(p)) {
try {
return createHash("sha256").update(readFileSync(p)).digest("hex");
} catch {
return null;
}
}
}
return null;
}
export function readInstallMarker(rootDir: string): string | null {
try {
const value = readFileSync(join(rootDir, INSTALL_MARKER_RELPATH), "utf-8").trim();
return value || null;
} catch {
return null;
}
}
export function writeInstallMarker(rootDir: string, hash: string): void {
try {
writeFileSync(join(rootDir, INSTALL_MARKER_RELPATH), hash);
} catch {
// Best-effort: a missing marker just means the next merge re-runs install.
}
}
function throwIfDependencySyncAborted(signal: AbortSignal | undefined, taskId: string): void {
if (!signal?.aborted) return;
const err = new Error(`Dependency sync aborted for ${taskId}`);
err.name = "AbortError";
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.
*/
export async function installWorktreeDependencies(options: InstallWorktreeDependenciesOptions): Promise<WorktreeDependencySyncResult> {
const { cwd, settings, taskId, signal, log, logger, context = "merge worktree dependency sync" } = options;
const startedAt = Date.now();
const configuredCommand = getConfiguredWorktreeInitCommand(settings);
const installCommand = getDependencySyncCommand(cwd, settings);
const configured = configuredCommand !== null;
if (!installCommand) {
return { installCommand: null, configured: false, skipped: true, skipReason: "no-command", healed: false, durationMs: Date.now() - startedAt };
}
const shouldUseInstallMarker = !configured;
const lockHash = shouldUseInstallMarker ? computeLockfileHash(cwd) : null;
if (lockHash && hasInstallState(cwd) && readInstallMarker(cwd) === lockHash) {
logger?.log?.(`${taskId}: skipping dependency sync (lockfile unchanged since last install)`);
await log?.(`Skipping dependency sync: lockfile hash matches last successful ${installCommand}`);
return {
installCommand,
configured,
skipped: true,
skipReason: "lockfile-marker-match",
healed: false,
durationMs: Date.now() - startedAt,
};
}
throwIfDependencySyncAborted(signal, taskId);
logger?.log?.(`${taskId}: syncing dependencies ${context}`);
await log?.(`Syncing dependencies ${context}: ${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, healed: false, durationMs: Date.now() - startedAt };
} catch (error: unknown) {
throwIfDependencySyncAborted(signal, taskId);
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());
}
}