feat(FN-5455): merge fusion/fn-5455

This commit is contained in:
gsxdsm
2026-05-22 21:08:00 -07:00
parent ba9d6326e4
commit 4a99e3fba2
8 changed files with 138 additions and 21 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
PR-mode merge cleanup (`cleanupMergedTaskArtifacts`) now releases the `WorktreePool` lease for the merged task worktree before removing it, preventing stranded lease bookkeeping after pull-request merges (FN-5420 / FN-4954 follow-up).

View File

@@ -23,7 +23,11 @@ vi.mock("node:child_process", () => ({
}, },
})); }));
import { processPullRequestMergeTask, getTaskBranchName } from "../task-lifecycle.js"; import {
cleanupMergedTaskArtifacts,
processPullRequestMergeTask,
getTaskBranchName,
} from "../task-lifecycle.js";
interface MockTask { interface MockTask {
id: string; id: string;
@@ -915,3 +919,41 @@ describe("processPullRequestMergeTask", () => {
}); });
}); });
}); });
describe("cleanupMergedTaskArtifacts FN-5455", () => {
beforeEach(() => {
execMock.mockReset();
execMock.mockReturnValue("");
});
it("FN-5455: releases pool lease before removing worktree and deleting branch", async () => {
const pool = { release: vi.fn() };
await cleanupMergedTaskArtifacts("/repo", { id: "FN-5455-A", worktree: "/repo/wt" } as never, { pool } as never);
expect(pool.release).toHaveBeenCalledWith("/repo/wt", "FN-5455-A");
expect(execMock).toHaveBeenCalledWith(expect.stringContaining('git worktree remove "/repo/wt" --force'), expect.any(Object));
expect(execMock).toHaveBeenCalledWith(expect.stringContaining('git branch -d "fusion/fn-5455-a"'), expect.any(Object));
});
it("FN-5455: pool omitted keeps backward-compatible cleanup behavior", async () => {
await cleanupMergedTaskArtifacts("/repo", { id: "FN-5455-B", worktree: "/repo/wt-b" } as never);
expect(execMock).toHaveBeenCalledWith(expect.stringContaining('git worktree remove "/repo/wt-b" --force'), expect.any(Object));
expect(execMock).toHaveBeenCalledWith(expect.stringContaining('git branch -d "fusion/fn-5455-b"'), expect.any(Object));
});
it("FN-5455: undefined worktree skips pool interaction and worktree removal", async () => {
const pool = { release: vi.fn() };
await cleanupMergedTaskArtifacts("/repo", { id: "FN-5455-C", worktree: undefined } as never, { pool } as never);
expect(pool.release).not.toHaveBeenCalled();
expect(execMock).not.toHaveBeenCalledWith(expect.stringContaining("git worktree remove"), expect.anything());
expect(execMock).toHaveBeenCalledWith(expect.stringContaining('git branch -d "fusion/fn-5455-c"'), expect.any(Object));
});
it("FN-5455: release errors are swallowed and cleanup continues", async () => {
const pool = { release: vi.fn(() => { throw new Error("boom"); }) };
await expect(
cleanupMergedTaskArtifacts("/repo", { id: "FN-5455-D", worktree: "/repo/wt-d" } as never, { pool } as never),
).resolves.toBeUndefined();
expect(execMock).toHaveBeenCalledWith(expect.stringContaining('git worktree remove "/repo/wt-d" --force'), expect.any(Object));
expect(execMock).toHaveBeenCalledWith(expect.stringContaining('git branch -d "fusion/fn-5455-d"'), expect.any(Object));
});
});

View File

@@ -332,8 +332,8 @@ export async function runDaemon(opts: DaemonOptions = {}) {
const engineManager = new ProjectEngineManager(sharedCentralCore, { const engineManager = new ProjectEngineManager(sharedCentralCore, {
getMergeStrategy, getMergeStrategy,
processPullRequestMerge: (s, wd, taskId) => processPullRequestMerge: (s, wd, taskId, pool) =>
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker), processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),
getTaskMergeBlocker, getTaskMergeBlocker,
onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult), onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult),
}); });

View File

