feat(FN-5566): add soft-delete cleanup sweep for blocker residue

Added soft-delete reliability sweeps and guardrails to prevent blocker residue from persisting across delete operations, including column drift detection, deleted row sweep guards, and in-progress delete reconciliation, with comprehensive test coverage and documentation updates to the soft-delete ve

Fusion-Task-Id: FN-5566

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5566
This commit is contained in:
gsxdsm
2026-05-23 21:46:52 -07:00
parent 41727cd3bb
commit 9b7e87667b
9 changed files with 380 additions and 5 deletions

View File

@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore deleteTask blocker residue rewrite (FN-5566)", () => {
const harness = createTaskStoreTestHarness();
beforeEach(async () => {
await harness.beforeEach();
});
afterEach(async () => {
await harness.afterEach();
});
it("clears dependencies + blockedBy + status and appends auto-unblocked log when blocker is referenced by both", async () => {
const store = harness.store();
const blocker = await store.createTask({ column: "todo", description: "blocker" });
const dependent = await store.createTask({ column: "todo", description: "dependent", dependencies: [blocker.id] });
await store.updateTask(dependent.id, { blockedBy: blocker.id, status: "queued" });
await store.deleteTask(blocker.id, { removeDependencyReferences: true });
const updated = await store.getTask(dependent.id);
expect(updated.dependencies).not.toContain(blocker.id);
expect(updated.blockedBy).toBeUndefined();
expect(updated.status).toBeUndefined();
expect(updated.log.some((entry) => entry.action === `Auto-unblocked: blocker ${blocker.id} was soft-deleted`)).toBe(true);
});
it("clears blockedBy-only residue while preserving dependencies", async () => {
const store = harness.store();
const blocker = await store.createTask({ column: "todo", description: "blocker" });
const other = await store.createTask({ column: "todo", description: "other" });
const dependent = await store.createTask({ column: "todo", description: "dependent", dependencies: [other.id] });
await store.updateTask(dependent.id, { blockedBy: blocker.id, status: "queued" });
await store.deleteTask(blocker.id, { removeDependencyReferences: true });
const updated = await store.getTask(dependent.id);
expect(updated.dependencies).toEqual([other.id]);
expect(updated.blockedBy).toBeUndefined();
expect(updated.status).toBeUndefined();
expect(updated.log.some((entry) => entry.action === `Auto-unblocked: blocker ${blocker.id} was soft-deleted`)).toBe(true);
});
it("filters dependency without adding auto-unblocked log when blockedBy is already null", async () => {
const store = harness.store();
const blocker = await store.createTask({ column: "todo", description: "blocker" });
const dependent = await store.createTask({ column: "todo", description: "dependent", dependencies: [blocker.id] });
await store.deleteTask(blocker.id, { removeDependencyReferences: true });
const updated = await store.getTask(dependent.id);
expect(updated.dependencies).toEqual([]);
expect(updated.blockedBy).toBeUndefined();
expect(updated.log.some((entry) => entry.action === `Auto-unblocked: blocker ${blocker.id} was soft-deleted`)).toBe(false);
});
it("leaves unrelated tasks untouched", async () => {
const store = harness.store();
const blocker = await store.createTask({ column: "todo", description: "blocker" });
const unrelated = await store.createTask({ column: "todo", description: "unrelated", dependencies: [] });
await store.deleteTask(blocker.id, { removeDependencyReferences: true });
const after = await store.getTask(unrelated.id);
expect(after.blockedBy).toBeUndefined();
expect(after.dependencies).toEqual([]);
expect(after.log.some((entry) => entry.action.includes("Auto-unblocked"))).toBe(false);
});
it("never rewrites already soft-deleted dependents", async () => {
const store = harness.store();
const blocker = await store.createTask({ column: "todo", description: "blocker" });
const dependent = await store.createTask({ column: "todo", description: "dependent", dependencies: [blocker.id] });
await store.updateTask(dependent.id, { blockedBy: blocker.id, status: "queued" });
await store.deleteTask(dependent.id);
const deletedDependentBefore = await store.getTask(dependent.id, { includeDeleted: true });
await store.deleteTask(blocker.id, { removeDependencyReferences: true });
const deletedDependentAfter = await store.getTask(dependent.id, { includeDeleted: true });
expect(deletedDependentAfter.updatedAt).toBe(deletedDependentBefore.updatedAt);
expect(deletedDependentAfter.blockedBy).toBe(blocker.id);
});
it("is idempotent and does not emit extra dependent updates on re-delete", async () => {
const store = harness.store();
const blocker = await store.createTask({ column: "todo", description: "blocker" });
const dependent = await store.createTask({ column: "todo", description: "dependent", dependencies: [blocker.id] });
await store.updateTask(dependent.id, { blockedBy: blocker.id, status: "queued" });
const updatedEvents: string[] = [];
store.on("task:updated", (task) => {
if (task.id === dependent.id) updatedEvents.push(task.id);
});
await store.deleteTask(blocker.id, { removeDependencyReferences: true });
const afterFirst = await store.getTask(dependent.id);
await store.deleteTask(blocker.id, { removeDependencyReferences: true });
const afterSecond = await store.getTask(dependent.id);
expect(afterSecond.updatedAt).toBe(afterFirst.updatedAt);
expect(updatedEvents.length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -6824,9 +6824,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
let rewrittenDependents: Task[] = [];
let rewrittenBlockedByResidueDependents: Task[] = [];
let rewrittenLineageChildren: Task[] = [];
this.db.transaction(() => {
rewrittenDependents = this.rewriteDependentsForRemoval(id, dependentIds);
rewrittenBlockedByResidueDependents = this.rewriteBlockedByResidueDependentsForRemoval(id, new Set(dependentIds));
rewrittenLineageChildren = this.rewriteLineageChildrenForRemoval(id, lineageChildIds);
const deletedAt = new Date().toISOString();
const allowResurrection = options?.allowResurrection === true ? 1 : 0;
@@ -6873,6 +6875,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
for (const dependentTask of rewrittenDependents) {
this.emit("task:updated", dependentTask);
}
for (const dependentTask of rewrittenBlockedByResidueDependents) {
this.emit("task:updated", dependentTask);
}
for (const lineageChild of rewrittenLineageChildren) {
this.emit("task:updated", lineageChild);
}
@@ -6901,18 +6906,34 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (!dependentTask) continue;
const nextDependencies = dependentTask.dependencies.filter((dependencyId) => dependencyId !== taskId);
if (nextDependencies.length === dependentTask.dependencies.length) {
const clearsBlockedBy = dependentTask.blockedBy === taskId;
if (nextDependencies.length === dependentTask.dependencies.length && !clearsBlockedBy) {
continue;
}
const updatedDependent = {
const updatedLog = clearsBlockedBy
? [
...(dependentTask.log ?? []),
{
timestamp: new Date().toISOString(),
action: `Auto-unblocked: blocker ${taskId} was soft-deleted`,
},
]
: dependentTask.log;
const updatedDependent: Task = {
...dependentTask,
dependencies: nextDependencies,
blockedBy: clearsBlockedBy ? undefined : dependentTask.blockedBy,
status: clearsBlockedBy ? undefined : dependentTask.status,
log: updatedLog,
updatedAt: new Date().toISOString(),
};
this.db.prepare("UPDATE tasks SET dependencies = ?, updatedAt = ? WHERE id = ?").run(
this.db.prepare("UPDATE tasks SET dependencies = ?, blockedBy = ?, status = ?, log = ?, updatedAt = ? WHERE id = ?").run(
toJson(updatedDependent.dependencies),
updatedDependent.blockedBy ?? null,
updatedDependent.status ?? null,
toJson(updatedDependent.log ?? []),
updatedDependent.updatedAt,
updatedDependent.id,
);
@@ -6925,6 +6946,46 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return rewrittenDependents;
}
private rewriteBlockedByResidueDependentsForRemoval(taskId: string, excludedDependentIds: Set<string>): Task[] {
const rewrittenDependents: Task[] = [];
const candidates = this.db
.prepare(`SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE} AND blockedBy = ?`)
.all(taskId) as Array<{ id: string }>;
for (const candidate of candidates) {
if (excludedDependentIds.has(candidate.id)) continue;
const dependentTask = this.readTaskFromDb(candidate.id);
if (!dependentTask || dependentTask.blockedBy !== taskId) continue;
const updatedDependent: Task = {
...dependentTask,
blockedBy: undefined,
status: undefined,
log: [
...(dependentTask.log ?? []),
{
timestamp: new Date().toISOString(),
action: `Auto-unblocked: blocker ${taskId} was soft-deleted`,
},
],
updatedAt: new Date().toISOString(),
};
this.db.prepare("UPDATE tasks SET blockedBy = NULL, status = NULL, log = ?, updatedAt = ? WHERE id = ?").run(
toJson(updatedDependent.log ?? []),
updatedDependent.updatedAt,
updatedDependent.id,
);
if (this.isWatching) {
this.taskCache.set(updatedDependent.id, updatedDependent);
}
rewrittenDependents.push(updatedDependent);
}
return rewrittenDependents;
}
private rewriteLineageChildrenForRemoval(parentId: string, childIds: string[]): Task[] {
const rewrittenChildren: Task[] = [];