fix(FN-850): harden merge readiness and reopen done tasks

This commit is contained in:
gsxdsm
2026-04-04 08:35:19 -07:00
parent e9fddc44cb
commit 6d1c6411d9
11 changed files with 311 additions and 28 deletions

View File

@@ -69,6 +69,18 @@ vi.mock("@fusion/core", () => ({
recordRun: vi.fn().mockResolvedValue({}),
getDueSchedules: vi.fn().mockResolvedValue([]),
})),
getTaskMergeBlocker: vi.fn((task: any) => {
if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`;
if (task.paused) return "task is paused";
if (task.status === "failed") return "task is marked 'failed'";
if (task.steps?.some((step: any) => step.status === "pending" || step.status === "in-progress")) {
return "task has incomplete steps";
}
if (task.workflowStepResults?.some((result: any) => result.status === "pending" || result.status === "failed")) {
return "task has incomplete or failed workflow steps";
}
return undefined;
}),
}));
// ── Hoisted shared mocks ───────────────────────────────────────────
@@ -633,6 +645,59 @@ describe("runDashboard — auto-merge pause exclusion", () => {
);
expect(mergedIds).not.toContain("FN-PAUSED");
});
it("does not auto-merge failed in-review tasks", async () => {
mockStore.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: true,
pollIntervalMs: 60_000,
});
mockStore.listTasks.mockResolvedValue([
{ id: "FN-FAILED", column: "in-review", paused: false, status: "failed" },
]);
mockStore.getTask = vi.fn().mockResolvedValue({
id: "FN-FAILED",
column: "in-review",
paused: false,
status: "failed",
steps: [{ name: "Step 1", status: "done" }],
});
const { aiMergeTask } = await import("@fusion/engine");
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
expect(aiMergeTask).not.toHaveBeenCalled();
});
it("does not auto-merge in-review tasks with incomplete steps", async () => {
mockStore.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: true,
pollIntervalMs: 60_000,
});
mockStore.listTasks.mockResolvedValue([
{ id: "FN-INCOMPLETE", column: "in-review", paused: false, steps: [{ name: "Step 1", status: "in-progress" }] },
]);
mockStore.getTask = vi.fn().mockResolvedValue({
id: "FN-INCOMPLETE",
column: "in-review",
paused: false,
steps: [{ name: "Step 1", status: "in-progress" }],
});
const { aiMergeTask } = await import("@fusion/engine");
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
expect(aiMergeTask).not.toHaveBeenCalled();
});
});
describe("runDashboard — immediate resume on unpause", () => {

View File

@@ -1,7 +1,7 @@
import { execSync } from "node:child_process";
import type { AddressInfo } from "node:net";
import { createInterface } from "node:readline";
import { TaskStore, AutomationStore, CentralCore } from "@fusion/core";
import { TaskStore, AutomationStore, CentralCore, getTaskMergeBlocker } from "@fusion/core";
import type { Settings, TaskDetail, PrInfo } from "@fusion/core";
import { createServer, GitHubClient } from "@fusion/dashboard";
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier, PrMonitor, PrCommentHandler, CronRunner, StuckTaskDetector, SelfHealingManager } from "@fusion/engine";
@@ -120,7 +120,7 @@ export async function processPullRequestMergeTask(
github: Pick<GitHubClient, "findPrForBranch" | "createPr" | "getPrMergeStatus" | "mergePr">,
): Promise<"waiting" | "merged" | "skipped"> {
const task = await store.getTask(taskId);
if (task.column !== "in-review" || task.paused) {
if (getTaskMergeBlocker(task)) {
return "skipped";
}
@@ -368,7 +368,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
// Verify the task is still in-review and not paused
const task = await store.getTask(taskId);
if (task.column !== "in-review" || task.paused) {
if (getTaskMergeBlocker(task)) {
continue;
}
const mergeStrategy = getMergeStrategy(settings);
@@ -453,7 +453,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// enqueue it for serialized merge processing.
store.on("task:moved", async ({ task, to }) => {
if (to !== "in-review") return;
if (task.paused) return;
if (getTaskMergeBlocker(task)) return;
try {
const settings = await store.getSettings();
if (settings.globalPause || settings.enginePaused) return;
@@ -633,7 +633,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// ── Startup sweep: enqueue any tasks already in "in-review" ───────
if (settings.autoMerge) {
const existing = await store.listTasks();
const inReview = existing.filter((t) => t.column === "in-review" && !t.paused);
const inReview = existing.filter((t) => !getTaskMergeBlocker(t));
if (inReview.length > 0) {
console.log(
`[auto-merge] Startup sweep: enqueueing ${inReview.length} in-review task(s)`,
@@ -662,7 +662,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
try {
const tasks = await store.listTasks();
for (const t of tasks) {
if (t.column === "in-review" && !t.paused) {
if (!getTaskMergeBlocker(t)) {
enqueueMerge(t.id);
}
}
@@ -687,7 +687,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
try {
const tasks = await store.listTasks();
for (const t of tasks) {
if (t.column === "in-review" && !t.paused) {
if (!getTaskMergeBlocker(t)) {
enqueueMerge(t.id);
}
}
@@ -721,7 +721,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (!s.globalPause && !s.enginePaused && s.autoMerge) {
const tasks = await store.listTasks();
for (const t of tasks) {
if (t.column === "in-review" && !t.paused) {
if (!getTaskMergeBlocker(t)) {
enqueueMerge(t.id);
}
}