feat(FN-5047): emit task:merged across merge finalize paths

Fusion-Task-Id: FN-5047
Fusion-Task-Lineage: 9b03f2de-1b4a-45ad-b1ce-0a6217d6da77
This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 07:28:59 -07:00
committed by gsxdsm
parent f9c0b9399c
commit d94ef51238
7 changed files with 190 additions and 17 deletions

View File

@@ -180,7 +180,7 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
listTasks: vi.fn().mockResolvedValue(allTasks),
updateTask: vi.fn().mockResolvedValue(baseTask),
moveTask: vi.fn().mockResolvedValue(baseTask),
moveTask: vi.fn().mockResolvedValue({ ...baseTask, column: "done" }),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
@@ -846,6 +846,26 @@ describe("aiMergeTask — merge details collection", () => {
expect(mergeDetailsCall?.[1].mergeDetails.mergeCommitMessage).toBe("AI summary of merged work.");
});
it("emits task:merged once when completeTask finalizes a successful merge", async () => {
const store = createMockStore(
{ id: "FN-777", worktree: "/tmp/root/.worktrees/FN-777" },
[{ id: "FN-777", worktree: "/tmp/root/.worktrees/FN-777", column: "in-review" } as Task],
);
setupHappyPathExecSync();
const result = await aiMergeTask(store, "/tmp/root", "FN-777");
expect(result.merged).toBe(true);
const mergedEvents = (store.emit as ReturnType<typeof vi.fn>).mock.calls.filter((call: any[]) => call[0] === "task:merged");
expect(mergedEvents).toHaveLength(1);
expect(mergedEvents[0][1]).toEqual(
expect.objectContaining({
merged: true,
task: expect.objectContaining({ column: "done" }),
}),
);
});
it("falls back to raw commit log when AI merge summary returns null", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },

View File

