diff --git a/packages/engine/src/__tests__/merge-dependency-sync-lockfile-heal.test.ts b/packages/engine/src/__tests__/merge-dependency-sync-lockfile-heal.test.ts index 7238cd7a62..b03021cf73 100644 --- a/packages/engine/src/__tests__/merge-dependency-sync-lockfile-heal.test.ts +++ b/packages/engine/src/__tests__/merge-dependency-sync-lockfile-heal.test.ts @@ -135,3 +135,127 @@ describe("installWorktreeDependencies lockfile auto-heal", () => { expect(readLog(logPath)).toEqual([["install", "--frozen-lockfile"]]); }); }); + +/* +FNXC:MergeDeps 2026-07-17-12:00: +Env passthrough coverage for installWorktreeDependencies. The explicit forwarding of corepack/pnpm +env vars mirrors mission-verification.ts VERIFICATION_ENV_ALLOWLIST so pnpm is resolvable even when +the engine process starts without full shell initialization. +*/ +describe("installWorktreeDependencies env passthrough", () => { + /** + * Install a fake `pnpm` that logs selected env vars to a file so we can assert + * the child process receives the expected environment. Writes a JSON object with + * the requested env var values. + */ + function installEnvLoggingPnpm(envVars: string[], logPath: string): string { + const binDir = tmp("fusion-env-fake-bin-"); + const script = join(binDir, "pnpm"); + const varsJson = JSON.stringify(envVars); + writeFileSync( + script, + `#!/usr/bin/env node +const fs = require('fs'); +const vars = ${varsJson}; +const env = {}; +for (let v of vars) env[v] = process.env[v]; +fs.writeFileSync(${JSON.stringify(logPath)}, JSON.stringify(env)); +`, + ); + chmodSync(script, 0o755); + const previousPath = process.env.PATH ?? ""; + process.env.PATH = `${binDir}${delimiter}${previousPath}`; + return previousPath; + } + + it("passes COREPACK_HOME, PNPM_HOME, and npm_config_registry through to exec", async () => { + // Set the env vars so the passthrough has values to forward + const origCorepackHome = process.env.COREPACK_HOME; + const origPnpmHome = process.env.PNPM_HOME; + const origNpmRegistry = process.env.npm_config_registry; + process.env.COREPACK_HOME = "/tmp/fake-corepack"; + process.env.PNPM_HOME = "/tmp/fake-pnpm"; + process.env.npm_config_registry = "https://fake.registry/"; + + const dir = tmp("fusion-env-repo-"); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfile: {}\n"); + + const logPath = join(tmp("fusion-env-log-"), "env.json"); + const previousPath = installEnvLoggingPnpm( + ["COREPACK_HOME", "PNPM_HOME", "npm_config_registry"], + logPath, + ); + try { + await installWorktreeDependencies({ cwd: dir, taskId: "FN-1" }); + + const captured = JSON.parse(readFileSync(logPath, "utf-8")); + expect(captured.COREPACK_HOME).toBe("/tmp/fake-corepack"); + expect(captured.PNPM_HOME).toBe("/tmp/fake-pnpm"); + expect(captured.npm_config_registry).toBe("https://fake.registry/"); + } finally { + process.env.PATH = previousPath; + process.env.COREPACK_HOME = origCorepackHome; + process.env.PNPM_HOME = origPnpmHome; + process.env.npm_config_registry = origNpmRegistry; + } + }); + + it("does NOT override or strip existing env vars like PATH", async () => { + const dir = tmp("fusion-env-repo2-"); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfile: {}\n"); + + const logPath = join(tmp("fusion-env-log2-"), "env.json"); + const previousPath = installEnvLoggingPnpm( + ["PATH", "HOME", "SHELL"], + logPath, + ); + try { + await installWorktreeDependencies({ cwd: dir, taskId: "FN-1" }); + + const captured = JSON.parse(readFileSync(logPath, "utf-8")); + // PATH should still contain the fake bin dir AND the real PATH + expect(captured.PATH).toContain("fusion-env-fake-bin-"); + // HOME and SHELL should be preserved from process.env + expect(captured.HOME).toBe(process.env.HOME); + expect(captured.SHELL).toBe(process.env.SHELL); + } finally { + process.env.PATH = previousPath; + } + }); + + it("handles undefined corepack/pnpm env vars gracefully", async () => { + // Clear the env vars + const origCorepackHome = process.env.COREPACK_HOME; + const origPnpmHome = process.env.PNPM_HOME; + const origNpmRegistry = process.env.npm_config_registry; + delete process.env.COREPACK_HOME; + delete process.env.PNPM_HOME; + delete process.env.npm_config_registry; + + const dir = tmp("fusion-env-repo3-"); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfile: {}\n"); + + const logPath = join(tmp("fusion-env-log3-"), "env.json"); + const previousPath = installEnvLoggingPnpm( + ["COREPACK_HOME", "PNPM_HOME", "npm_config_registry", "PATH"], + logPath, + ); + try { + await installWorktreeDependencies({ cwd: dir, taskId: "FN-1" }); + + const captured = JSON.parse(readFileSync(logPath, "utf-8")); + // When the env vars are undefined, they should be undefined in the child too + // (not set to empty string or some sentinel) + expect(captured.COREPACK_HOME).toBeUndefined(); + expect(captured.PNPM_HOME).toBeUndefined(); + expect(captured.npm_config_registry).toBeUndefined(); + // PATH should still be present + expect(captured.PATH).toBeDefined(); + } finally { + process.env.PATH = previousPath; + process.env.COREPACK_HOME = origCorepackHome; + process.env.PNPM_HOME = origPnpmHome; + process.env.npm_config_registry = origNpmRegistry; + } + }); +}); diff --git a/packages/engine/src/__tests__/merger-ai-no-commits-deps-skip.test.ts b/packages/engine/src/__tests__/merger-ai-no-commits-deps-skip.test.ts new file mode 100644 index 0000000000..11aeac9a73 --- /dev/null +++ b/packages/engine/src/__tests__/merger-ai-no-commits-deps-skip.test.ts @@ -0,0 +1,261 @@ +/* +FNXC:MergeNoCommits 2026-07-17-12:00: +No-commits tasks (audit, documentation, decision-only) have no code changes to install or build. +The clean-room dependency sync must be skipped entirely to avoid "pnpm: command not found" failures +when pnpm is not resolvable in the engine process environment. We drive the REAL landOneRepo against +a REAL git fixture with injected agents (the squash is a plain `git merge --squash`, no AI), and +MOCK installWorktreeDependencies so we can assert call counts without real/slow/networked npm runs +(FN-5048). +*/ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { Task, TaskStore } from "@fusion/core"; + +vi.mock("../merge-dependency-sync.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, installWorktreeDependencies: vi.fn() }; +}); + +import { installWorktreeDependencies } from "../merge-dependency-sync.js"; +import { landOneRepo } from "../merger-ai.js"; +import { createRunAuditor, generateSyntheticRunId } from "../run-audit.js"; + +// --------------------------------------------------------------------------- +// Git helpers +// --------------------------------------------------------------------------- + +function hasGitInstall(): boolean { + try { + execSync("git --version", { stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +const describeIfGit = hasGitInstall() ? describe : describe.skip; +const TASK_ID = "RUFU-018"; +const BRANCH = "fusion/rufu-018"; + +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +interface RepoFixture { + rootDir: string; + mainSha: string; + cleanup(): void; +} + +/** Create a single real git repo with an initial commit on main, then a branch + * with one commit. Returns the fixture. */ +function createRepoFixture(withChanges: boolean): RepoFixture { + const rootDir = mkdtempSync(path.join(os.tmpdir(), "fusion-no-commits-")); + execSync("git init -b main", { cwd: rootDir, stdio: "pipe" }); + configureIdentity(rootDir); + writeFileSync(path.join(rootDir, "README.md"), "# Test\n", "utf-8"); + execSync("git add README.md && git commit -m 'init'", { cwd: rootDir, stdio: "pipe" }); + + // Create the task branch + execSync(`git checkout -b ${BRANCH}`, { cwd: rootDir, stdio: "pipe" }); + if (withChanges) { + writeFileSync(path.join(rootDir, "feature.txt"), "feature work\n", "utf-8"); + execSync("git add feature.txt && git commit -m 'feat: add feature'", { cwd: rootDir, stdio: "pipe" }); + } else { + // Commit that adds no code (e.g. a readme-only doc change), so the branch is ahead + writeFileSync(path.join(rootDir, "README.md"), "# Test\n\nUpdated\n", "utf-8"); + execSync("git add README.md && git commit -m 'docs: update readme'", { cwd: rootDir, stdio: "pipe" }); + } + const mainSha = execSync("git rev-parse main", { cwd: rootDir, encoding: "utf-8" }).trim(); + execSync("git checkout main", { cwd: rootDir, stdio: "pipe" }); + + return { + rootDir, + mainSha, + cleanup: () => { try { rmSync(rootDir, { recursive: true, force: true }); } catch { /* best effort */ } }, + }; +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +function createStore(): TaskStore & { logs: string[] } { + const emitter = new EventEmitter(); + const logs: string[] = []; + return Object.assign(emitter, { + logs, + getSettings: vi.fn().mockResolvedValue({ autoMerge: false }), + updateTask: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn((_id: string, message: string) => { logs.push(message); return Promise.resolve(undefined); }), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + getTask: vi.fn().mockResolvedValue({ + id: TASK_ID, column: "in-review", branch: BRANCH, + comments: [], steeringComments: [], steps: [], log: [], + }), + moveTask: vi.fn().mockResolvedValue({ id: TASK_ID, column: "done" } as Task), + upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined), + accumulateTokenUsage: vi.fn().mockResolvedValue(undefined), + }) as unknown as TaskStore & { logs: string[] }; +} + +// --------------------------------------------------------------------------- +// Injected agents (plain git merge, no AI) +// --------------------------------------------------------------------------- + +const squashMergeAgent = async (cwd: string): Promise => { + configureIdentity(cwd); + execSync(`git merge --squash ${BRANCH}`, { cwd, stdio: "pipe" }); + const staged = execSync("git diff --cached --name-only", { cwd, encoding: "utf-8" }).trim(); + if (staged.length === 0) return; + execSync(`git commit -m "${BRANCH}: squashed"`, { cwd, stdio: "pipe" }); +}; +const approveReviewAgent = async (): Promise => "REVIEW_VERDICT: approve"; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describeIfGit("landOneRepo no-commits dep-sync skip", () => { + let fx: RepoFixture; + let store: TaskStore & { logs: string[] }; + let audit: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + fx?.cleanup(); + }); + + it("skips installWorktreeDependencies entirely when noCommitsExpected: true", async () => { + fx = createRepoFixture(true); // branch has actual changes + store = createStore(); + audit = createRunAuditor(store, { + runId: generateSyntheticRunId("ai-merge", TASK_ID), + agentId: "merger", + taskId: TASK_ID, + phase: "merge", + }); + + const result = await landOneRepo(fx.rootDir, BRANCH, "main", { + taskId: TASK_ID, + settings: { autoMerge: false } as never, + audit, + log: async () => undefined, + setStatus: async () => undefined, + maxPasses: 1, + mergeAgent: squashMergeAgent, + reviewAgent: approveReviewAgent, + stashResolveAgent: async () => undefined, + includeTaskId: true, + trailers: [], + store, + noCommitsExpected: true, + }); + + // Dep sync was never called + expect(vi.mocked(installWorktreeDependencies)).not.toHaveBeenCalled(); + // The land still succeeds + expect(result.outcome).toBe("landed"); + }); + + it("still calls installWorktreeDependencies when noCommitsExpected is false", async () => { + fx = createRepoFixture(true); // branch has actual changes + store = createStore(); + audit = createRunAuditor(store, { + runId: generateSyntheticRunId("ai-merge", TASK_ID), + agentId: "merger", + taskId: TASK_ID, + phase: "merge", + }); + + const result = await landOneRepo(fx.rootDir, BRANCH, "main", { + taskId: TASK_ID, + settings: { autoMerge: false } as never, + audit, + log: async () => undefined, + setStatus: async () => undefined, + maxPasses: 1, + mergeAgent: squashMergeAgent, + reviewAgent: approveReviewAgent, + stashResolveAgent: async () => undefined, + includeTaskId: true, + trailers: [], + store, + // noCommitsExpected intentionally omitted (defaults to undefined → false) + }); + + // Dep sync WAS called + expect(vi.mocked(installWorktreeDependencies)).toHaveBeenCalled(); + // The land still succeeds + expect(result.outcome).toBe("landed"); + }); + + it("still calls installWorktreeDependencies when noCommitsExpected is explicitly false", async () => { + fx = createRepoFixture(true); // branch has actual changes + store = createStore(); + audit = createRunAuditor(store, { + runId: generateSyntheticRunId("ai-merge", TASK_ID), + agentId: "merger", + taskId: TASK_ID, + phase: "merge", + }); + + const result = await landOneRepo(fx.rootDir, BRANCH, "main", { + taskId: TASK_ID, + settings: { autoMerge: false } as never, + audit, + log: async () => undefined, + setStatus: async () => undefined, + maxPasses: 1, + mergeAgent: squashMergeAgent, + reviewAgent: approveReviewAgent, + stashResolveAgent: async () => undefined, + includeTaskId: true, + trailers: [], + store, + noCommitsExpected: false, + }); + + // Dep sync WAS called + expect(vi.mocked(installWorktreeDependencies)).toHaveBeenCalled(); + expect(result.outcome).toBe("landed"); + }); + + it("lands successfully with noCommitsExpected: true and actual changes", async () => { + fx = createRepoFixture(true); // branch has actual file changes + store = createStore(); + audit = createRunAuditor(store, { + runId: generateSyntheticRunId("ai-merge", TASK_ID), + agentId: "merger", + taskId: TASK_ID, + phase: "merge", + }); + + const result = await landOneRepo(fx.rootDir, BRANCH, "main", { + taskId: TASK_ID, + settings: { autoMerge: false } as never, + audit, + log: async () => undefined, + setStatus: async () => undefined, + maxPasses: 1, + mergeAgent: squashMergeAgent, + reviewAgent: approveReviewAgent, + stashResolveAgent: async () => undefined, + includeTaskId: true, + trailers: [], + store, + noCommitsExpected: true, + }); + + expect(result.outcome).toBe("landed"); + expect(vi.mocked(installWorktreeDependencies)).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/engine/src/merge-dependency-sync.ts b/packages/engine/src/merge-dependency-sync.ts index 678159151f..72e944243d 100644 --- a/packages/engine/src/merge-dependency-sync.ts +++ b/packages/engine/src/merge-dependency-sync.ts @@ -166,9 +166,25 @@ export async function installWorktreeDependencies(options: InstallWorktreeDepend logger?.log?.(`${taskId}: syncing dependencies ${context}`); await log?.(`Syncing dependencies ${context}: ${installCommand}`); + /* + FNXC:MergeDeps 2026-07-17-12:00: + Pass corepack/pnpm env vars (PNPM_HOME, COREPACK_HOME, npm_config_registry) to the exec + child process — mirrors mission-verification.ts VERIFICATION_ENV_ALLOWLIST so pnpm is + resolvable even when the engine process starts without full shell initialization. Without + these vars, corepack cannot locate its pnpm shim and "pnpm: command not found" occurs. + */ + const resolvedEnv: NodeJS.ProcessEnv = { ...process.env }; + const PNPM_ENV_VARS = ["COREPACK_HOME", "PNPM_HOME", "npm_config_registry"] as const; + for (const key of PNPM_ENV_VARS) { + const value = process.env[key]; + if (value !== undefined) { + resolvedEnv[key] = value; + } + } const runInstall = (command: string): Promise => execAsync(command, { cwd, + env: resolvedEnv, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024, timeout: INSTALL_TIMEOUT_MS, diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 1cb63a8de1..0e1196f8c4 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -763,6 +763,14 @@ export interface LandRepoContext { off, preserving the documented hard-fail for the single-repo land path. */ nonFatalDependencySync?: boolean; + /* + FNXC:MergeNoCommits 2026-07-17-12:00: + When true, the task is expected to produce no code changes (audit, documentation, decision-only). + The clean-room dependency sync is skipped entirely because there are no source changes to install + or build. Avoiding the dep-sync prevents "pnpm: command not found" failures when pnpm is not + resolvable in the engine process environment, and avoids unnecessary work. + */ + noCommitsExpected?: boolean; store: TaskStore; } @@ -883,6 +891,17 @@ export async function landOneRepo( * 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. */ + /* + FNXC:MergeNoCommits 2026-07-17-12:00: + No-commits tasks (audit, documentation, decision-only) have no code changes to install or + build. Skip the entire dependency-sync step in the clean-room worktree to avoid "pnpm: command + not found" when pnpm is not resolvable in the engine process environment. The merge/review + agents still run (they may verify documentation or produce merge metadata); only the + dependency install is skipped. + */ + if (ctx.noCommitsExpected === true) { + await log(`AI merge: skipping dependency sync — no-commits task (no code changes expected)`); + } else { const depsSyncStartedAt = Date.now(); let depsSyncResult: Awaited> | null = null; try { @@ -937,6 +956,7 @@ export async function landOneRepo( }); } await log(`[timing] AI merge dependency sync completed in ${Date.now() - depsSyncStartedAt}ms${depsSyncResult ? (depsSyncResult.installCommand ? ` (${depsSyncResult.skipped ? "skipped" : "ran"}: ${depsSyncResult.installCommand})` : " (no command)") : " (failed — non-fatal, deps unavailable)"}`); + } // 2 + 3. Merge + review loop (corrective passes). const reviewResult = await mergeAndReview({ @@ -1281,6 +1301,8 @@ export async function runAiMerge( mergeAgent, reviewAgent, stashResolveAgent, includeTaskId, trailers, taskTitle, signal: options.signal, allowDirtyLocalCheckoutSync, + // FNXC:MergeNoCommits 2026-07-17-12:00: no-commits tasks skip dependency sync in the clean room + noCommitsExpected: task.noCommitsExpected === true, store, }); @@ -1877,6 +1899,8 @@ export async function landWorkspaceTask( // FNXC:Workspace 2026-06-24-23:50: one sub-repo's dependency-sync failure must not block // landing the others — degrade verification for that repo, still land the git squash. nonFatalDependencySync: true, + // FNXC:MergeNoCommits 2026-07-17-12:00: no-commits tasks skip dependency sync in the clean room + noCommitsExpected: task.noCommitsExpected === true, store, }); if (landResult.outcome === "landed") {