fix(FN-850): harden merge readiness and reopen done tasks
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user