@@ -1197,7 +1197,14 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const settings = await store.getSettings(); const settings = await store.getSettings();
if (getMergeStrategy(settings) === "pull-request") { if (getMergeStrategy(settings) === "pull-request") {
const githubClient = new GitHubClient(); const githubClient = new GitHubClient();
const outcome = await processPullRequestMergeTask(store, cwd, taskId, githubClient, getTaskMergeBlocker); const outcome = await processPullRequestMergeTask(
store,
cwd,
taskId,
githubClient,
getTaskMergeBlocker,
worktreePool,
);
const task = await store.getTask(taskId); const task = await store.getTask(taskId);
return { return {
task, task,
@@ -1510,8 +1517,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const engineManager = new ProjectEngineManager(centralCoreForEngine, { const engineManager = new ProjectEngineManager(centralCoreForEngine, {
getMergeStrategy, getMergeStrategy,
processPullRequestMerge: (s, wd, taskId) => processPullRequestMerge: (s, wd, taskId, pool) =>
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker), processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),
getTaskMergeBlocker, getTaskMergeBlocker,
}); });

View File

@@ -358,8 +358,8 @@ export async function runServe(
const engineManager = new ProjectEngineManager(sharedCentralCore, { const engineManager = new ProjectEngineManager(sharedCentralCore, {
getMergeStrategy, getMergeStrategy,
processPullRequestMerge: (s, wd, taskId) => processPullRequestMerge: (s, wd, taskId, pool) =>
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker), processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),
getTaskMergeBlocker, getTaskMergeBlocker,
onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult), onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult),
}); });

View File

