FN-6420: run dependency sync in AI merge worktrees
Run configured or inferred dependency installs before AI merge verification uses the clean-room worktree. - Add shared dependency-sync helpers with install marker caching for inferred lockfile installs. - Run dependency sync during AI clean-room merges and audit/log the command, skip reason, and duration. - Reuse the helper from merger paths while documenting the new behavior and covering install success/failure cases. Files changed: .changeset/fn-6420-ai-merge-dependency-sync.md | 5 + docs/architecture.md | 2 +- docs/settings-reference.md | 2 +- .../src/__tests__/executor-step-session.test.ts | 5 +- .../merger-ai-dependency-install.slow.test.ts | 269 +++++++++++++++++++++ packages/engine/src/merge-dependency-sync.ts | 144 +++++++++++ packages/engine/src/merger-ai.ts | 31 +++ packages/engine/src/merger.ts | 120 +++------ packages/engine/src/run-audit.ts | 1 + 9 files changed, 484 insertions(+), 95 deletions(-) Fusion-Task-Id: FN-6420 Fusion-Task-Lineage: 49353cb6-4953-4670-b620-a90331f048dc
This commit is contained in:
5
.changeset/fn-6420-ai-merge-dependency-sync.md
Normal file
5
.changeset/fn-6420-ai-merge-dependency-sync.md
Normal file
@@ -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.
|
||||
@@ -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, `<worktreesDir>/.ai-merge/`, as `fusion-ai-merge-fn-<id>-<random>` 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, `<worktreesDir>/.ai-merge/`, as `fusion-ai-merge-fn-<id>-<random>` 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 `<worktreesDir>` (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 `<worktreesDir>/.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 <path>` 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`.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string>();
|
||||
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<string, unknown> = {}) {
|
||||
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<string, unknown>) => { 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);
|
||||
});
|
||||
});
|
||||
144
packages/engine/src/merge-dependency-sync.ts
Normal file
144
packages/engine/src/merge-dependency-sync.ts
Normal file
@@ -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> | 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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", 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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
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<Settings, "worktreeInitCommand"> | 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<ReturnType<typeof runConfiguredMergeWorktreeCommand>>;
|
||||
|
||||
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<void> {
|
||||
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 ────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user