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:
@@ -0,0 +1,126 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { DEFAULT_SETTINGS, TaskStore, type Task } from "@fusion/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Scheduler } from "../../scheduler.js";
|
||||
import { SelfHealingManager } from "../../self-healing.js";
|
||||
|
||||
type Fixture = { rootDir: string; store: TaskStore; scheduler: Scheduler; selfHealing: SelfHealingManager };
|
||||
|
||||
async function createFixture(autoMerge = true): Promise<Fixture> {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "fusion-fn5566-"));
|
||||
await mkdir(join(rootDir, ".fusion"), { recursive: true });
|
||||
await writeFile(join(rootDir, "README.md"), "# test\n", "utf8");
|
||||
const store = new TaskStore(rootDir, undefined, { inMemoryDb: true });
|
||||
await store.init();
|
||||
await store.updateSettings({ ...DEFAULT_SETTINGS, autoMerge } as any);
|
||||
const scheduler = new Scheduler(store as any);
|
||||
const selfHealing = new SelfHealingManager(store, { rootDir, getExecutingTaskIds: () => new Set<string>() });
|
||||
return { rootDir, store, scheduler, selfHealing };
|
||||
}
|
||||
|
||||
async function createTask(store: TaskStore, input: Partial<Task>): Promise<Task> {
|
||||
return store.createTask({ title: "task", description: "task", prompt: "## File Scope\n- packages/engine/src/**\n", steps: [], ...input } as any);
|
||||
}
|
||||
|
||||
describe("reliability interactions: FN-5566 / FN-5446 soft-delete blocker residue", () => {
|
||||
const fixtures: Fixture[] = [];
|
||||
afterEach(async () => {
|
||||
while (fixtures.length) {
|
||||
const fx = fixtures.pop()!;
|
||||
fx.scheduler.stop();
|
||||
fx.selfHealing.stop();
|
||||
fx.store.close();
|
||||
await rm(fx.rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("covers direct-delete blocker residue and blockedBy-only paths", async () => {
|
||||
const fx = await createFixture();
|
||||
fixtures.push(fx);
|
||||
const blocker = await createTask(fx.store, { column: "todo" });
|
||||
const other = await createTask(fx.store, { column: "todo" });
|
||||
const depA = await createTask(fx.store, { column: "todo", status: "blocked", dependencies: [blocker.id], blockedBy: blocker.id });
|
||||
const depB = await createTask(fx.store, { column: "todo", status: "blocked", dependencies: [other.id], blockedBy: blocker.id });
|
||||
|
||||
await fx.store.deleteTask(blocker.id, { removeDependencyReferences: true });
|
||||
|
||||
const depAAfter = await fx.store.getTask(depA.id);
|
||||
const depBAfter = await fx.store.getTask(depB.id);
|
||||
expect(depAAfter.blockedBy ?? null).toBeNull();
|
||||
expect(depAAfter.status ?? null).toBeNull();
|
||||
expect(depAAfter.dependencies).not.toContain(blocker.id);
|
||||
expect(depBAfter.blockedBy ?? null).toBeNull();
|
||||
expect(depBAfter.status ?? null).toBeNull();
|
||||
expect(depBAfter.dependencies).toEqual([other.id]);
|
||||
});
|
||||
|
||||
it("event-driven reconciliation reblocks dependents to next unresolved dependency", async () => {
|
||||
const fx = await createFixture();
|
||||
fixtures.push(fx);
|
||||
const blocker = await createTask(fx.store, { column: "in-progress" });
|
||||
const other = await createTask(fx.store, { column: "todo" });
|
||||
const dep = await createTask(fx.store, { column: "todo", status: "blocked", blockedBy: blocker.id, dependencies: [other.id, blocker.id] });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const db = fx.store.getDatabase();
|
||||
db.prepare("UPDATE tasks SET deletedAt = ?, \"column\" = 'archived', updatedAt = ? WHERE id = ?").run(now, now, blocker.id);
|
||||
fx.store.emit("task:deleted", await fx.store.getTask(blocker.id, { includeDeleted: true }));
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const depAfter = await fx.store.getTask(dep.id);
|
||||
expect(depAfter.blockedBy).toBe(other.id);
|
||||
expect(depAfter.status).toBe("queued");
|
||||
});
|
||||
});
|
||||
|
||||
it("reconciles soft-delete column drift with audit and preserves FN-5208 invariants", async () => {
|
||||
const fx = await createFixture();
|
||||
fixtures.push(fx);
|
||||
const drift = await createTask(fx.store, { column: "in-review" });
|
||||
await fx.store.deleteTask(drift.id);
|
||||
const db = fx.store.getDatabase();
|
||||
db.prepare("UPDATE tasks SET \"column\" = 'in-review' WHERE id = ?").run(drift.id);
|
||||
|
||||
const first = await fx.selfHealing.reconcileSoftDeletedColumnDrift();
|
||||
const second = await fx.selfHealing.reconcileSoftDeletedColumnDrift();
|
||||
const row = db.prepare("SELECT deletedAt, \"column\" as column, allowResurrection FROM tasks WHERE id = ?").get(drift.id) as any;
|
||||
|
||||
expect(first.reconciled).toBe(1);
|
||||
expect(second.reconciled).toBe(0);
|
||||
expect(row.column).toBe("archived");
|
||||
expect(row.deletedAt).toBeTruthy();
|
||||
expect(row.allowResurrection).toBe(0);
|
||||
const auditEvents = (fx.store as any).getRunAuditEvents({ mutationType: "task:soft-delete-column-reconciled", limit: 10 }) as any[];
|
||||
expect(auditEvents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clearStaleBlockedBy handles missed task:deleted event with soft-deleted-blocker reason", async () => {
|
||||
const fx = await createFixture();
|
||||
fixtures.push(fx);
|
||||
const blocker = await createTask(fx.store, { column: "todo" });
|
||||
const dep = await createTask(fx.store, { column: "todo", status: "blocked", blockedBy: blocker.id, dependencies: [] });
|
||||
|
||||
await fx.store.deleteTask(blocker.id, { removeDependencyReferences: true });
|
||||
await fx.store.updateTask(dep.id, { blockedBy: blocker.id, status: "blocked" as any });
|
||||
|
||||
await fx.selfHealing.clearStaleBlockedBy();
|
||||
const depAfter = await fx.store.getTask(dep.id);
|
||||
expect(depAfter.blockedBy ?? null).toBeNull();
|
||||
expect(depAfter.log.some((entry) => entry.action.includes("soft-deleted at"))).toBe(true);
|
||||
});
|
||||
|
||||
it("FN-5147 composition: live in-review tasks remain untouched when autoMerge=false", async () => {
|
||||
const fx = await createFixture(false);
|
||||
fixtures.push(fx);
|
||||
const live = await createTask(fx.store, { column: "in-review", status: "failed" });
|
||||
|
||||
const result = await fx.selfHealing.reconcileSoftDeletedColumnDrift();
|
||||
const liveAfter = await fx.store.getTask(live.id);
|
||||
expect(result.reconciled).toBe(0);
|
||||
expect(liveAfter.column).toBe("in-review");
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ function createStore(tasks: TestTask[], leakDeleted = false) {
|
||||
return store as any;
|
||||
}
|
||||
|
||||
describe("reliability interactions: FN-5528 soft-delete deadlock scan exclusion", () => {
|
||||
describe("reliability interactions: FN-5566/FN-5528 soft-delete deadlock scan exclusion", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-22T02:00:00.000Z"));
|
||||
|
||||
@@ -520,6 +520,37 @@ describe("Scheduler", () => {
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-DEP", "Auto-unblocked (FN-5496): blocker FN-DEL was soft-deleted");
|
||||
});
|
||||
|
||||
it("FN-5496: task:deleted clears blockedBy but preserves status for in-progress dependents", async () => {
|
||||
const deleted = createMockTask({ id: "FN-DEL", column: "todo" });
|
||||
const dependent = createMockTask({
|
||||
id: "FN-DEP",
|
||||
column: "in-progress",
|
||||
blockedBy: "FN-DEL",
|
||||
status: "running",
|
||||
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 });
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-DEP", expect.objectContaining({ 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" });
|
||||
|
||||
@@ -418,6 +418,7 @@ export type DatabaseMutationType =
|
||||
| "task:auto-archived-ghost-bug"
|
||||
| "task:auto-archived-duplicate"
|
||||
| "task:auto-reconciled-self-defeating-dep"
|
||||
| "task:soft-delete-column-reconciled"
|
||||
| "task:dependency-cycle-rejected"
|
||||
| "task:dependency-cycle-detected"
|
||||
| "task:auto-reconciled-dependency-cycle"
|
||||
|
||||
@@ -801,6 +801,7 @@ export class SelfHealingManager {
|
||||
{ name: "recover-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns().then(() => undefined) },
|
||||
{ name: "recover-running-on-inactive-tasks", fn: () => this.recoverAgentsRunningOnInactiveTasks().then(() => undefined) },
|
||||
{ name: "recover-drifted-agent-task-links", fn: () => this.recoverDriftedAgentTaskLinks().then(() => undefined) },
|
||||
{ name: "reconcile-soft-delete-column-drift", fn: () => this.reconcileSoftDeletedColumnDrift().then(() => undefined) },
|
||||
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy().then(() => undefined) },
|
||||
{ name: "reconcile-self-defeating-deps", fn: () => this.reconcileSelfDefeatingDependencies().then(() => undefined) },
|
||||
{ name: "reconcile-dependency-cycles", fn: () => this.reconcileDependencyCycles().then(() => undefined) },
|
||||
@@ -1458,6 +1459,7 @@ export class SelfHealingManager {
|
||||
{ name: "recover-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns() },
|
||||
{ name: "recover-running-on-inactive-tasks", fn: () => this.recoverAgentsRunningOnInactiveTasks() },
|
||||
{ name: "recover-drifted-agent-task-links", fn: () => this.recoverDriftedAgentTaskLinks() },
|
||||
{ name: "reconcile-soft-delete-column-drift", fn: () => this.reconcileSoftDeletedColumnDrift() },
|
||||
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy() },
|
||||
{ name: "auto-rebound-paused-scope-decay", fn: () => this.autoReboundPausedScopeDecay() },
|
||||
{ name: "auto-archive-meta-resolved", fn: () => this.autoArchiveResolvedMetaTasks() },
|
||||
@@ -3607,6 +3609,48 @@ export class SelfHealingManager {
|
||||
this.lastDbCorruptionNotifiedAt = now;
|
||||
}
|
||||
|
||||
async reconcileSoftDeletedColumnDrift(): Promise<{ reconciled: number }> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return { reconciled: 0 };
|
||||
|
||||
const db = this.store.getDatabase();
|
||||
// FN-5147 invariant: only rows with deletedAt are eligible, so live
|
||||
// in-review tasks (including autoMerge: false workflows) are never moved.
|
||||
const candidates = db.prepare("SELECT id, \"column\" AS column FROM tasks WHERE deletedAt IS NOT NULL AND \"column\" != 'archived'").all() as Array<{ id: string; column: Task["column"] }>;
|
||||
if (candidates.length === 0) return { reconciled: 0 };
|
||||
|
||||
let reconciled = 0;
|
||||
const now = new Date().toISOString();
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
runId: generateSyntheticRunId("fn5566-soft-delete-column", "global"),
|
||||
agentId: "self-healing",
|
||||
phase: "reconcile-soft-delete-column-drift",
|
||||
});
|
||||
|
||||
for (const candidate of candidates) {
|
||||
db.prepare("UPDATE tasks SET \"column\" = 'archived', updatedAt = ? WHERE id = ?").run(now, candidate.id);
|
||||
await auditor.database({
|
||||
type: "task:soft-delete-column-reconciled",
|
||||
target: candidate.id,
|
||||
metadata: { previousColumn: candidate.column },
|
||||
});
|
||||
log.log(`[self-heal] reconcile-soft-delete-column-drift: ${candidate.id} previous=${candidate.column} → archived`);
|
||||
reconciled++;
|
||||
}
|
||||
|
||||
if (reconciled > 0) {
|
||||
db.bumpLastModified();
|
||||
}
|
||||
|
||||
return { reconciled };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.warn(`reconcileSoftDeletedColumnDrift: failed: ${message}`);
|
||||
return { reconciled: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async clearStaleBlockedBy(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
@@ -3888,6 +3932,7 @@ export class SelfHealingManager {
|
||||
const seenCycleSignatures = new Set<string>();
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.deletedAt) continue;
|
||||
if (!task.dependencies.length) continue;
|
||||
|
||||
try {
|
||||
@@ -5461,6 +5506,7 @@ export class SelfHealingManager {
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of candidates) {
|
||||
if (task.deletedAt) continue;
|
||||
const blockedDependents = dependentsByBlocker.get(task.id) ?? [];
|
||||
const blockedTaskIds = blockedDependents.map((dep) => dep.id);
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user