@@ -153,6 +153,7 @@ function createMockStore(initialSettings: Record<string, unknown>) {
return structuredClone(settings);
}),
logEntry: vi.fn(async () => undefined),
emit: vi.fn(),
addTaskComment: vi.fn(async () => undefined),
getActiveMergingTask: vi.fn(() => null),
on: vi.fn((event: string, handler: (...args: unknown[]) => void | Promise<void>) => {
@@ -1421,6 +1422,82 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
await engine.stop();
});
it("emits task:merged when mergeConfirmed fast-path finalizes to done", async () => {
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
mockStore.store.getTask.mockResolvedValueOnce({
id: "FN-merged",
column: "in-review",
paused: false,
mergeRetries: 0,
status: null,
branch: "fusion/fn-merged",
mergeDetails: { mergeConfirmed: true, mergedAt: "2026-05-18T00:00:00.000Z", mergeTargetBranch: "main" },
});
mockStore.store.moveTask.mockResolvedValueOnce({
id: "FN-merged",
column: "done",
branch: "fusion/fn-merged",
mergeDetails: { mergeConfirmed: true, mergedAt: "2026-05-18T00:00:00.000Z", mergeTargetBranch: "main" },
});
mocks.currentStore = mockStore.store;
const engine = createEngine();
await engine.start();
engine.enqueueMerge("FN-merged");
await vi.waitFor(() => {
expect(mockStore.store.emit).toHaveBeenCalledWith(
"task:merged",
expect.objectContaining({
merged: true,
task: expect.objectContaining({ id: "FN-merged", column: "done" }),
}),
);
});
await engine.stop();
});
it("emits task:merged when PR merge strategy returns merged", async () => {
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
mockStore.store.getTask
.mockResolvedValueOnce({
id: "FN-pr",
column: "in-review",
paused: false,
mergeRetries: 0,
status: null,
branch: "fusion/fn-pr",
})
.mockResolvedValue({
id: "FN-pr",
column: "done",
paused: false,
mergeRetries: 0,
status: null,
branch: "fusion/fn-pr",
mergeDetails: { mergeConfirmed: true, mergedAt: "2026-05-18T00:00:00.000Z", mergeTargetBranch: "main" },
});
mocks.currentStore = mockStore.store;
const processPullRequestMerge = vi.fn(async () => "merged" as const);
const engine = createEngine({ processPullRequestMerge, getMergeStrategy: () => "pull-request" });
await engine.start();
engine.enqueueMerge("FN-pr");
await vi.waitFor(() => {
expect(mockStore.store.emit).toHaveBeenCalledWith(
"task:merged",
expect.objectContaining({
merged: true,
task: expect.objectContaining({ id: "FN-pr" }),
}),
);
});
await engine.stop();
});
it("logs and skips paused tasks dequeued for auto-merge", async () => {
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
mockStore.store.getTask.mockResolvedValueOnce({

View File

@@ -6241,8 +6241,7 @@ export async function aiMergeTask(
};
await store.updateTask(taskId, { mergeDetails });
await store.logEntry(taskId, `Auto-finalized: recovered owned landed commit ${classification.commit.sha.slice(0, 8)}`);
await store.moveTask(taskId, "done");
return {
const result: MergeResult = {
task,
branch,
merged: true,
@@ -6252,6 +6251,8 @@ export async function aiMergeTask(
mergedAt: mergeDetails.mergedAt,
mergeTargetBranch: aheadInfo.baseRef,
};
await completeTask(store, taskId, result);
return result;
}
if (classification.kind === "proven-no-op" || classification.kind === "no-changes-finalized") {
@@ -6275,8 +6276,7 @@ export async function aiMergeTask(
? `Auto-finalized no-op (proven): start point on ${classification.baseRef}; modifiedFiles cleared`
: "Auto-finalized verification-only no-change task: branch absent with no owned commits; modifiedFiles cleared",
);
await store.moveTask(taskId, "done");
return {
const result: MergeResult = {
task,
branch,
merged: true,
@@ -6289,6 +6289,8 @@ export async function aiMergeTask(
mergedAt: mergeDetails.mergedAt,
mergeTargetBranch: classification.baseRef,
};
await completeTask(store, taskId, result);
return result;
}
const unprovenError = `finalize-unproven: ${classification.reason}`;

View File

@@ -1367,7 +1367,18 @@ export class ProjectEngine {
);
await store.updateTask(taskId, { paused: false, status: null, error: null });
try {
await store.moveTask(taskId, "done");
const movedTask = await store.moveTask(taskId, "done");
const mergedTask = movedTask ?? (await store.getTask(taskId).catch(() => null)) ?? task;
store.emit("task:merged", {
task: mergedTask,
branch: mergedTask.branch ?? task.branch ?? "",
merged: true,
worktreeRemoved: false,
branchDeleted: false,
mergeConfirmed: true,
mergedAt: mergedTask.mergeDetails?.mergedAt,
mergeTargetBranch: mergedTask.mergeDetails?.mergeTargetBranch,
} as MergeResult);
} catch (error) {
if (isInvalidDoneTransitionError(error)) {
const latest = await store.getTask(taskId).catch(() => null);
@@ -1447,6 +1458,19 @@ export class ProjectEngine {
const result = await this.options.processPullRequestMerge(store, cwd, taskId);
if (result === "merged") {
runtimeLog.log(`${manualResolver ? "Manual" : "Auto"}-merge PR merged: ${taskId}`);
const mergedTask = await store.getTask(taskId).catch(() => null);
if (mergedTask) {
store.emit("task:merged", {
task: mergedTask,
branch: mergedTask.branch ?? "",
merged: true,
worktreeRemoved: false,
branchDeleted: false,
mergeConfirmed: mergedTask.mergeDetails?.mergeConfirmed,
mergedAt: mergedTask.mergeDetails?.mergedAt,
mergeTargetBranch: mergedTask.mergeDetails?.mergeTargetBranch,
} as MergeResult);
}
} else if (result === "waiting") {
runtimeLog.log(`${manualResolver ? "Manual" : "Auto"}-merge PR waiting: ${taskId}`);
}

View File

@@ -28,7 +28,7 @@ import { exec, execSync } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectSelfDefeatingDependency, getInReviewStallReason, getStalePausedReviewSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority } from "@fusion/core";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectSelfDefeatingDependency, getInReviewStallReason, getStalePausedReviewSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { createLogger } from "./logger.js";
import { RemovalReason, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
@@ -529,6 +529,21 @@ export class SelfHealingManager {
private options: SelfHealingOptions,
) {}
private emitTaskMerged(task: Task | undefined | null, overrides: Partial<MergeResult> = {}): void {
if (!task) return;
this.store.emit("task:merged", {
task,
branch: task.branch ?? "",
merged: true,
worktreeRemoved: false,
branchDeleted: false,
mergeConfirmed: task.mergeDetails?.mergeConfirmed,
mergedAt: task.mergeDetails?.mergedAt,
mergeTargetBranch: task.mergeDetails?.mergeTargetBranch,
...overrides,
} as MergeResult);
}
// ── Lifecycle ───────────────────────────────────────────────────────
start(): void {
@@ -3142,7 +3157,8 @@ export class SelfHealingManager {
await this.store.logEntry(task.id, `Auto-finalized no-op (proven): start point on ${classification.baseRef}; modifiedFiles cleared`);
}
await this.store.moveTask(task.id, "done");
const movedTask = await this.store.moveTask(task.id, "done");
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
recovered++;
}
@@ -3801,7 +3817,8 @@ export class SelfHealingManager {
mergeRetries: 0,
mergeDetails,
});
await this.store.moveTask(task.id, "done");
const movedTask = await this.store.moveTask(task.id, "done");
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
await this.cleanupInterruptedMergeArtifacts(task);
await this.store.logEntry(
task.id,
@@ -4082,7 +4099,8 @@ export class SelfHealingManager {
error: null,
mergeRetries: 0,
});
await this.store.moveTask(task.id, "done");
const movedTask = await this.store.moveTask(task.id, "done");
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
await this.store.logEntry(
task.id,
`Auto-finalized from in-review/paused: content proven via mergeConfirmed metadata. Cleared soft state paused=${clearedFlags.paused}, status=${clearedFlags.status}, error=${clearedFlags.error}`,
@@ -4192,7 +4210,8 @@ export class SelfHealingManager {
branch: null,
mergeDetails,
});
await this.store.moveTask(task.id, "done");
const movedTask = await this.store.moveTask(task.id, "done");
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
await this.cleanupInterruptedMergeArtifacts(task);
const clearedDependents: string[] = [];
@@ -4369,7 +4388,8 @@ export class SelfHealingManager {
mergeRetries: 0,
mergeDetails,
});
await this.store.moveTask(task.id, "done");
const movedTask = await this.store.moveTask(task.id, "done");
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
await this.store.logEntry(
task.id,
`Auto-finalized from in-review/paused: content proven on ${baseBranch} (${landed.sha.slice(0, 8)}). Cleared soft state paused=${clearedFlags.paused}, status=${clearedFlags.status}, error=${clearedFlags.error}`,
@@ -4506,7 +4526,8 @@ export class SelfHealingManager {
mergeDetails,
});
const worktreeHint = task.worktree;
await this.store.moveTask(task.id, "done");
const movedTask = await this.store.moveTask(task.id, "done");
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
await this.store.logEntry(
task.id,
`Auto-finalized from in-review/paused: content proven on ${baseBranch} (${landed.sha.slice(0, 8)}). Cleared soft state paused=${clearedFlags.paused}, status=${clearedFlags.status}, error=${clearedFlags.error}`,
@@ -4703,7 +4724,8 @@ export class SelfHealingManager {
await this.clearCompletionBranchIfSubsumed(task, branch).catch(() => false);
await this.store.moveTask(task.id, "done");
const movedTask = await this.store.moveTask(task.id, "done");
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
await this.store.logEntry(
task.id,
`Auto-recovered: branch tip misbound but content found on ${baseBranch} at ${check.landed.sha.slice(0, 8)} via ${check.landed.strategy}`,