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

@@ -1061,6 +1061,7 @@ The run-audit system records every mutation performed by the engine across four
- **Git / `merge:no-op-attribution-mismatch-skipped`** — emitted when the FN-5304 source-tip guard cannot run because the source branch ref is unavailable (for example already pruned). `target` is the task ID; metadata includes `reason` (`"source-ref-unavailable"`).
- **Database / `task:auto-recover-misrouted-foreign-commit`** — emitted per dropped misrouted commit during FN-4948 contamination recovery. `target` is the recovering task; metadata carries `{ droppedSha, foreignTaskId, paths }`.
- **Database / `task:orphan-detected-no-action`** — emitted by `recoverOrphanedExecutions` (FN-5337) when row metadata looks orphaned after grace windows; annotation-only event with no lifecycle mutation (`in-progress` task stays put).
- **Database / `task:soft-delete-column-reconciled`** — emitted by `reconcileSoftDeletedColumnDrift` (FN-5566, re-land FN-5446) when a soft-deleted row (`deletedAt IS NOT NULL`) is found with legacy `column != 'archived'`; rewrites only `column` (no resurrection), with metadata `{ previousColumn }`.
- **Database / `session:runtime-resolved`** — emitted once per `createResolvedAgentSession` call with metadata `{ sessionPurpose, runtimeId, wasConfigured, provider, modelId, mockProviderActive, testModeActive, runtimeHint? }` for per-lane runtime/provider attribution.
- **Database / `task:*-no-action` backward-move family (FN-5335)** — backward self-healing sweeps now emit annotation-only events when triple proof fails instead of mutating lifecycle state. New mutation types: `task:reclaim-pr-conflict-no-action`, `task:reclaim-self-owned-branch-conflict-no-action`, `task:auto-rebound-scope-decay-no-action`, `task:finalize-no-op-review-no-action`, `task:stale-incomplete-review-no-action`, `task:ghost-review-no-action`, `task:stuck-merge-deadlock-no-action`, `task:no-progress-no-task-done-no-action`, `task:missing-worktree-review-no-action`, `task:partial-progress-no-task-done-no-action`. See `docs/self-healing-backward-move-audit.md` for per-stage disposition.
- **Filesystem** — file:write, prompt:write, attachment:create, etc.

View File

