feat(FN-5528): add soft-delete exclusion to stale blocked-by recovery scrip

Adds `deletedAt` sweep guards to the engine's self-healing and merger to prevent recovery operations from processing soft-deleted tasks, filters deleted tasks in the `recover-stale-blocked-by` script, includes a new regression test for the deadlock-scan exclusion pattern, and updates the soft-delete

Fusion-Task-Id: FN-5528

Fusion-Task-Lineage: 5c9e45ca-49a8-47a0-a23d-6fe8e15e7e00

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5528
This commit is contained in:
gsxdsm
2026-05-23 03:33:53 -07:00
parent 2209c57dd6
commit d4ec82bf02
7 changed files with 241 additions and 7 deletions

View File

@@ -21,7 +21,8 @@ function setupFixture() {
worktree TEXT,
paused INTEGER,
log TEXT,
updatedAt TEXT
updatedAt TEXT,
deletedAt TEXT
);
`);
@@ -36,8 +37,17 @@ function writePrompt(tasksDir, taskId, scopeLines) {
}
function insertTask(db, row) {
db.prepare(`INSERT INTO tasks (id, "column", blockedBy, worktree, paused, log, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(row.id, row.column, row.blockedBy ?? null, row.worktree ?? null, row.paused ?? 0, row.log ?? "[]", row.updatedAt ?? new Date().toISOString());
db.prepare(`INSERT INTO tasks (id, "column", blockedBy, worktree, paused, log, updatedAt, deletedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.run(
row.id,
row.column,
row.blockedBy ?? null,
row.worktree ?? null,
row.paused ?? 0,
row.log ?? "[]",
row.updatedAt ?? new Date().toISOString(),
row.deletedAt ?? null,
);
}
test("clears stale blocker when blocker is terminal", () => {
@@ -100,3 +110,45 @@ test("dry-run reports repairs without writing", () => {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("recoverBlockedBy ignores soft-deleted blockers and dependents (FN-5528)", () => {
const { dir, tasksDir, db } = setupFixture();
try {
writePrompt(tasksDir, "FN-LIVE-DEPENDENT", ["packages/core/src/store.ts"]);
writePrompt(tasksDir, "FN-DELETED-BLOCKER", ["packages/core/src/store.ts"]);
writePrompt(tasksDir, "FN-DELETED-TODO", ["packages/dashboard/app/App.tsx"]);
writePrompt(tasksDir, "FN-LIVE-TERMINAL", ["packages/engine/src/self-healing.ts"]);
writePrompt(tasksDir, "FN-LIVE-TERMINAL-DEP", ["packages/engine/src/self-healing.ts"]);
insertTask(db, {
id: "FN-DELETED-BLOCKER",
column: "in-review",
deletedAt: "2026-05-20T05:50:51.015Z",
});
insertTask(db, { id: "FN-LIVE-DEPENDENT", column: "todo", blockedBy: "FN-DELETED-BLOCKER" });
const deletedTodoUpdatedAt = "2026-05-22T01:00:00.000Z";
insertTask(db, {
id: "FN-DELETED-TODO",
column: "todo",
blockedBy: "FN-LIVE-TERMINAL",
updatedAt: deletedTodoUpdatedAt,
deletedAt: "2026-05-20T05:50:51.015Z",
});
insertTask(db, { id: "FN-LIVE-TERMINAL", column: "done" });
insertTask(db, { id: "FN-LIVE-TERMINAL-DEP", column: "todo", blockedBy: "FN-LIVE-TERMINAL" });
const findings = recoverBlockedBy({ db, tasksDir, dryRun: false });
assert.equal(findings.find((f) => f.taskId === "FN-LIVE-DEPENDENT")?.reason, "blocker-missing");
assert.equal(findings.find((f) => f.taskId === "FN-LIVE-TERMINAL-DEP")?.reason, "blocker-terminal:done");
const deletedTodo = db.prepare("SELECT blockedBy, updatedAt FROM tasks WHERE id = ?").get("FN-DELETED-TODO");
assert.equal(deletedTodo.blockedBy, "FN-LIVE-TERMINAL");
assert.equal(deletedTodo.updatedAt, deletedTodoUpdatedAt);
} finally {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});

View File

@@ -57,8 +57,17 @@ function isTerminalColumn(column) {
return column === "done" || column === "archived";
}
function hasDeletedAtColumn(db) {
const columns = db.prepare("PRAGMA table_info('tasks')").all();
return columns.some((column) => column?.name === "deletedAt");
}
export function recoverBlockedBy({ db, tasksDir, dryRun = true }) {
const rows = db.prepare("SELECT id, \"column\", blockedBy, worktree, paused, log FROM tasks").all();
const includeDeletedAt = hasDeletedAtColumn(db);
const selectSql = includeDeletedAt
? "SELECT id, \"column\", blockedBy, worktree, paused, log, deletedAt FROM tasks WHERE deletedAt IS NULL"
: "SELECT id, \"column\", blockedBy, worktree, paused, log FROM tasks";
const rows = db.prepare(selectSql).all();
const byId = new Map(rows.map((row) => [row.id, row]));
const activeScopes = new Map();