test(FN-4662): complete Step 3 — add starvation sweep coverage
Fusion-Task-Id: FN-4662 Fusion-Task-Lineage: f3de365a-ccb5-404b-9d0c-f9bc70d2678a
This commit is contained in:
committed by
gsxdsm
parent
8a01ba0af8
commit
cdd85ba235
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { SelfHealingManager } from "../../self-healing.js";
|
||||
import { TriageProcessor } from "../../triage.js";
|
||||
|
||||
function task(overrides: Partial<Task> & Pick<Task, "id">): Task {
|
||||
return {
|
||||
id: overrides.id,
|
||||
title: overrides.id,
|
||||
description: overrides.id,
|
||||
priority: "normal",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
status: null,
|
||||
createdAt: "2026-05-15T10:00:00.000Z",
|
||||
updatedAt: "2026-05-15T10:00:00.000Z",
|
||||
columnMovedAt: "2026-05-15T10:00:00.000Z",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("reliability interaction: starved refinement x approval gate", () => {
|
||||
it("keeps requirePlanApproval flow after starvation escalation", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "fusion-fn4662-approval-"));
|
||||
try {
|
||||
const updates: Array<Record<string, unknown>> = [];
|
||||
const moves: string[] = [];
|
||||
const refinement = task({ id: "FN-RA", sourceType: "task_refine" });
|
||||
const tasks: Task[] = [
|
||||
refinement,
|
||||
task({ id: "FN-P1", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:15:00.000Z" }),
|
||||
task({ id: "FN-P2", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:16:00.000Z" }),
|
||||
task({ id: "FN-P3", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:17:00.000Z" }),
|
||||
];
|
||||
|
||||
const store: any = {
|
||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||
updateTask: vi.fn().mockImplementation(async (_id: string, patch: Record<string, unknown>) => {
|
||||
updates.push(patch);
|
||||
}),
|
||||
moveTask: vi.fn().mockImplementation(async (_id: string, to: string) => {
|
||||
moves.push(to);
|
||||
}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
|
||||
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
removeListener: () => {},
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-15T11:00:00.000Z"));
|
||||
const manager = new SelfHealingManager(store, { rootDir: root, getPlanningTaskIds: () => new Set() });
|
||||
await expect(manager.recoverStarvedRefinementTriageTasks()).resolves.toBe(1);
|
||||
|
||||
const taskDir = join(root, ".fusion", "tasks", "FN-RA");
|
||||
await mkdir(taskDir, { recursive: true });
|
||||
await writeFile(join(taskDir, "PROMPT.md"), "# FN-RA\n\n## File Scope\n- packages/engine/src/self-healing.ts\n", "utf8");
|
||||
|
||||
const triage = new TriageProcessor(store, root);
|
||||
await (triage as any).finalizeApprovedTask(
|
||||
refinement,
|
||||
"# FN-RA\n\n## File Scope\n- packages/engine/src/self-healing.ts\n",
|
||||
{ requirePlanApproval: true },
|
||||
);
|
||||
|
||||
expect(updates.some((u) => u.priority === "high")).toBe(true);
|
||||
expect(updates.some((u) => u.status === "awaiting-approval")).toBe(true);
|
||||
expect(moves).toEqual([]);
|
||||
vi.useRealTimers();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { SelfHealingManager } from "../../self-healing.js";
|
||||
import { TriageProcessor } from "../../triage.js";
|
||||
|
||||
function triageTask(overrides: Partial<Task> & Pick<Task, "id">): Task {
|
||||
return {
|
||||
id: overrides.id,
|
||||
title: overrides.id,
|
||||
description: overrides.id,
|
||||
priority: "low",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
status: null,
|
||||
createdAt: "2026-05-15T10:00:00.000Z",
|
||||
updatedAt: "2026-05-15T10:00:00.000Z",
|
||||
columnMovedAt: "2026-05-15T10:00:00.000Z",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("reliability interaction: starved refinement x triage poll", () => {
|
||||
it("escalation surfaces a previously-starved refinement to poll within bounded ticks", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "fusion-fn4662-poll-"));
|
||||
await mkdir(join(root, ".fusion", "tasks"), { recursive: true });
|
||||
|
||||
try {
|
||||
const tasks: Task[] = [
|
||||
triageTask({ id: "FN-R1", sourceType: "task_refine" }),
|
||||
...Array.from({ length: 6 }, (_, idx) => triageTask({ id: `FN-B${idx + 1}`, createdAt: `2026-05-15T09:${String(10 + idx).padStart(2, "0")}:00.000Z`, priority: "normal" })),
|
||||
triageTask({ id: "FN-P1", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:15:00.000Z" }),
|
||||
triageTask({ id: "FN-P2", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:16:00.000Z" }),
|
||||
triageTask({ id: "FN-P3", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:17:00.000Z" }),
|
||||
];
|
||||
|
||||
const store: any = {
|
||||
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 1, maxTriageConcurrent: 1, pollIntervalMs: 10_000, globalPause: false, enginePaused: false }),
|
||||
listTasks: vi.fn().mockImplementation(async () => tasks.map((t) => ({ ...t }))),
|
||||
updateTask: vi.fn().mockImplementation(async (id: string, patch: Partial<Task>) => {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
tasks[idx] = { ...tasks[idx], ...patch, updatedAt: new Date().toISOString() } as Task;
|
||||
}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
removeListener: () => {},
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-15T11:00:00.000Z"));
|
||||
const manager = new SelfHealingManager(store, { rootDir: root, getPlanningTaskIds: () => new Set() });
|
||||
await expect(manager.recoverStarvedRefinementTriageTasks()).resolves.toBe(1);
|
||||
|
||||
const triage = new TriageProcessor(store, root);
|
||||
const specifySpy = vi.spyOn(triage, "specifyTask").mockImplementation(async (task) => {
|
||||
const idx = tasks.findIndex((t) => t.id === task.id);
|
||||
if (idx >= 0) tasks[idx] = { ...tasks[idx], column: "todo" };
|
||||
});
|
||||
|
||||
(triage as any).running = true;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await (triage as any).poll();
|
||||
}
|
||||
|
||||
expect(specifySpy.mock.calls.some(([t]) => t.id === "FN-R1")).toBe(true);
|
||||
expect(tasks.find((t) => t.id === "FN-R1")?.column).toBe("todo");
|
||||
vi.useRealTimers();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { SelfHealingManager } from "../self-healing.js";
|
||||
import { TriageProcessor } from "../triage.js";
|
||||
|
||||
function task(overrides: Partial<Task> & Pick<Task, "id">): Task {
|
||||
return {
|
||||
id: overrides.id,
|
||||
title: overrides.id,
|
||||
description: overrides.id,
|
||||
column: "triage",
|
||||
priority: "normal",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
status: null,
|
||||
createdAt: "2026-05-15T10:00:00.000Z",
|
||||
updatedAt: "2026-05-15T10:00:00.000Z",
|
||||
columnMovedAt: "2026-05-15T10:00:00.000Z",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("SelfHealingManager.recoverStarvedRefinementTriageTasks", () => {
|
||||
it("escalates starved refinements once and emits run-audit", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-15T11:00:00.000Z"));
|
||||
|
||||
const tasks: Task[] = [
|
||||
task({ id: "FN-R1", sourceType: "task_refine", createdAt: "2026-05-15T10:00:00.000Z", updatedAt: "2026-05-15T10:00:00.000Z", priority: "low" }),
|
||||
task({ id: "FN-P1", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:15:00.000Z" }),
|
||||
task({ id: "FN-P2", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:16:00.000Z" }),
|
||||
task({ id: "FN-P3", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:17:00.000Z" }),
|
||||
];
|
||||
|
||||
const updateTask = vi.fn(async (id: string, patch: Partial<Task>) => {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
tasks[idx] = { ...tasks[idx], ...patch, updatedAt: new Date().toISOString() } as Task;
|
||||
});
|
||||
const recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
|
||||
const store: any = {
|
||||
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
|
||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||
updateTask,
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
recordRunAuditEvent,
|
||||
on: () => {},
|
||||
removeListener: () => {},
|
||||
};
|
||||
|
||||
const manager = new SelfHealingManager(store, { rootDir: process.cwd(), getPlanningTaskIds: () => new Set() });
|
||||
await expect(manager.recoverStarvedRefinementTriageTasks()).resolves.toBe(1);
|
||||
await expect(manager.recoverStarvedRefinementTriageTasks()).resolves.toBe(0);
|
||||
|
||||
expect(updateTask).toHaveBeenCalledTimes(1);
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-R1", { priority: "normal" });
|
||||
expect(recordRunAuditEvent).toHaveBeenCalledTimes(1);
|
||||
expect(recordRunAuditEvent.mock.calls[0][0]).toMatchObject({ mutationType: "task:auto-recover-starved-refinement", target: "FN-R1" });
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not escalate non-refinement triage tasks", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-15T11:00:00.000Z"));
|
||||
const store: any = {
|
||||
listTasks: vi.fn().mockResolvedValue([
|
||||
task({ id: "FN-NON", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:00:00.000Z" }),
|
||||
task({ id: "FN-P1", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:15:00.000Z" }),
|
||||
task({ id: "FN-P2", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:16:00.000Z" }),
|
||||
task({ id: "FN-P3", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:17:00.000Z" }),
|
||||
]),
|
||||
updateTask: vi.fn(),
|
||||
logEntry: vi.fn(),
|
||||
recordRunAuditEvent: vi.fn(),
|
||||
on: () => {},
|
||||
removeListener: () => {},
|
||||
};
|
||||
const manager = new SelfHealingManager(store, { rootDir: process.cwd(), getPlanningTaskIds: () => new Set() });
|
||||
await expect(manager.recoverStarvedRefinementTriageTasks()).resolves.toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not escalate refinements under grace", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-15T11:00:00.000Z"));
|
||||
const store: any = {
|
||||
listTasks: vi.fn().mockResolvedValue([
|
||||
task({ id: "FN-YOUNG", sourceType: "task_refine", createdAt: "2026-05-15T10:55:00.000Z", updatedAt: "2026-05-15T10:55:00.000Z" }),
|
||||
task({ id: "FN-P1", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:56:00.000Z" }),
|
||||
task({ id: "FN-P2", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:57:00.000Z" }),
|
||||
task({ id: "FN-P3", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:58:00.000Z" }),
|
||||
]),
|
||||
updateTask: vi.fn(),
|
||||
logEntry: vi.fn(),
|
||||
recordRunAuditEvent: vi.fn(),
|
||||
on: () => {},
|
||||
removeListener: () => {},
|
||||
};
|
||||
const manager = new SelfHealingManager(store, { rootDir: process.cwd(), getPlanningTaskIds: () => new Set() });
|
||||
await expect(manager.recoverStarvedRefinementTriageTasks()).resolves.toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not escalate aged refinements when peer progress threshold is not met", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-15T11:00:00.000Z"));
|
||||
const store: any = {
|
||||
listTasks: vi.fn().mockResolvedValue([
|
||||
task({ id: "FN-IDLE", sourceType: "task_refine", createdAt: "2026-05-15T10:00:00.000Z", updatedAt: "2026-05-15T10:00:00.000Z" }),
|
||||
]),
|
||||
updateTask: vi.fn(),
|
||||
logEntry: vi.fn(),
|
||||
recordRunAuditEvent: vi.fn(),
|
||||
on: () => {},
|
||||
removeListener: () => {},
|
||||
};
|
||||
const manager = new SelfHealingManager(store, { rootDir: process.cwd(), getPlanningTaskIds: () => new Set() });
|
||||
await expect(manager.recoverStarvedRefinementTriageTasks()).resolves.toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not escalate paused refinements", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-15T11:00:00.000Z"));
|
||||
const store: any = {
|
||||
listTasks: vi.fn().mockResolvedValue([
|
||||
task({ id: "FN-PAUSED", sourceType: "task_refine", paused: true, createdAt: "2026-05-15T10:00:00.000Z", updatedAt: "2026-05-15T10:00:00.000Z" }),
|
||||
task({ id: "FN-P1", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:15:00.000Z" }),
|
||||
task({ id: "FN-P2", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:16:00.000Z" }),
|
||||
task({ id: "FN-P3", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:17:00.000Z" }),
|
||||
]),
|
||||
updateTask: vi.fn(),
|
||||
logEntry: vi.fn(),
|
||||
recordRunAuditEvent: vi.fn(),
|
||||
on: () => {},
|
||||
removeListener: () => {},
|
||||
};
|
||||
const manager = new SelfHealingManager(store, { rootDir: process.cwd(), getPlanningTaskIds: () => new Set() });
|
||||
await expect(manager.recoverStarvedRefinementTriageTasks()).resolves.toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("preserves approval gate flow (never direct todo) after escalation", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "fusion-fn4662-"));
|
||||
try {
|
||||
const refinement = task({ id: "FN-RG", sourceType: "task_refine" });
|
||||
const updateTask = vi.fn().mockResolvedValue(undefined);
|
||||
const moveTask = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const store: any = {
|
||||
listTasks: vi.fn().mockResolvedValue([
|
||||
refinement,
|
||||
task({ id: "FN-P1", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:15:00.000Z" }),
|
||||
task({ id: "FN-P2", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:16:00.000Z" }),
|
||||
task({ id: "FN-P3", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:17:00.000Z" }),
|
||||
]),
|
||||
updateTask,
|
||||
moveTask,
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
|
||||
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
removeListener: () => {},
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-15T11:00:00.000Z"));
|
||||
const manager = new SelfHealingManager(store, { rootDir: root, getPlanningTaskIds: () => new Set() });
|
||||
await manager.recoverStarvedRefinementTriageTasks();
|
||||
expect(moveTask).not.toHaveBeenCalled();
|
||||
|
||||
const taskDir = join(root, ".fusion", "tasks", "FN-RG");
|
||||
await mkdir(taskDir, { recursive: true });
|
||||
await writeFile(join(taskDir, "PROMPT.md"), "# FN-RG\n\n## File Scope\n- packages/engine/src/self-healing.ts\n", "utf-8");
|
||||
const processor = new TriageProcessor(store, root);
|
||||
await (processor as any).finalizeApprovedTask(refinement, "# FN-RG\n\n## File Scope\n- packages/engine/src/self-healing.ts\n", { requirePlanApproval: true });
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-RG", expect.objectContaining({ status: "awaiting-approval" }));
|
||||
expect(moveTask).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user