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:
committed by
gsxdsm
parent
f9c0b9399c
commit
d94ef51238
@@ -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" },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user