FN-6096: run worktree init command in post-merge worktrees

Run the configured worktree init command before post-merge workflow steps use their temporary worktree.

- execute the configured worktreeInitCommand inside isolated post-merge worktrees before workflow steps run
- log successful and failed post-merge init outcomes while keeping workflow-step execution non-fatal on init failure
- add merger tests covering configured init execution, missing init configuration, and init-command failure handling

Files changed:
 packages/engine/src/__tests__/merger-post-merge.test.ts | 156 +++++++++++++++++++++
 packages/engine/src/merger.ts                      |  71 +++++++++-
 2 files changed, 226 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-6096

Fusion-Task-Lineage: 32ba5e62-cffd-4d94-bd23-a7ce2df3cbf9
This commit is contained in:
gsxdsm
2026-06-09 10:19:44 -07:00
parent be7645f86b
commit 55ba994ae9
2 changed files with 226 additions and 1 deletions

View File

@@ -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<typeof vi.fn>).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<typeof vi.fn>).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({

View File

@@ -514,7 +514,7 @@ export function shouldSyncDependenciesForMerge(
);
}
function getConfiguredWorktreeInitCommand(settings?: Settings | null): string | null {
function getConfiguredWorktreeInitCommand(settings?: Pick<Settings, "worktreeInitCommand"> | 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<ReturnType<typeof runConfiguredMergeWorktreeCommand>>;
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<Settings>,
audit?: RunAuditor,
): Promise<void> {
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`);