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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user