@@ -20,6 +20,7 @@ import type { TaskStore } from "@fusion/core";
import { resolveTaskMergeTarget } from "@fusion/core"; import { resolveTaskMergeTarget } from "@fusion/core";
import type { Settings, TaskDetail, PrInfo, MergeResult } from "@fusion/core"; import type { Settings, TaskDetail, PrInfo, MergeResult } from "@fusion/core";
import { resolveIntegrationBranch } from "@fusion/engine"; import { resolveIntegrationBranch } from "@fusion/engine";
import type { WorktreePool } from "@fusion/engine";
/** /**
* Minimal interface for GitHub operations needed by the PR merge workflow. * Minimal interface for GitHub operations needed by the PR merge workflow.
@@ -140,10 +141,26 @@ function buildPullRequestBody(task: Pick<TaskDetail, "id" | "description">): str
* Clean up worktree and branch artifacts after a successful merge. * Clean up worktree and branch artifacts after a successful merge.
* Both operations are best-effort; errors are logged but don't propagate. * Both operations are best-effort; errors are logged but don't propagate.
*/ */
export async function cleanupMergedTaskArtifacts(cwd: string, task: Pick<TaskDetail, "id" | "worktree">): Promise<void> { /**
* @param options.pool Optional runtime worktree pool; FN-5455/FN-4954 require best-effort
* release before force-removing merged PR worktrees.
*/
export async function cleanupMergedTaskArtifacts(
cwd: string,
task: Pick<TaskDetail, "id" | "worktree">,
options?: { pool?: WorktreePool },
): Promise<void> {
const branch = getTaskBranchName(task.id); const branch = getTaskBranchName(task.id);
if (task.worktree) { if (task.worktree) {
if (options?.pool) {
try {
options.pool.release(task.worktree, task.id);
} catch {
// Best-effort cleanup — release may fail if pool state is already divergent.
}
}
try { try {
await execAsync(`git worktree remove "${task.worktree}" --force`, { await execAsync(`git worktree remove "${task.worktree}" --force`, {
cwd, cwd,
@@ -177,8 +194,9 @@ async function finalizePullRequestMerge(
task: TaskDetail, task: TaskDetail,
prInfo: PrInfo, prInfo: PrInfo,
message = "Pull request merged", message = "Pull request merged",
pool?: WorktreePool,
): Promise<void> { ): Promise<void> {
await cleanupMergedTaskArtifacts(cwd, task); await cleanupMergedTaskArtifacts(cwd, task, { pool });
await store.updateTask(task.id, { status: null, mergeRetries: 0 }); await store.updateTask(task.id, { status: null, mergeRetries: 0 });
const movedTask = await store.moveTask(task.id, "done"); const movedTask = await store.moveTask(task.id, "done");
const mergedTask = movedTask ?? (await store.getTask(task.id)); const mergedTask = movedTask ?? (await store.getTask(task.id));
@@ -240,6 +258,7 @@ export async function processPullRequestMergeTask(
taskId: string, taskId: string,
github: GitHubOperations, github: GitHubOperations,
getTaskMergeBlocker: TaskMergeBlockerFn, getTaskMergeBlocker: TaskMergeBlockerFn,
pool?: WorktreePool,
): Promise<ProcessPullRequestResult> { ): Promise<ProcessPullRequestResult> {
const task = await store.getTask(taskId); const task = await store.getTask(taskId);
if (getTaskMergeBlocker(task)) { if (getTaskMergeBlocker(task)) {
@@ -304,7 +323,7 @@ export async function processPullRequestMergeTask(
await store.updatePrInfo(task.id, refreshedPrInfo); await store.updatePrInfo(task.id, refreshedPrInfo);
if (mergeStatus.prInfo.status === "merged") { if (mergeStatus.prInfo.status === "merged") {
await finalizePullRequestMerge(store, cwd, task, prInfo); await finalizePullRequestMerge(store, cwd, task, prInfo, "Pull request merged", pool);
return "merged"; return "merged";
} }
@@ -359,6 +378,7 @@ export async function processPullRequestMergeTask(
task, task,
refreshedAfterFailure, refreshedAfterFailure,
"Pull request already merged after merge command failed; reconciled task state from GitHub", "Pull request already merged after merge command failed; reconciled task state from GitHub",
pool,
); );
return "merged"; return "merged";
} }
@@ -366,6 +386,6 @@ export async function processPullRequestMergeTask(
throw err; throw err;
} }
await store.updatePrInfo(task.id, { ...mergedPr, lastCheckedAt: new Date().toISOString() }); await store.updatePrInfo(task.id, { ...mergedPr, lastCheckedAt: new Date().toISOString() });
await finalizePullRequestMerge(store, cwd, task, mergedPr); await finalizePullRequestMerge(store, cwd, task, mergedPr, "Pull request merged", pool);
return "merged"; return "merged";
} }

View File

@@ -239,18 +239,54 @@ describe("FN-5420 reliability interactions: PR mode worktree invariants", () =>
} }
}); });
it.fails("FN-5420/FN-4954/FN-4811 follow-up required: cleanup should release pool lease and clear active-session registry", async () => { it("FN-5455: PR-mode cleanupMergedTaskArtifacts releases WorktreePool lease for task worktree", async () => {
const pool = new WorktreePool(); const pool = new WorktreePool();
const path = "/tmp/fn-5420-leased"; const path = "/tmp/fn-5455-leased";
(pool as any).getLeasedPaths().set(path, "FN-5420-LEAK"); (pool as any).getLeasedPaths().set(path, "FN-5455-LEAK");
activeSessionRegistry.registerPath(path, { taskId: "FN-5420-LEAK", kind: "executor", ownerKey: "FN-5420-LEAK" });
const spy = vi.spyOn((pool as any).constructor.prototype, "release"); const releaseSpy = vi.spyOn(pool, "release");
const { cleanupMergedTaskArtifacts } = await loadPrLifecycleModule(); const { cleanupMergedTaskArtifacts } = await loadPrLifecycleModule();
await cleanupMergedTaskArtifacts("/tmp", { id: "FN-5420-LEAK", worktree: path } as any); await cleanupMergedTaskArtifacts("/tmp", { id: "FN-5455-LEAK", worktree: path } as any, { pool });
expect(spy).toHaveBeenCalled(); expect(releaseSpy).toHaveBeenCalledTimes(1);
expect(activeSessionRegistry.lookupByPath(path)).toBeNull(); expect(releaseSpy).toHaveBeenCalledWith(path, "FN-5455-LEAK");
expect(pool.getLeasedPaths().has(path)).toBe(false);
expect(pool.getLeasedPaths().get(path)).toBeUndefined();
});
it("FN-5455: cleanupMergedTaskArtifacts is a no-op for pool when options.pool is omitted", async () => {
const { cleanupMergedTaskArtifacts } = await loadPrLifecycleModule();
await expect(
cleanupMergedTaskArtifacts("/tmp", { id: "FN-5455-NO-POOL", worktree: "/tmp/fn-5455-no-pool" } as any),
).resolves.toBeUndefined();
});
it("FN-5455: cleanupMergedTaskArtifacts calls pool.release even when worktree directory is already gone", async () => {
const pool = new WorktreePool();
const path = "/tmp/fn-5455-missing";
(pool as any).getLeasedPaths().set(path, "FN-5455-MISSING");
const releaseSpy = vi.spyOn(pool, "release");
const { cleanupMergedTaskArtifacts } = await loadPrLifecycleModule();
await cleanupMergedTaskArtifacts("/tmp", { id: "FN-5455-MISSING", worktree: path } as any, { pool });
expect(releaseSpy).toHaveBeenCalledWith(path, "FN-5455-MISSING");
expect(pool.getLeasedPaths().has(path)).toBe(false);
});
it("FN-5455: cleanupMergedTaskArtifacts swallows pool.release errors (best-effort)", async () => {
const pool = new WorktreePool();
const path = "/tmp/fn-5455-release-throws";
const releaseSpy = vi.spyOn(pool, "release").mockImplementation(() => {
throw new Error("release failed");
});
const { cleanupMergedTaskArtifacts } = await loadPrLifecycleModule();
await expect(cleanupMergedTaskArtifacts("/tmp", { id: "FN-5455-THROW", worktree: path } as any, { pool })).resolves.toBeUndefined();
expect(releaseSpy).toHaveBeenCalledWith(path, "FN-5455-THROW");
});
it.skip("FN-5456 follow-up required: cleanup should clear active-session registry entry", async () => {
const path = "/tmp/fn-5456-session";
activeSessionRegistry.registerPath(path, { taskId: "FN-5456", kind: "executor", ownerKey: "FN-5456" });
expect(activeSessionRegistry.lookupByPath(path)).not.toBeNull();
}); });
it("FN-5420/FN-5279: mergeIntegrationWorktree setting does not gate PR-mode processing", async () => { it("FN-5420/FN-5279: mergeIntegrationWorktree setting does not gate PR-mode processing", async () => {

View File

@@ -14,6 +14,7 @@ import { compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, sortTasksB
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { InProcessRuntime } from "./runtimes/in-process-runtime.js"; import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
import type { WorktreePool } from "./worktree-pool.js";
import type { ProjectRuntimeConfig } from "./project-runtime.js"; import type { ProjectRuntimeConfig } from "./project-runtime.js";
import { PrMonitor } from "./pr-monitor.js"; import { PrMonitor } from "./pr-monitor.js";
import { PrCommentHandler } from "./pr-comment-handler.js"; import { PrCommentHandler } from "./pr-comment-handler.js";
@@ -55,6 +56,7 @@ export type ProcessPullRequestMergeFn = (
store: TaskStore, store: TaskStore,
cwd: string, cwd: string,
taskId: string, taskId: string,
pool?: WorktreePool,
) => Promise<"merged" | "waiting" | "skipped">; ) => Promise<"merged" | "waiting" | "skipped">;
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -1483,7 +1485,12 @@ export class ProjectEngine {
if (mergeStrategy === "pull-request" && this.options.processPullRequestMerge) { if (mergeStrategy === "pull-request" && this.options.processPullRequestMerge) {
this.activeMergeTaskId = taskId; this.activeMergeTaskId = taskId;
runtimeLog.log(`${manualResolver ? "Manual" : "Auto"}-merge processing PR flow for ${taskId}...`); runtimeLog.log(`${manualResolver ? "Manual" : "Auto"}-merge processing PR flow for ${taskId}...`);
const result = await this.options.processPullRequestMerge(store, cwd, taskId); const result = await this.options.processPullRequestMerge(
store,
cwd,
taskId,
(this.runtime as any).worktreePool,
);
if (result === "merged") { if (result === "merged") {
runtimeLog.log(`${manualResolver ? "Manual" : "Auto"}-merge PR merged: ${taskId}`); runtimeLog.log(`${manualResolver ? "Manual" : "Auto"}-merge PR merged: ${taskId}`);
const mergedTask = await store.getTask(taskId).catch(() => null); const mergedTask = await store.getTask(taskId).catch(() => null);