fix: improve merge verification and dashboard behavior
This commit is contained in:
@@ -8,7 +8,15 @@ const workspaceRoot = join(import.meta.dirname!, "..", "..", "..", "..");
|
||||
function loadWorkflow(name: string): any {
|
||||
const path = join(workspaceRoot, ".github", "workflows", name);
|
||||
const content = readFileSync(path, "utf-8");
|
||||
return { content, parsed: parse(content) };
|
||||
const parsed = parse(content) as Record<string, unknown>;
|
||||
|
||||
// Some YAML parsers treat the unquoted `on:` key as boolean `true`.
|
||||
// Normalize it so tests can consistently read `workflow.on`.
|
||||
if (parsed && parsed.on === undefined) {
|
||||
(parsed as any).on = (parsed as any)["on"] ?? (parsed as any).true ?? (parsed as any)["true"];
|
||||
}
|
||||
|
||||
return { content, parsed };
|
||||
}
|
||||
|
||||
describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
@@ -26,12 +34,13 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
expect(typeof workflow).toBe("object");
|
||||
});
|
||||
|
||||
it("has push trigger on main", () => {
|
||||
expect(workflow.on.push.branches).toContain("main");
|
||||
it("uses workflow_dispatch trigger (auto CI disabled)", () => {
|
||||
expect(workflow.on).toHaveProperty("workflow_dispatch");
|
||||
});
|
||||
|
||||
it("has pull_request trigger on main", () => {
|
||||
expect(workflow.on.pull_request.branches).toContain("main");
|
||||
it("does not auto-trigger on push/pull_request", () => {
|
||||
expect(workflow.on.push).toBeUndefined();
|
||||
expect(workflow.on.pull_request).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes pnpm install step", () => {
|
||||
|
||||
@@ -851,6 +851,47 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
||||
expect(aiMergeTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-heals stale exit-0 verification buffer failures with exhausted merge retries", async () => {
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: true,
|
||||
pollIntervalMs: 60_000,
|
||||
});
|
||||
const task = {
|
||||
id: "FN-BUFFER",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 3,
|
||||
error: "Deterministic test verification failed for FN-BUFFER",
|
||||
steps: [{ name: "Step 1", status: "done" }],
|
||||
log: [
|
||||
{
|
||||
timestamp: "2026-04-10T20:23:18.691Z",
|
||||
action: "[verification] test command failed (exit 0): stdout maxBuffer length exceeded",
|
||||
},
|
||||
],
|
||||
};
|
||||
mockStore.listTasks.mockResolvedValue([task]);
|
||||
mockStore.getTask = vi.fn().mockResolvedValue(task);
|
||||
|
||||
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(mockStore.logEntry).toHaveBeenCalledWith(
|
||||
"FN-BUFFER",
|
||||
"Auto-healing stale deterministic verification buffer failure; retrying merge verification",
|
||||
);
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
"FN-BUFFER",
|
||||
{ mergeRetries: 0, error: null, status: null },
|
||||
);
|
||||
expect(aiMergeTask).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-merge in-review tasks with incomplete steps", async () => {
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
@@ -1902,28 +1943,22 @@ describe("runDashboard — lifecycle listener cleanup", () => {
|
||||
expect(process.listenerCount("SIGTERM")).toBe(baselineSigterm);
|
||||
});
|
||||
|
||||
it("does not emit MaxListenersExceededWarning after 12 rapid invocations", async () => {
|
||||
it("does not leak process signal listeners after 12 rapid invocations", async () => {
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||
|
||||
const warnings: string[] = [];
|
||||
const warningHandler = (warning: unknown) => {
|
||||
warnings.push(String(warning));
|
||||
};
|
||||
const baselineSigint = process.listenerCount("SIGINT");
|
||||
const baselineSigterm = process.listenerCount("SIGTERM");
|
||||
|
||||
process.on("warning", warningHandler);
|
||||
|
||||
try {
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const { dispose } = await runDashboard(0, { open: false });
|
||||
dispose();
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
} finally {
|
||||
process.removeListener("warning", warningHandler);
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const { dispose } = await runDashboard(0, { open: false });
|
||||
dispose();
|
||||
}
|
||||
|
||||
expect(warnings.some((warning) => warning.includes("MaxListenersExceededWarning"))).toBe(false);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(process.listenerCount("SIGINT")).toBe(baselineSigint);
|
||||
expect(process.listenerCount("SIGTERM")).toBe(baselineSigterm);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -392,9 +392,27 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
let mergeRunning = false;
|
||||
const maxAutoMergeRetries = 3;
|
||||
|
||||
function canAutoMergeTask(task: { mergeRetries?: number | null; column: string; paused?: boolean; status?: string | null; error?: string | null; steps?: Array<{ status: string }>; workflowStepResults?: Array<{ status: string }> }): boolean {
|
||||
function hasAutoHealableVerificationBufferFailure(task: {
|
||||
mergeRetries?: number | null;
|
||||
column: string;
|
||||
error?: string | null;
|
||||
log?: Array<{ action?: string }>;
|
||||
}): boolean {
|
||||
if (task.column !== "in-review") return false;
|
||||
if ((task.mergeRetries ?? 0) < maxAutoMergeRetries) return false;
|
||||
if (!task.error?.includes("Deterministic test verification failed")) return false;
|
||||
|
||||
return task.log?.some((entry) =>
|
||||
entry.action?.includes("[verification] test command failed (exit 0)")
|
||||
|| entry.action?.includes("[verification] build command failed (exit 0)")
|
||||
|| entry.action?.includes("output exceeded buffer"),
|
||||
) ?? false;
|
||||
}
|
||||
|
||||
function canAutoMergeTask(task: { mergeRetries?: number | null; column: string; paused?: boolean; status?: string | null; error?: string | null; steps?: Array<{ status: string }>; workflowStepResults?: Array<{ status: string }>; log?: Array<{ action?: string }> }): boolean {
|
||||
if (getTaskMergeBlocker(task as any)) return false;
|
||||
return (task.mergeRetries ?? 0) < maxAutoMergeRetries;
|
||||
return (task.mergeRetries ?? 0) < maxAutoMergeRetries
|
||||
|| hasAutoHealableVerificationBufferFailure(task);
|
||||
}
|
||||
|
||||
/** Enqueue a task for auto-merge if not already queued/active. */
|
||||
@@ -428,6 +446,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
if (!canAutoMergeTask(task as any)) {
|
||||
continue;
|
||||
}
|
||||
if (hasAutoHealableVerificationBufferFailure(task as any)) {
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
"Auto-healing stale deterministic verification buffer failure; retrying merge verification",
|
||||
);
|
||||
await store.updateTask(taskId, { mergeRetries: 0, error: null, status: null });
|
||||
}
|
||||
const mergeStrategy = getMergeStrategy(settings);
|
||||
if (mergeStrategy === "pull-request") {
|
||||
console.log(`[auto-merge] Processing PR flow for ${taskId}...`);
|
||||
@@ -852,7 +877,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) => !getTaskMergeBlocker(t));
|
||||
const inReview = existing.filter((t) => canAutoMergeTask(t as any));
|
||||
if (inReview.length > 0) {
|
||||
console.log(
|
||||
`[auto-merge] Startup sweep: enqueueing ${inReview.length} in-review task(s)`,
|
||||
@@ -888,7 +913,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
for (const t of tasks) {
|
||||
if (!getTaskMergeBlocker(t)) {
|
||||
if (canAutoMergeTask(t as any)) {
|
||||
enqueueMerge(t.id);
|
||||
}
|
||||
}
|
||||
@@ -912,7 +937,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
for (const t of tasks) {
|
||||
if (!getTaskMergeBlocker(t)) {
|
||||
if (canAutoMergeTask(t as any)) {
|
||||
enqueueMerge(t.id);
|
||||
}
|
||||
}
|
||||
@@ -961,8 +986,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// ── Periodic retry: catch failed merges on each poll cycle ────────
|
||||
// Uses a setTimeout chain so the interval dynamically follows
|
||||
// settings.pollIntervalMs without requiring an engine restart.
|
||||
// The readiness predicate uses getTaskMergeBlocker() to detect tasks that
|
||||
// have become unblocked (e.g., awaiting-user-review cleared by user).
|
||||
// The readiness predicate uses canAutoMergeTask() to detect tasks that
|
||||
// have become unblocked while respecting retry limits.
|
||||
async function scheduleMergeRetry(): Promise<void> {
|
||||
if (disposed) return;
|
||||
const currentSettings = await store.getSettings().catch(() => settings);
|
||||
@@ -976,7 +1001,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 (!getTaskMergeBlocker(t)) {
|
||||
if (canAutoMergeTask(t as any)) {
|
||||
enqueueMerge(t.id);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user