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

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Improve soft-deleted blocker recovery so blocked tasks become schedulable without manual intervention. `SelfHealingManager.clearStaleBlockedBy` now emits an explicit `soft-deleted at ...` reason when a stale blocker is a soft-deleted row, and the scheduler now reconciles downstream `blockedBy`/dependency state immediately on `task:deleted` events to reblock on remaining live deps or unblock tasks in the same tick.

View File

@@ -75,5 +75,6 @@ No public API flag exposes deleted-task forensics today. Adding one requires a n
## Notes
- FN-5496: `task:deleted` now triggers immediate scheduler-side downstream `blockedBy` reconciliation for `todo`/`in-progress` dependents, and stale-blockedBy self-healing logs now discriminate soft-deleted blockers with `soft-deleted at ...` reasons.
- Store-level soft-delete invariants live in `packages/core/src/store.ts` (`ACTIVE_TASKS_WHERE`, `deleteTask`, `readTaskFromDb(..., { includeDeleted: true })`) and `packages/core/src/__tests__/soft-delete-tasks.test.ts`.
- Cross-reference `docs/storage.md` for the persisted-row model; FN-5140 and FN-5143 own the storage-doc deltas for document and agent-log visibility.

View File

@@ -423,6 +423,15 @@ describe("getTaskCompletionBlocker", () => {
}, { resolveTask })).resolves.toBeUndefined();
});
it("treats soft-deleted blockedBy as non-blocking when resolveTask returns null", async () => {
const resolveTask = async (_taskId: string) => null;
await expect(getTaskCompletionBlocker({
...baseCompletionTask,
blockedBy: "FN-SOFT-DELETED",
}, { resolveTask })).resolves.toBeUndefined();
});
it.each(["done", "archived"] as const)("ignores blockedBy when resolveTask reports the blocker is %s", async (column) => {
const resolveTask = async () => ({ id: "FN-4054", column });

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";

View File

@@ -574,6 +574,58 @@ export class Scheduler {
this.wasNodeBlocked.delete(task.id);
this.wasPermanentAgentUnavailable.delete(task.id);
this.clearDispatchQueuedReasonMemo(task.id);
void (async () => {
try {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) {
return;
}
const todoTasks = await this.store.listTasks({ column: "todo", slim: true });
const inProgressTasks = await this.store.listTasks({ column: "in-progress", slim: true });
const dependents = [...todoTasks, ...inProgressTasks];
const allTasks = await this.store.listTasks({ slim: true, includeArchived: true });
const taskById = new Map(allTasks.map((candidate) => [candidate.id, candidate]));
for (const dependent of dependents) {
const mentionsDeletedTask = dependent.dependencies.includes(task.id);
const currentlyBlockedByDeletedTask = dependent.blockedBy === task.id;
if (!mentionsDeletedTask && !currentlyBlockedByDeletedTask) continue;
const unresolvedDeps = dependent.dependencies.filter((depId) => {
const dep = taskById.get(depId);
return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived";
});
try {
if (unresolvedDeps.length > 0) {
const nextBlocker = unresolvedDeps[0]!;
await this.store.updateTask(dependent.id, {
blockedBy: nextBlocker,
status: "queued",
});
await this.store.logEntry(
dependent.id,
`Auto-reblocked (FN-5496): unresolved dependency ${nextBlocker} remains after blocker ${task.id} was soft-deleted`,
);
} else if (dependent.column === "todo") {
await this.store.updateTask(dependent.id, { blockedBy: null, status: null });
await this.store.logEntry(dependent.id, `Auto-unblocked (FN-5496): blocker ${task.id} was soft-deleted`);
} else {
await this.store.updateTask(dependent.id, { blockedBy: null });
await this.store.logEntry(dependent.id, `Auto-unblocked (FN-5496): blocker ${task.id} was soft-deleted`);
}
} catch (error) {
schedulerLog.error(`Failed to reconcile dependent ${dependent.id} for soft-deleted blocker ${task.id}`, error);
}
}
this.schedule();
} catch (error) {
schedulerLog.error(`Failed event-driven soft-delete blocker reconciliation for ${task.id}`, error);
}
})();
});
}

View File

@@ -3597,6 +3597,8 @@ export class SelfHealingManager {
const unresolvedDeps = task.dependencies.filter((depId) => {
const dep = taskById.get(depId);
// listTasks excludes soft-deleted rows, so missing dependency IDs are
// treated as resolved here by design.
return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived";
});
const overlapBlocker = task.overlapBlockedBy ? taskById.get(task.overlapBlockedBy) : undefined;
@@ -3613,8 +3615,21 @@ export class SelfHealingManager {
let reasonCode: string | null = null;
if (!blocker) {
reasonCode = "missing-blocker";
reason = `blocker ${blockerId} missing`;
let softDeletedBlocker: Task | null = null;
try {
const maybeDeleted = await this.store.getTask(blockerId, { includeDeleted: true });
softDeletedBlocker = maybeDeleted.deletedAt ? maybeDeleted : null;
} catch {
softDeletedBlocker = null;
}
if (softDeletedBlocker?.deletedAt) {
reasonCode = "soft-deleted-blocker";
reason = `blocker ${blockerId} soft-deleted at ${softDeletedBlocker.deletedAt}`;
} else {
reasonCode = "missing-blocker";
reason = `blocker ${blockerId} missing`;
}
} else if (blocker.column === "done") {
reasonCode = "blocker-done";
reason = `blocker ${blockerId} is done`;