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:
@@ -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({
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user