diff --git a/packages/engine/src/__tests__/merger-post-merge.test.ts b/packages/engine/src/__tests__/merger-post-merge.test.ts index 1a2dd2480e..764720d63e 100644 --- a/packages/engine/src/__tests__/merger-post-merge.test.ts +++ b/packages/engine/src/__tests__/merger-post-merge.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mock external dependencies @@ -766,6 +767,161 @@ describe("aiMergeTask — post-merge workflow steps", () => { expect(worktreeRemoveCall).toBeDefined(); }); + it("runs configured worktreeInitCommand in the temporary post-merge worktree", async () => { + const store = createMockStore(); + (store as any).getWorkflowStep = vi.fn().mockResolvedValue({ + id: "WS-001", + name: "Post-merge Notify", + description: "Send notifications after merge", + prompt: "Check merged code.", + phase: "post-merge", + mode: "prompt", + enabled: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + (store.getSettings as ReturnType).mockResolvedValue({ + ...DEFAULT_SETTINGS, + mergeIntegrationWorktree: "cwd-main" as const, + worktreeInitCommand: "pnpm install", + }); + store.getTask = vi.fn().mockResolvedValue({ + id: "FN-050", + title: "Test task", + description: "Test", + column: "in-review", + dependencies: [], + worktree: "/tmp/root/.worktrees/KB-050", + steps: [], + currentStep: 0, + log: [], + enabledWorkflowSteps: ["WS-001"], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + prompt: "# test", + }); + + await aiMergeTask(store, "/tmp/root", "FN-050"); + + const initSpawnCall = mockedSpawn.mock.calls.find((call: any) => String(call[0]) === "pnpm install"); + expect(initSpawnCall).toBeDefined(); + expect(initSpawnCall?.[2]?.cwd).toMatch(/\.worktrees\/post-merge-FN-050-[a-z0-9]+/); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-050", + expect.stringContaining("Post-merge worktree init command completed"), + "pnpm install", + ); + }); + + it("skips post-merge worktree init when worktreeInitCommand is not configured", async () => { + const store = createMockStore(); + (store as any).getWorkflowStep = vi.fn().mockResolvedValue({ + id: "WS-001", + name: "Post-merge Notify", + description: "Send notifications after merge", + prompt: "Check merged code.", + phase: "post-merge", + mode: "prompt", + enabled: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + store.getTask = vi.fn().mockResolvedValue({ + id: "FN-050", + title: "Test task", + description: "Test", + column: "in-review", + dependencies: [], + worktree: "/tmp/root/.worktrees/KB-050", + steps: [], + currentStep: 0, + log: [], + enabledWorkflowSteps: ["WS-001"], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + prompt: "# test", + }); + + await aiMergeTask(store, "/tmp/root", "FN-050"); + + const initSpawnCall = mockedSpawn.mock.calls.find((call: any) => String(call[0]) === "pnpm install"); + expect(initSpawnCall).toBeUndefined(); + expect(store.logEntry).not.toHaveBeenCalledWith( + "FN-050", + expect.stringContaining("Post-merge worktree init command completed"), + expect.anything(), + ); + }); + + it("keeps post-merge steps non-fatal when post-merge worktree init fails", async () => { + const store = createMockStore(); + (store as any).getWorkflowStep = vi.fn().mockResolvedValue({ + id: "WS-001", + name: "Post-merge Notify", + description: "Send notifications after merge", + prompt: "Check merged code.", + phase: "post-merge", + mode: "prompt", + enabled: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + (store.getSettings as ReturnType).mockResolvedValue({ + ...DEFAULT_SETTINGS, + mergeIntegrationWorktree: "cwd-main" as const, + worktreeInitCommand: "pnpm install", + }); + store.getTask = vi.fn().mockResolvedValue({ + id: "FN-050", + title: "Test task", + description: "Test", + column: "in-review", + dependencies: [], + worktree: "/tmp/root/.worktrees/KB-050", + steps: [], + currentStep: 0, + log: [], + enabledWorkflowSteps: ["WS-001"], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + prompt: "# test", + }); + mockedSpawn.mockImplementation(((cmd: string) => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.pid = 12345; + child.exitCode = null; + child.signalCode = null; + child.kill = vi.fn(); + queueMicrotask(() => { + if (cmd === "pnpm install") { + child.stderr.emit("data", Buffer.from("install failed")); + child.exitCode = 1; + child.emit("close", 1, null); + } else { + child.exitCode = 0; + child.emit("close", 0, null); + } + }); + return child; + }) as any); + + const result = await aiMergeTask(store, "/tmp/root", "FN-050"); + + const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find( + (c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"), + ); + expect(postMergeAgentCall).toBeDefined(); + expect(result.merged).toBe(true); + expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done"); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-050", + expect.stringContaining("Post-merge worktree init command failed"), + expect.stringContaining("install failed"), + ); + }); + it("falls back to rootDir when worktree creation fails", async () => { const store = createMockStore(); (store as any).getWorkflowStep = vi.fn().mockResolvedValue({ diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 7963347be2..90e97895c4 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -514,7 +514,7 @@ export function shouldSyncDependenciesForMerge( ); } -function getConfiguredWorktreeInitCommand(settings?: Settings | null): string | null { +function getConfiguredWorktreeInitCommand(settings?: Pick | null): string | null { const trimmed = settings?.worktreeInitCommand?.trim(); return trimmed ? trimmed : null; } @@ -531,6 +531,44 @@ function getDependencySyncCommand(rootDir: string, settings?: Settings | null): return null; } +type MergeWorktreeCommandResult = Awaited>; + +const POST_MERGE_INIT_OUTCOME_MAX_CHARS = 2_000; + +function mergeWorktreeCommandErrorMessage(result: { spawnError?: string | Error; timedOut?: boolean; exitCode?: number | null }): string { + if (result.spawnError) return `Failed to start command: ${result.spawnError}`; + if (result.timedOut) return "Command timed out"; + return `Command exited with code ${result.exitCode ?? "unknown"}`; +} + +function truncatePostMergeInitOutput(output: string): string { + if (output.length <= POST_MERGE_INIT_OUTCOME_MAX_CHARS) return output; + return `... output truncated to last ${POST_MERGE_INIT_OUTCOME_MAX_CHARS} chars ...\n${output.slice(-POST_MERGE_INIT_OUTCOME_MAX_CHARS)}`; +} + +function formatPostMergeInitFailureOutcome(initResult: MergeWorktreeCommandResult | undefined, err: unknown): string { + const stderr = initResult?.stderr?.trim(); + if (stderr) return truncatePostMergeInitOutput(stderr); + + const stdout = initResult?.stdout?.trim(); + if (stdout) return truncatePostMergeInitOutput(stdout); + + if (initResult?.spawnError) { + return typeof initResult.spawnError === "string" ? initResult.spawnError : initResult.spawnError.message; + } + + const parts: string[] = []; + if (initResult?.timedOut) parts.push("Command timed out"); + if (initResult?.exitCode !== undefined && initResult.exitCode !== null) parts.push(`exit code: ${initResult.exitCode}`); + if (initResult?.signal) parts.push(`signal: ${initResult.signal}`); + if (parts.length > 0) return parts.join("; "); + + if (err instanceof Error && err.message.trim().length > 0) return err.message; + + const fallback = String(err).trim(); + 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"]; @@ -7236,6 +7274,36 @@ async function createPostMergeWorktree( } } +async function runPostMergeWorktreeInitCommand( + store: TaskStore, + taskId: string, + postMergeWorktree: string, + settings: Partial, + audit?: RunAuditor, +): Promise { + const initCommand = getConfiguredWorktreeInitCommand(settings); + if (!initCommand) return; + + const initStartedAt = Date.now(); + let initResult: MergeWorktreeCommandResult | undefined; + try { + initResult = await runConfiguredMergeWorktreeCommand(initCommand, postMergeWorktree, 300_000, undefined, audit); + if (initResult.spawnError || initResult.timedOut || initResult.exitCode !== 0) { + throw new Error(mergeWorktreeCommandErrorMessage(initResult)); + } + await store.logEntry(taskId, `[timing] Post-merge worktree init command completed in ${Date.now() - initStartedAt}ms`, initCommand); + } catch (err: unknown) { + if (err instanceof Error && err.name === "AbortError") { + throw err; + } + await store.logEntry(taskId, `[timing] Post-merge worktree init command failed after ${Date.now() - initStartedAt}ms`); + const message = err instanceof Error ? err.message : String(err); + const outcome = formatPostMergeInitFailureOutcome(initResult, err); + mergerLog.warn(`${taskId}: post-merge worktree init command failed — post-merge workflow steps will still run: ${message}`); + await store.logEntry(taskId, `Post-merge worktree init command failed (post-merge workflow steps will still run): ${message}`, outcome); + } +} + /** * Remove a temporary worktree created for post-merge step execution. * Non-fatal: logs and swallows errors. @@ -10440,6 +10508,7 @@ export async function aiMergeTask( const postMergeWorktree = await createPostMergeWorktree(rootDir, taskId, settings); const postMergeCwd = postMergeWorktree || rootDir; if (postMergeWorktree) { + await runPostMergeWorktreeInitCommand(store, taskId, postMergeWorktree, settings, audit); mergerLog.log(`${taskId}: running post-merge workflow steps in isolated worktree: ${postMergeWorktree}`); } else { mergerLog.warn(`${taskId}: could not create post-merge worktree — falling back to rootDir`);