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:
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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