feat(FN-5496): reconcile soft-deleted tasks with active blockers

Added soft-delete blocker recovery logic to the scheduler and self-healing systems, enabling reconciliation of stale blocker reasons when tasks are archived or restored. The changes include corresponding tests for the completion guard behavior, scheduler recovery paths, and self-healing integration,

Fusion-Task-Id: FN-5496
This commit is contained in:
Fusion (runfusion.ai)
2026-05-22 10:58:00 -07:00
committed by gsxdsm
parent ba066c88a3
commit c3890a9b43
7 changed files with 263 additions and 2 deletions

View File

@@ -496,6 +496,88 @@ describe("Scheduler", () => {
expect(store.on).toHaveBeenCalledWith("task:moved", expect.any(Function));
});
it("FN-5496: task:deleted immediately unblocks dependents in same tick", async () => {
const deleted = createMockTask({ id: "FN-DEL", column: "todo" });
const dependent = createMockTask({ id: "FN-DEP", column: "todo", blockedBy: "FN-DEL", dependencies: ["FN-DEL"] });
const tasks = [dependent];
const listTasks = vi.fn(async (options?: { column?: string; includeArchived?: boolean }) => {
if (options?.column === "todo") return tasks.filter((task) => task.column === "todo");
if (options?.column === "in-progress") return tasks.filter((task) => task.column === "in-progress");
return tasks;
});
const store = createMockStore({
listTasks,
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, globalPause: false, enginePaused: false }),
});
new Scheduler(store);
const deletedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:deleted")?.[1];
deletedHandler(deleted);
await flushAsyncWork();
expect(store.updateTask).toHaveBeenCalledWith("FN-DEP", { blockedBy: null, status: null });
expect(store.logEntry).toHaveBeenCalledWith("FN-DEP", "Auto-unblocked (FN-5496): blocker FN-DEL was soft-deleted");
});
it("FN-5496: task:deleted repoints blockedBy when another dependency remains unresolved", async () => {
const deleted = createMockTask({ id: "FN-DEL", column: "todo" });
const live = createMockTask({ id: "FN-LIVE", column: "in-progress" });
const dependent = createMockTask({
id: "FN-DEP",
column: "todo",
status: "queued",
blockedBy: "FN-DEL",
dependencies: ["FN-DEL", "FN-LIVE"],
});
const tasks = [dependent, live];
const listTasks = vi.fn(async (options?: { column?: string; includeArchived?: boolean }) => {
if (options?.column === "todo") return tasks.filter((task) => task.column === "todo");
if (options?.column === "in-progress") return tasks.filter((task) => task.column === "in-progress");
return tasks;
});
const store = createMockStore({
listTasks,
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, globalPause: false, enginePaused: false }),
});
new Scheduler(store);
const deletedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:deleted")?.[1];
deletedHandler(deleted);
await flushAsyncWork();
expect(store.updateTask).toHaveBeenCalledWith("FN-DEP", { blockedBy: "FN-LIVE", status: "queued" });
expect(store.logEntry).toHaveBeenCalledWith(
"FN-DEP",
"Auto-reblocked (FN-5496): unresolved dependency FN-LIVE remains after blocker FN-DEL was soft-deleted",
);
});
it("FN-5496: task:deleted reconciliation is skipped when engine is paused", async () => {
const deleted = createMockTask({ id: "FN-DEL", column: "todo" });
const dependent = createMockTask({ id: "FN-DEP", column: "todo", blockedBy: "FN-DEL", dependencies: ["FN-DEL"] });
const tasks = [dependent];
const listTasks = vi.fn(async (options?: { column?: string; includeArchived?: boolean }) => {
if (options?.column === "todo") return tasks.filter((task) => task.column === "todo");
if (options?.column === "in-progress") return tasks.filter((task) => task.column === "in-progress");
return tasks;
});
const store = createMockStore({
listTasks,
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, globalPause: false, enginePaused: true }),
});
new Scheduler(store);
const deletedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:deleted")?.[1];
deletedHandler(deleted);
await flushAsyncWork();
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.logEntry).not.toHaveBeenCalled();
});
it("triggers scheduling immediately when task:moved to done event fires", async () => {
// Mock filesystem validation so schedule() can proceed
vi.mocked(existsSync).mockReturnValue(true);

View File

@@ -5837,6 +5837,103 @@ describe("clearStaleBlockedBy", () => {
manager.stop();
});
it("clears stale blockedBy with explicit reason when blocker is soft-deleted", async () => {
const store = createRunningStore();
const deletedAt = "2026-05-22T00:00:00.000Z";
(store.getTask as ReturnType<typeof vi.fn>).mockImplementation(async (id: string, options?: { includeDeleted?: boolean }) => {
if (id === "FN-DELETED" && options?.includeDeleted) {
return createTask("FN-DELETED", { deletedAt }) as unknown as Task;
}
throw new Error(`Task ${id} not found`);
});
const taskA = createTask("A", { blockedBy: "FN-DELETED" });
mockSweepTasks(store, { todo: [taskA] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
expect(recovered).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("A", { blockedBy: null, overlapBlockedBy: null, status: null });
expect(store.logEntry).toHaveBeenCalledWith("A", expect.stringContaining("soft-deleted at 2026-05-22T00:00:00.000Z"));
manager.stop();
});
it("clears stale blockedBy for in-progress task when blocker is soft-deleted", async () => {
const store = createRunningStore();
const deletedAt = "2026-05-22T00:00:00.000Z";
(store.getTask as ReturnType<typeof vi.fn>).mockImplementation(async (id: string, options?: { includeDeleted?: boolean }) => {
if (id === "FN-DELETED" && options?.includeDeleted) {
return createTask("FN-DELETED", { deletedAt }) as unknown as Task;
}
throw new Error(`Task ${id} not found`);
});
const taskA = createTask("A", { column: "in-progress", blockedBy: "FN-DELETED" });
mockSweepTasks(store, { inProgress: [taskA], all: [taskA] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
expect(recovered).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("A", { blockedBy: null });
expect(store.logEntry).toHaveBeenCalledWith("A", expect.stringContaining("Auto-recovered (FN-4091): cleared stale blockedBy — blocker FN-DELETED soft-deleted at 2026-05-22T00:00:00.000Z"));
manager.stop();
});
it("refreshes to next live dependency when one dependency is soft-deleted", async () => {
const store = createRunningStore();
const deletedAt = "2026-05-22T00:00:00.000Z";
(store.getTask as ReturnType<typeof vi.fn>).mockImplementation(async (id: string, options?: { includeDeleted?: boolean }) => {
if (id === "FN-DELETED" && options?.includeDeleted) {
return createTask("FN-DELETED", { deletedAt }) as unknown as Task;
}
throw new Error(`Task ${id} not found`);
});
const taskA = createTask("A", { blockedBy: "FN-DELETED", status: "queued", dependencies: ["FN-DELETED", "FN-LIVE"] });
const liveBlocker = createTask("FN-LIVE", { column: "todo" });
mockSweepTasks(store, { todo: [taskA, liveBlocker], all: [taskA, liveBlocker] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
expect(recovered).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("A", { blockedBy: "FN-LIVE", status: "queued" });
expect(store.logEntry).toHaveBeenCalledWith("A", expect.stringContaining("soft-deleted"));
expect(store.logEntry).toHaveBeenCalledWith("A", expect.stringContaining("now blocked by FN-LIVE"));
manager.stop();
});
it("is idempotent after recovering soft-deleted blockers", async () => {
const store = createRunningStore();
const deletedAt = "2026-05-22T00:00:00.000Z";
(store.getTask as ReturnType<typeof vi.fn>).mockImplementation(async (id: string, options?: { includeDeleted?: boolean }) => {
if (id === "FN-DELETED" && options?.includeDeleted) {
return createTask("FN-DELETED", { deletedAt }) as unknown as Task;
}
throw new Error(`Task ${id} not found`);
});
const taskA = createTask("A", { blockedBy: "FN-DELETED" });
mockSweepTasks(store, { todo: [taskA], all: [taskA] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const firstRecovered = await manager.clearStaleBlockedBy();
expect(firstRecovered).toBe(1);
const healedTask = createTask("A", { blockedBy: null, status: null });
mockSweepTasks(store, { todo: [healedTask], all: [healedTask] });
(store.updateTask as ReturnType<typeof vi.fn>).mockClear();
(store.logEntry as ReturnType<typeof vi.fn>).mockClear();
const secondRecovered = await manager.clearStaleBlockedBy();
expect(secondRecovered).toBe(0);
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.logEntry).not.toHaveBeenCalled();
manager.stop();
});
it.each(["done", "archived"] as const)("clears stale blockedBy when blocker is %s", async (column) => {
const store = createRunningStore();
const blockerId = "FN-100";