diff --git a/.changeset/fn-6420-ai-merge-dependency-sync.md b/.changeset/fn-6420-ai-merge-dependency-sync.md new file mode 100644 index 0000000000..df758daf28 --- /dev/null +++ b/.changeset/fn-6420-ai-merge-dependency-sync.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Run the configured or inferred dependency install inside temporary standalone AI-merge clean-room worktrees before merge/review verification. diff --git a/docs/architecture.md b/docs/architecture.md index 5b89610817..afe0fde15a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -672,7 +672,7 @@ Runtime action-gate flow (v1): - `TransientErrorDetector` (`transient-error-detector.ts`) — retriable error classification - `SelfHealingManager` (`self-healing.ts`) — auto-unpause/maintenance recovery actions - Batch 1 maintenance now includes one `fts-maintenance` step for both search indexes. The live `tasks_fts` branch still runs `merge` every tick, `optimize` every 4th tick, and `rebuild` above `32 MiB` or `1 MiB × live task count`. The archive `archived_tasks_fts` branch is lighter because archive writes are mostly append-only: `merge` every 8th tick, `optimize` every 24th tick, and `rebuild` above `64 MiB` or `512 KiB × archived row count`. Each branch is independently guarded by `fts5Available` and emits `task:fts-maintenance` run-audit telemetry with distinct `target` values (`tasks_fts` vs `archived_tasks_fts`). - - AI merge clean-room worktrees are created under the configured worktrees directory's hidden container, `/.ai-merge/`, as `fusion-ai-merge-fn--` detached worktrees. When that container is repo-local, its relative path is added to the repo's local git exclude when possible (alongside the legacy `.fusion/ai-merge/` entry) so an in-flight clean room does not dirty the integration checkout. Inline cleanup runs from `runAiMerge`'s clean-room `finally` for successful lands, empty/no-op finalization, concurrent-advance retries, and thrown/aborted merges. Cleanup canonicalizes the path, attempts `git worktree remove --force`, always falls back to filesystem removal, then runs `git worktree prune` so stale or partial registrations (including `git worktree add` failures) do not dangle. Cleanup emits `merge:ai-worktree-cleanup` audit events for git-remove, fs-rm, and prune phases; benign already-absent/de-registered paths are treated as idempotent success, while genuine filesystem-removal failures are logged/audited with `success: false` rather than silently swallowed. + - AI merge clean-room worktrees are created under the configured worktrees directory's hidden container, `/.ai-merge/`, as `fusion-ai-merge-fn--` detached worktrees. When that container is repo-local, its relative path is added to the repo's local git exclude when possible (alongside the legacy `.fusion/ai-merge/` entry) so an in-flight clean room does not dirty the integration checkout. After `git worktree add` and before the merge/review loop, `runAiMerge` bootstraps the clean room with the shared merge dependency-sync helper: a configured `worktreeInitCommand` is authoritative and always runs, while unset settings infer `pnpm`/`npm`/`yarn`/`bun` installs from lockfiles and can skip only when the `node_modules/.fusion-install-marker` hash still matches. Failures and aborts hard-stop the AI merge before merge agents or verification run, and `merge:ai-deps-sync` records the command, skip state, and duration. Inline cleanup runs from `runAiMerge`'s clean-room `finally` for successful lands, empty/no-op finalization, concurrent-advance retries, and thrown/aborted merges. Cleanup canonicalizes the path, attempts `git worktree remove --force`, always falls back to filesystem removal, then runs `git worktree prune` so stale or partial registrations (including `git worktree add` failures) do not dangle. Cleanup emits `merge:ai-worktree-cleanup` audit events for git-remove, fs-rm, and prune phases; benign already-absent/de-registered paths are treated as idempotent success, while genuine filesystem-removal failures are logged/audited with `success: false` rather than silently swallowed. - Worktrees-dir sweeps that list direct children of `` (pool idle scan, orphan cleanup/reap, self-healing unregistered-orphan reap, and cap enforcement) must exclude the `.ai-merge` container by name; those one-level sweeps never inspect or recycle clean rooms beneath it. Batch 1 sweeps stale AI merge clean-room worktrees under the new `/.ai-merge/` root and still scans legacy `.fusion/ai-merge/` plus legacy `tmpdir()` locations for pre-relocation leftovers; candidates are bounded to names starting with `fusion-ai-merge-`. `runAiMerge` registers each live clean-room worktree in `activeSessionRegistry` with kind `ai-merge` as soon as the directory exists and keeps both raw and canonical paths registered for the duration of the merge, so the dedicated periodic sweep and pre-merge prune defer when either path is active (including concurrent same-task merge attempts). The default age gate is 2 hours; task-aware cleanup uses a 10-minute grace period for `done`/`archived` tasks and for genuinely missing/deleted task rows, and every removal path is clamped by the same 10-minute minimum-age floor so a freshly created worktree is never reaped. Transient `getTask` lookup failures (for example SQLite busy/parse errors) are not treated as deletion evidence; they log a warning, emit `lookup-error` only if eventually removed, and retain the conservative 2-hour gate. The sweep canonicalizes paths before checking `activeSessionRegistry`, attempts `git worktree remove --force ` before filesystem removal, runs `git worktree prune` after cleanup attempts, and emits `worktree:tempdir-sweep` run-audit telemetry for removal attempts and failures. Fresh directories, active-session paths, and individual removal failures are skipped/logged without aborting the maintenance cycle. - `recoverGhostReviewTasks()` is a fallback only for idle, non-terminal `in-review` states. Terminal/actionable states (notably `status: "failed"`) are preserved and **not** auto-kicked back to `todo`. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 80b942e2fc..daeeed0dcb 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -373,7 +373,7 @@ Sandbox backend precedence is: | `pushAfterMerge` | `boolean` | `false` | Auto-push to remote after successful direct merge. Includes pulling latest and AI conflict resolution. | | `pushRemote` | `string` | `"origin"` | Git remote (and optional branch) to push to after merge. | -| `worktreeInitCommand` | `string` | `undefined` | Shell command run after worktree creation and again to bootstrap the merge worktree before AI merge verification. Useful for project-specific setup beyond package install (for example `pnpm install --frozen-lockfile`, `cp .env.local .env`, or codegen/bootstrap scripts). | +| `worktreeInitCommand` | `string` | `undefined` | Shell command run after task worktree creation and in temporary merge worktrees before merge/review verification. In standalone AI merge, this runs inside each fresh `fusion-ai-merge-*` clean-room worktree after `git worktree add`; when unset, Fusion infers a package-manager install from the lockfile and may skip only when the install marker matches. Useful for project-specific setup beyond package install (for example `pnpm install --frozen-lockfile`, `cp .env.local .env`, or codegen/bootstrap scripts). | | `testCommand` | `string` | `undefined` | Merge-time test command (hard gate). When unset, Fusion auto-detects from lockfile. | | `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). | | `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. | diff --git a/packages/engine/src/__tests__/executor-step-session.test.ts b/packages/engine/src/__tests__/executor-step-session.test.ts index e52f7faa51..a8158d3351 100644 --- a/packages/engine/src/__tests__/executor-step-session.test.ts +++ b/packages/engine/src/__tests__/executor-step-session.test.ts @@ -475,7 +475,10 @@ describe("Workflow Steps Execution", () => { expect(secondCall[0].tools).toBe("readonly"); expect(secondCall[0].systemPrompt).toContain("Docs Review"); expect(secondCall[0].systemPrompt).toContain("Review all docs and verify they are complete."); - expect(secondCall[0].taskEnv).toEqual(mockedCreateFnAgent.mock.calls[0][0].taskEnv); + expect(secondCall[0].taskEnv).toEqual({ + ...mockedCreateFnAgent.mock.calls[0][0].taskEnv, + FUSION_WORKFLOW_STEP: "1", + }); // Task should move to in-review expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); diff --git a/packages/engine/src/__tests__/merger-ai-dependency-install.slow.test.ts b/packages/engine/src/__tests__/merger-ai-dependency-install.slow.test.ts new file mode 100644 index 0000000000..1f6b18b957 --- /dev/null +++ b/packages/engine/src/__tests__/merger-ai-dependency-install.slow.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect, vi, afterAll } from "vitest"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +import { runAiMerge } from "../merger-ai.js"; +import { computeLockfileHash, INSTALL_MARKER_RELPATH } from "../merge-dependency-sync.js"; + +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 git(cwd: string, args: string): string { + return execSync(`git ${args}`, { cwd, encoding: "utf-8" }).trim(); +} + +function initRepoWithBranch(): { dir: string } { + const dir = mkdtempSync(join(tmpdir(), "fusion-ai-merge-deps-test-")); + tracked.add(dir); + git(dir, "init -q -b main"); + git(dir, "config user.email t@t.t"); + git(dir, "config user.name t"); + writeFileSync(join(dir, "base.txt"), "base\n"); + git(dir, "add -A"); + git(dir, "commit -q -m base"); + git(dir, "checkout -q -b fusion/fn-1"); + writeFileSync(join(dir, "feature.txt"), "feature work\n"); + git(dir, "add -A"); + git(dir, "commit -q -m 'feat: work'"); + git(dir, "checkout -q main"); + return { dir }; +} + +function makeStore(settingsOverrides: Record = {}) { + const task: any = { + id: "FN-1", + column: "in-review", + status: null, + branch: "fusion/fn-1", + worktree: null, + title: "do the thing", + steps: [], + }; + const store: any = { + getTask: vi.fn(async () => task), + getSettings: vi.fn(async () => ({ merger: { mode: "ai", maxReviewPasses: 1 }, ...settingsOverrides })), + updateTask: vi.fn(async (_id: string, patch: Record) => { Object.assign(task, patch); return task; }), + moveTask: vi.fn(async (_id: string, column: string) => { task.column = column; return task; }), + emit: vi.fn(), + logEntry: vi.fn(async () => undefined), + appendAgentLog: vi.fn(async () => undefined), + }; + return store; +} + +function realMergeAgent(branch = "fusion/fn-1") { + return vi.fn(async (cwd: string) => { + execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" }); + execSync("git add -A", { cwd, stdio: "pipe" }); + execSync('git commit -q -m "squash: feature"', { cwd, stdio: "pipe" }); + }); +} + +function nodeAppendCwdCommand(): string { + return `node -e "require('fs').appendFileSync(process.env.FN_INSTALL_LOG, process.cwd() + '\\n')"`; +} + +function makeInstallLog(): string { + const dir = mkdtempSync(join(tmpdir(), "fusion-ai-install-log-")); + tracked.add(dir); + return join(dir, "install.log"); +} + +function readInstallLog(path: string): string[] { + if (!existsSync(path)) return []; + return readFileSync(path, "utf-8").trim().split("\n").filter(Boolean); +} + +function installFakePackageManagerBins(_dir: string): string { + const binDir = mkdtempSync(join(tmpdir(), "fusion-ai-fake-bin-")); + tracked.add(binDir); + mkdirSync(binDir, { recursive: true }); + for (const bin of ["pnpm", "npm", "yarn", "bun"]) { + const script = join(binDir, bin); + writeFileSync(script, `#!/usr/bin/env node\nconst fs = require('fs');\nfs.appendFileSync(process.env.FN_INSTALL_LOG, JSON.stringify({ bin: ${JSON.stringify(bin)}, args: process.argv.slice(2), cwd: process.cwd() }) + '\\n');\nprocess.exit(Number(process.env.FN_INSTALL_EXIT || 0));\n`); + chmodSync(script, 0o755); + } + const previousPath = process.env.PATH ?? ""; + process.env.PATH = `${binDir}${delimiter}${previousPath}`; + return previousPath; +} + +function commitWarmInstallMarker(dir: string): void { + const hash = computeLockfileHash(dir); + if (!hash) throw new Error("expected lockfile hash"); + mkdirSync(join(dir, "node_modules"), { recursive: true }); + writeFileSync(join(dir, INSTALL_MARKER_RELPATH), hash); + execSync(`git add -f ${INSTALL_MARKER_RELPATH}`, { cwd: dir, stdio: "pipe" }); + git(dir, "commit -q -m 'record install marker'"); +} + +describe("runAiMerge dependency install", () => { + it("runs configured worktreeInitCommand in the AI-merge clean room before merge agents", async () => { + const { dir } = initRepoWithBranch(); + const installLog = makeInstallLog(); + const store = makeStore({ worktreeInitCommand: nodeAppendCwdCommand() }); + const mergeAgent = realMergeAgent(); + + process.env.FN_INSTALL_LOG = installLog; + try { + await runAiMerge(store, dir, "FN-1", { manual: true }, { + mergeAgent, + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + } finally { + delete process.env.FN_INSTALL_LOG; + } + + const installCwds = readInstallLog(installLog); + expect(installCwds).toHaveLength(1); + expect(installCwds[0]).toMatch(/fusion-ai-merge-fn-1-/); + expect(mergeAgent).toHaveBeenCalledTimes(1); + const timingLogOrder = store.appendAgentLog.mock.invocationCallOrder.find((_: number, index: number) => + String(store.appendAgentLog.mock.calls[index]?.[1]).includes("[timing] AI merge dependency sync completed"), + ); + expect(timingLogOrder).toBeLessThan(mergeAgent.mock.invocationCallOrder[0]); + }); + + it("infers lockfile install commands in the AI-merge clean room", async () => { + for (const testCase of [ + { lockfile: "pnpm-lock.yaml", expectedBin: "pnpm", expectedArgs: ["install", "--frozen-lockfile"] }, + { lockfile: "package-lock.json", expectedBin: "npm", expectedArgs: ["install"] }, + ]) { + const { dir } = initRepoWithBranch(); + writeFileSync(join(dir, testCase.lockfile), "lock\n"); + git(dir, `add ${testCase.lockfile}`); + git(dir, `commit -q -m 'add ${testCase.lockfile}'`); + const installLog = makeInstallLog(); + const previousPath = installFakePackageManagerBins(dir); + process.env.FN_INSTALL_LOG = installLog; + try { + await runAiMerge(makeStore(), dir, "FN-1", { manual: true }, { + mergeAgent: realMergeAgent(), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + } finally { + process.env.PATH = previousPath; + delete process.env.FN_INSTALL_LOG; + } + + const [entry] = readInstallLog(installLog).map((line) => JSON.parse(line)); + expect(entry).toEqual(expect.objectContaining({ bin: testCase.expectedBin, args: testCase.expectedArgs })); + expect(entry.cwd).toMatch(/fusion-ai-merge-fn-1-/); + } + }); + + it("proceeds without install when no configured command or known lockfile exists", async () => { + const { dir } = initRepoWithBranch(); + const store = makeStore(); + const mergeAgent = realMergeAgent(); + + await runAiMerge(store, dir, "FN-1", { manual: true }, { + mergeAgent, + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + + expect(mergeAgent).toHaveBeenCalledTimes(1); + expect(store.appendAgentLog).toHaveBeenCalledWith( + "FN-1", + expect.stringContaining("(no command)"), + "text", + undefined, + "merger", + ); + }); + + it("skips inferred installs on a matching marker but never skips configured init commands", async () => { + const { dir } = initRepoWithBranch(); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lock\n"); + git(dir, "add pnpm-lock.yaml"); + git(dir, "commit -q -m 'add pnpm lock'"); + commitWarmInstallMarker(dir); + const installLog = makeInstallLog(); + const previousPath = installFakePackageManagerBins(dir); + process.env.FN_INSTALL_LOG = installLog; + try { + await runAiMerge(makeStore(), dir, "FN-1", { manual: true }, { + mergeAgent: realMergeAgent(), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + } finally { + process.env.PATH = previousPath; + delete process.env.FN_INSTALL_LOG; + } + expect(readInstallLog(installLog)).toHaveLength(0); + + const { dir: configuredDir } = initRepoWithBranch(); + writeFileSync(join(configuredDir, "pnpm-lock.yaml"), "lock\n"); + git(configuredDir, "add pnpm-lock.yaml"); + git(configuredDir, "commit -q -m 'add pnpm lock'"); + commitWarmInstallMarker(configuredDir); + const configuredLog = makeInstallLog(); + process.env.FN_INSTALL_LOG = configuredLog; + try { + await runAiMerge(makeStore({ worktreeInitCommand: nodeAppendCwdCommand() }), configuredDir, "FN-1", { manual: true }, { + mergeAgent: realMergeAgent(), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + } finally { + delete process.env.FN_INSTALL_LOG; + } + expect(readInstallLog(configuredLog)).toHaveLength(1); + }); + + it("hard-fails configured install failures and propagates aborts", async () => { + const { dir } = initRepoWithBranch(); + const mergeAgent = realMergeAgent(); + + await expect(runAiMerge(makeStore({ worktreeInitCommand: `node -e "process.stderr.write('install failed'); process.exit(7)"` }), dir, "FN-1", { manual: true }, { + mergeAgent, + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + })).rejects.toThrow(/Dependency sync failed.*install failed/); + expect(mergeAgent).not.toHaveBeenCalled(); + + const { dir: abortDir } = initRepoWithBranch(); + const controller = new AbortController(); + const abortStore = makeStore({ worktreeInitCommand: `node -e "process.exit(0)"` }); + abortStore.appendAgentLog.mockImplementation(async (_id: string, message: string) => { + if (String(message).includes("Syncing dependencies")) controller.abort(); + }); + await expect(runAiMerge(abortStore, abortDir, "FN-1", { manual: true, signal: controller.signal }, { + mergeAgent: realMergeAgent(), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + })).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("runs dependency install again after a concurrent integration advance rebuild", async () => { + const { dir } = initRepoWithBranch(); + const installLog = makeInstallLog(); + let attempts = 0; + const mergeAgent = vi.fn(async (cwd: string) => { + await realMergeAgent()(cwd); + attempts++; + if (attempts === 1) { + writeFileSync(join(dir, "race.txt"), "race\n"); + git(dir, "add race.txt"); + git(dir, "commit -q -m 'main advanced concurrently'"); + } + }); + + process.env.FN_INSTALL_LOG = installLog; + try { + await runAiMerge(makeStore({ worktreeInitCommand: nodeAppendCwdCommand() }), dir, "FN-1", { manual: true }, { + mergeAgent, + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + } finally { + delete process.env.FN_INSTALL_LOG; + } + + expect(mergeAgent).toHaveBeenCalledTimes(2); + expect(readInstallLog(installLog)).toHaveLength(2); + }); +}); diff --git a/packages/engine/src/merge-dependency-sync.ts b/packages/engine/src/merge-dependency-sync.ts new file mode 100644 index 0000000000..32eda02aae --- /dev/null +++ b/packages/engine/src/merge-dependency-sync.ts @@ -0,0 +1,144 @@ +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"; + durationMs: number; +} + +export interface InstallWorktreeDependenciesOptions { + cwd: string; + settings?: Settings | null; + taskId: string; + signal?: AbortSignal; + log?: (message: string) => Promise | 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 | 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; +} + +/** + * 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 { + 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", 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", + durationMs: Date.now() - startedAt, + }; + } + + throwIfDependencySyncAborted(signal, taskId); + logger?.log?.(`${taskId}: syncing dependencies ${context}`); + await log?.(`Syncing dependencies ${context}: ${installCommand}`); + + try { + await execAsync(installCommand, { + cwd, + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + timeout: INSTALL_TIMEOUT_MS, + }); + throwIfDependencySyncAborted(signal, taskId); + if (lockHash) writeInstallMarker(cwd, lockHash); + return { installCommand, configured, skipped: 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()); + } +} diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index caf959f102..d9ca34bb44 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -63,6 +63,7 @@ import { accumulateSessionTokenUsage } from "./session-token-usage.js"; import { createRunAuditor, generateSyntheticRunId, type RunAuditor } from "./run-audit.js"; import { createLogger } from "./logger.js"; import { captureSingleCommitLandedMetadata, type MergerOptions } from "./merger.js"; +import { installWorktreeDependencies } from "./merge-dependency-sync.js"; import { activeSessionRegistry } from "./active-session-registry.js"; import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "./self-healing.js"; import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath } from "./worktree-paths.js"; @@ -1080,6 +1081,36 @@ export async function runAiMerge( await audit.git({ type: "merge:ai-clean-room", target: integrationBranch, metadata: { taskId, tipSha, mergeRoot } }); await log(`AI merge: merging ${branch} into ${integrationBranch} (clean room at ${short(tipSha)})${advanceRetries ? ` — retry ${advanceRetries} after concurrent advance` : ""}`); + /* + * FNXC:AIMerge 2026-06-13-20:32: + * The detached AI-merge clean room is rebuilt from the integration tip and starts without workspace dependencies. Hard-fail configured or inferred install failures so verification cannot silently run against an uninstalled checkout; aborts propagate before merge agents run. + */ + const depsSyncStartedAt = Date.now(); + const depsSyncResult = await installWorktreeDependencies({ + cwd: canonicalMergeRoot, + settings, + taskId, + signal: options.signal, + context: "for AI merge clean room", + logger: aiMergeLog, + log, + }); + await audit.git({ + type: "merge:ai-deps-sync", + target: integrationBranch, + metadata: { + taskId, + tipSha, + mergeRoot: canonicalMergeRoot, + installCommand: depsSyncResult.installCommand, + configured: depsSyncResult.configured, + skipped: depsSyncResult.skipped, + skipReason: depsSyncResult.skipReason, + durationMs: depsSyncResult.durationMs, + }, + }); + await log(`[timing] AI merge dependency sync completed in ${Date.now() - depsSyncStartedAt}ms${depsSyncResult.installCommand ? ` (${depsSyncResult.skipped ? "skipped" : "ran"}: ${depsSyncResult.installCommand})` : " (no command)"}`); + // 2 + 3. Merge + review loop (corrective passes). const squashSha = await mergeAndReview({ mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId, diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 1cb1390870..a4d5cf7852 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -53,6 +53,16 @@ export { import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync, renameSync } from "node:fs"; import { createHash } from "node:crypto"; import { join } from "node:path"; +import { + computeLockfileHash, + getConfiguredWorktreeInitCommand, + getDependencySyncCommand, + hasInstallState, + installWorktreeDependencies, + INSTALL_MARKER_RELPATH, + readInstallMarker, + writeInstallMarker, +} from "./merge-dependency-sync.js"; import { resolveTaskWorktreePath } from "./worktree-paths.js"; import { resolveTaskWorkingBranch } from "./worktree-names.js"; import { @@ -499,9 +509,15 @@ export async function getStagedFiles(cwd: string): Promise { } } -export function hasInstallState(rootDir: string): boolean { - return existsSync(join(rootDir, "node_modules")) || existsSync(join(rootDir, ".pnp.cjs")); -} +export { + computeLockfileHash, + getConfiguredWorktreeInitCommand, + getDependencySyncCommand, + hasInstallState, + INSTALL_MARKER_RELPATH, + readInstallMarker, + writeInstallMarker, +}; export function shouldSyncDependenciesForMerge( stagedFiles: string[], @@ -515,23 +531,6 @@ export function shouldSyncDependenciesForMerge( ); } -function getConfiguredWorktreeInitCommand(settings?: Pick | null): string | null { - const trimmed = settings?.worktreeInitCommand?.trim(); - return trimmed ? trimmed : null; -} - -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; -} - type MergeWorktreeCommandResult = Awaited>; const POST_MERGE_INIT_OUTCOME_MAX_CHARS = 2_000; @@ -570,40 +569,6 @@ function formatPostMergeInitFailureOutcome(initResult: MergeWorktreeCommandResul return fallback.length > 0 ? fallback : "Command failed"; } -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"]; - -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; -} - -function readInstallMarker(rootDir: string): string | null { - try { - const value = readFileSync(join(rootDir, INSTALL_MARKER_RELPATH), "utf-8").trim(); - return value || null; - } catch { - return null; - } -} - -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. - } -} - async function syncDependenciesForMerge( store: TaskStore, rootDir: string, @@ -611,44 +576,15 @@ async function syncDependenciesForMerge( settings?: Settings | null, signal?: AbortSignal, ): Promise { - const configuredCommand = getConfiguredWorktreeInitCommand(settings); - const installCommand = getDependencySyncCommand(rootDir, settings); - if (!installCommand) return; - - const shouldUseInstallMarker = configuredCommand === null; - - // Skip the install if node_modules is present and the lockfile content - // matches the hash recorded after the last successful install. Caller's - // shouldSyncDependenciesForMerge gate already filters most no-ops; this - // covers the case where package.json (but not the lockfile) is staged, and - // the case where multiple merge attempts hit the same worktree in a row. - const lockHash = shouldUseInstallMarker ? computeLockfileHash(rootDir) : null; - if (lockHash && hasInstallState(rootDir) && readInstallMarker(rootDir) === lockHash) { - mergerLog.log(`${taskId}: skipping dependency sync (lockfile unchanged since last install)`); - await store.logEntry( - taskId, - `Skipping dependency sync: lockfile hash matches last successful ${installCommand}`, - ); - return; - } - - throwIfAborted(signal, taskId); - mergerLog.log(`${taskId}: syncing dependencies before merge verification`); - await store.logEntry(taskId, `Syncing dependencies before merge verification: ${installCommand}`); - try { - await execAsync(installCommand, { - cwd: rootDir, - encoding: "utf-8", - maxBuffer: 10 * 1024 * 1024, - timeout: 300_000, - }); - throwIfAborted(signal, taskId); - if (lockHash) writeInstallMarker(rootDir, lockHash); - } catch (error: any) { - throwIfAborted(signal, taskId); - const details = error?.stderr || error?.stdout || error?.message || String(error); - throw new Error(`Dependency sync failed for ${taskId}: ${details}`.trim()); - } + await installWorktreeDependencies({ + cwd: rootDir, + settings, + taskId, + signal, + context: "before merge verification", + logger: mergerLog, + log: async (message) => { await store.logEntry(taskId, message); }, + }); } // ── Default test command inference ──────────────────────────────────── diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index 611d4dea4e..066e8d0746 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -171,6 +171,7 @@ export type GitMutationType = | "merge:ai-review-landed-with-concerns" | "merge:ai-local-sync" | "merge:ai-landed" + | "merge:ai-deps-sync" /** * Metadata shape: * ```ts