@@ -11,7 +11,7 @@
| Scenario | Pre-state | API (REST) | Scheduler / Executor | Merger | Triage | Dashboard (SSE + initial load) | Agent logs | Documents | Owning FN |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1. Soft-delete a `triage` task with no `PROMPT.md` yet | Live `triage` row; task dir may exist without spec | `DELETE /api/tasks/:id` soft-deletes; later live readers 404 / omit; forensic store access still sees `deletedAt` row | Not dispatchable; auto-claim snapshots exclude it; executor entry points refuse if manually targeted | N/A | Active/queued triage must not re-open it after refresh; active abort is **blocked by FN-5142** | SSE `task:deleted` removes it; reload keeps it absent from board/ListView/TodoView | Preserved until FN-5143 lands | No documents expected; document APIs still treat parent as absent once FN-5140 lands | FN-5105 / FN-5135 / FN-5137 / FN-5142 |
| 2. Soft-delete a `todo` task with dependencies pointing AT it | Live `todo`; other live tasks depend on it | Delete is 409 `TASK_HAS_DEPENDENTS` unless caller opts into `removeDependencyReferences`; retry succeeds and returns soft-deleted task | Deleted task is never redispatched; rewritten dependents keep running as live tasks | N/A | N/A | SSE removes deleted card; dependents reflect rewritten dependency state on refresh | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5105 / FN-5137 |
| 2. Soft-delete a `todo` task with dependencies pointing AT it | Live `todo`; other live tasks depend on it | Delete is 409 `TASK_HAS_DEPENDENTS` unless caller opts into `removeDependencyReferences`; retry succeeds and returns soft-deleted task | Deleted task is never redispatched; rewritten dependents clear blocker residue (`dependencies`, `blockedBy`, `status`) and keep running as live tasks | N/A | N/A | SSE removes deleted card; dependents reflect rewritten dependency state on refresh | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5105 / FN-5137 / FN-5566 (re-land FN-5446) |
| 3. Soft-delete a `todo` task with lineage children | Live `todo`; `sourceParentTaskId` children exist | Current contract is 409 with lineage child IDs, then retry with `removeLineageReferences=true`; route/UI wiring is **pending FN-5139** | Deleted parent never dispatches; rewritten children remain live | N/A | N/A | Delete/archive UI must surface confirm-retry flow once FN-5139 lands | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5139 |
| 4. Soft-delete an `in-progress` task with an active executor session | Live `in-progress`; executor owns active session | Delete succeeds; task vanishes from live readers immediately | Entry guards already refuse reruns; active-session abort / dispose is **pending FN-5142** | N/A | N/A | SSE removes card; reload does not re-seed it into active queues | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5137 / FN-5142 |
| 5. Soft-delete an `in-progress` task with an active workflow-step session and reviewer subagent | Live `in-progress`; workflow step child session exists | Delete succeeds; no public recovery/undelete flag | New execution attempts refuse; workflow-step + reviewer abort/cleanup is **pending FN-5142** | N/A | N/A | SSE removes card; reload stays clean | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5142 |
@@ -39,6 +39,7 @@
| 6,7 | In-flight merge abort / merge queue filtering | No deterministic merge-abort assertion in current corpus | Missing active merge abort + queued merge filtering coverage | `packages/engine/src/__tests__/project-engine-soft-delete-merge-abort.test.ts` | FN-5142 |
| 1,10 | Triage abort on `task:deleted` | No deterministic triage abort assertion in current corpus | Missing active triage session + subagent abort coverage | `packages/engine/src/__tests__/triage-soft-delete-abort.test.ts` | FN-5142 |
| 11 | Deadlock/stuck-merge/in-review-stall scan exclusion for soft-deleted exhausted blockers | Added in this task | GREEN — defensive sweep guards + script `WHERE deletedAt IS NULL` backstop | `packages/engine/src/__tests__/reliability-interactions/soft-delete-deadlock-scan-exclusion.test.ts`, `scripts/__tests__/recover-stale-blocked-by.test.mjs` | FN-5528 |
| 2,11 | Soft-delete blocker residue + legacy column drift reconciliation (`deletedAt` + non-archived column) | Added in this task | GREEN — in-transaction blocker cleanup, periodic/startup column-drift reconciler, and audit mutation `task:soft-delete-column-reconciled` | `packages/engine/src/__tests__/reliability-interactions/soft-delete-blocker-residue.test.ts`, `packages/core/src/__tests__/store-delete-task-blocker-residue.test.ts` | FN-5566 (re-land FN-5446) |
| 8 | `agentLogEntries` cleared on soft-delete | No dedicated coverage today | Missing atomic clear + post-delete empty-reader assertion | `packages/core/src/__tests__/soft-delete-agent-logs.test.ts` | FN-5143 |
| 8 | `/api/documents` and per-task docs exclude soft-deleted parents | No dedicated soft-delete document visibility assertion today | Missing store + route coverage | `packages/core/src/__tests__/task-documents.test.ts` and `packages/dashboard/src/__tests__/routes-tasks.test.ts` | FN-5140 |
| 3 | Lineage-unlink 409 flow through API + UI | Store lineage guards are covered; route/UI flow is not | Missing 409 payload + confirm-retry UX coverage | `packages/dashboard/src/__tests__/routes-tasks-ops.test.ts`, `packages/dashboard/app/utils/__tests__/taskDelete.test.ts`, `packages/dashboard/app/components/__tests__/TaskCard.test.tsx`, `packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx`, `packages/dashboard/app/components/__tests__/ListView.test.tsx` | FN-5139 |

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[] = [];

View File

@@ -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");
});
});

View File

@@ -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"));

View File

@@ -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" });

View File

@@ -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"

View File

@@ -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 {