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:
committed by
gsxdsm
parent
ba066c88a3
commit
c3890a9b43
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user