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
5
.changeset/fn-5496-soft-deleted-blocker-recovery.md
Normal file
5
.changeset/fn-5496-soft-deleted-blocker-recovery.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user