diff --git a/packages/engine/src/__tests__/invariant-stranded-in-review-recovery.test.ts b/packages/engine/src/__tests__/invariant-stranded-in-review-recovery.test.ts index 91342e9991..91d096ab52 100644 --- a/packages/engine/src/__tests__/invariant-stranded-in-review-recovery.test.ts +++ b/packages/engine/src/__tests__/invariant-stranded-in-review-recovery.test.ts @@ -33,12 +33,16 @@ function createStore(tasks: Task[], settingsOverrides: Partial = {}): const emitter = new EventEmitter(); return Object.assign(emitter, { getSettings: vi.fn(async () => settings), - listTasks: vi.fn(async (opts?: { column?: string }) => { - const all = [...map.values()]; + listTasks: vi.fn(async (opts?: { column?: string; includeDeleted?: boolean }) => { + const all = [...map.values()].filter((task) => opts?.includeDeleted || !task.deletedAt); if (!opts?.column) return all; return all.filter((t) => t.column === opts.column); }), - getTask: vi.fn(async (id: string) => map.get(id)), + getTask: vi.fn(async (id: string, opts?: { includeDeleted?: boolean }) => { + const task = map.get(id); + if (!task || (task.deletedAt && !opts?.includeDeleted)) return null; + return task; + }), updateTask: vi.fn(async (id: string, updates: Partial) => { const cur = map.get(id)!; map.set(id, { ...cur, ...updates } as Task); @@ -49,6 +53,12 @@ function createStore(tasks: Task[], settingsOverrides: Partial = {}): map.set(id, { ...cur, column } as Task); }), logEntry: vi.fn(async () => undefined), + /* + FNXC:OverlapSelfHealing 2026-06-26-12:00: + clearStaleBlockedBy object stores must include the overlap-path TaskStore seam, including soft-deleted getTask reads, so stale-blocker recovery invariants do not depend on which branch a test reaches. + */ + parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), + getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null), }) as unknown as TaskStore & EventEmitter; } diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index e43fea6ee3..160fd6fa2e 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -225,6 +225,12 @@ function createMockStore(initialSettings: Record) { return structuredClone(settings); }), logEntry: vi.fn(async () => undefined), + /* + FNXC:OverlapSelfHealing 2026-06-26-12:00: + ProjectEngine's broad TaskStore fake is shared by maintenance wiring tests, so it carries clearStaleBlockedBy's overlap-path seam even when individual cases only exercise merge orchestration. + */ + parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), + getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null), emit: vi.fn(), addTaskComment: vi.fn(async () => undefined), getActiveMergingTask: vi.fn(() => null), diff --git a/packages/engine/src/__tests__/reliability-interactions/completion-fanout-x-self-healing.test.ts b/packages/engine/src/__tests__/reliability-interactions/completion-fanout-x-self-healing.test.ts index b4d85a2582..c2f33a7495 100644 --- a/packages/engine/src/__tests__/reliability-interactions/completion-fanout-x-self-healing.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/completion-fanout-x-self-healing.test.ts @@ -47,6 +47,12 @@ function storeWith(tasks: Task[]): TaskStore & EventEmitter { return map.get(id); }), logEntry: vi.fn(async () => undefined), + /* + FNXC:OverlapSelfHealing 2026-06-26-12:00: + Completion fan-out tests reuse a hand-rolled TaskStore; keep clearStaleBlockedBy's overlap-path methods present so fan-out count checks do not depend on an incomplete fake. + */ + parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), + getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null), }) as unknown as TaskStore & EventEmitter; } diff --git a/packages/engine/src/__tests__/reliability-interactions/in-review-retry-exhausted-policy-convergence.test.ts b/packages/engine/src/__tests__/reliability-interactions/in-review-retry-exhausted-policy-convergence.test.ts index a0365f0b5f..b93702a908 100644 --- a/packages/engine/src/__tests__/reliability-interactions/in-review-retry-exhausted-policy-convergence.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/in-review-retry-exhausted-policy-convergence.test.ts @@ -54,9 +54,10 @@ function createStore(seed: Task[]): { store: Store; tasks: Map } { .filter((task) => (opts?.includeDeleted ? true : !task.deletedAt)) .map((task) => ({ ...task })); }), - getTask: vi.fn(async (id: string) => { + getTask: vi.fn(async (id: string, opts?: { includeDeleted?: boolean }) => { const task = tasks.get(id); - return task ? { ...task } : undefined; + if (!task || (task.deletedAt && !opts?.includeDeleted)) return undefined; + return { ...task }; }), updateTask: vi.fn(async (id: string, patch: Partial) => { const current = tasks.get(id); @@ -81,6 +82,12 @@ function createStore(seed: Task[]): { store: Store; tasks: Map } { tasks.set(id, next); }), recordRunAuditEvent: vi.fn(async () => undefined), + /* + FNXC:OverlapSelfHealing 2026-06-26-12:00: + Policy-convergence fakes must satisfy clearStaleBlockedBy's full TaskStore seam, including future overlap and completion-handoff branches, without changing the retry-exhausted assertions. + */ + parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), + getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null), } as unknown as Store; return { store, tasks }; diff --git a/packages/engine/src/__tests__/reliability-interactions/self-healing-interactions.test.ts b/packages/engine/src/__tests__/reliability-interactions/self-healing-interactions.test.ts index 92557878de..3d8a60644d 100644 --- a/packages/engine/src/__tests__/reliability-interactions/self-healing-interactions.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/self-healing-interactions.test.ts @@ -29,6 +29,12 @@ function makeStore(tasks: Map): TaskStore & EventEmitter { updateTask: vi.fn(async (id: string, updates: Partial) => { tasks.set(id, { ...tasks.get(id)!, ...updates } as Task); return tasks.get(id); }), moveTask: vi.fn(async (id: string, column: Task["column"]) => { tasks.set(id, { ...tasks.get(id)!, column } as Task); }), logEntry: vi.fn(async () => undefined), + /* + FNXC:OverlapSelfHealing 2026-06-26-12:00: + Reliability interaction object fakes must provide clearStaleBlockedBy's overlap-path store methods; real TaskStore fixture cases inherit the production implementation and stay untouched. + */ + parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), + getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null), walCheckpoint: vi.fn(() => ({ busy: 0, log: 0, checkpointed: 0 })), archiveTaskAndCleanup: vi.fn(async () => ({})), clearStaleExecutionStartBranchReferences: vi.fn(() => []), diff --git a/packages/engine/src/__tests__/reliability-interactions/soft-blocker-auto-finalize-interactions.real-git.test.ts b/packages/engine/src/__tests__/reliability-interactions/soft-blocker-auto-finalize-interactions.real-git.test.ts index 6e8de5fb47..86e87204cd 100644 --- a/packages/engine/src/__tests__/reliability-interactions/soft-blocker-auto-finalize-interactions.real-git.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/soft-blocker-auto-finalize-interactions.real-git.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -29,6 +29,12 @@ function makeStore(tasks: Task[], events: unknown[] = [], settings?: Partial undefined, getTask: async (id: string) => tasks.find((candidate) => candidate.id === id) ?? null, + /* + FNXC:OverlapSelfHealing 2026-06-26-12:00: + This real-git interaction uses an object TaskStore fake, so it must mirror clearStaleBlockedBy's overlap-path seam instead of relying on unrelated real repository setup. + */ + parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), + getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null), walCheckpoint: () => ({ busy: 0, log: 0, checkpointed: 0 }), archiveTaskAndCleanup: async () => ({}), clearStaleExecutionStartBranchReferences: () => [], diff --git a/packages/engine/src/__tests__/reliability-interactions/soft-delete-deadlock-scan-exclusion.test.ts b/packages/engine/src/__tests__/reliability-interactions/soft-delete-deadlock-scan-exclusion.test.ts index 9bf7f864c6..bd7c369b0b 100644 --- a/packages/engine/src/__tests__/reliability-interactions/soft-delete-deadlock-scan-exclusion.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/soft-delete-deadlock-scan-exclusion.test.ts @@ -53,6 +53,12 @@ function createStore(tasks: TestTask[], leakDeleted = false) { updateTask: vi.fn(async () => ({})), moveTask: vi.fn(async () => ({})), logEntry: vi.fn(async () => ({})), + /* + FNXC:OverlapSelfHealing 2026-06-26-12:00: + Soft-delete recovery tests already cover getTask(includeDeleted); keep the same fake complete for clearStaleBlockedBy overlap and handoff branches so branch reachability cannot change counts by throwing. + */ + parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), + getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null), recordRunAuditEvent: vi.fn(async () => ({})), }; return store as any; diff --git a/packages/engine/src/__tests__/self-healing-fake-overlap-seam.test.ts b/packages/engine/src/__tests__/self-healing-fake-overlap-seam.test.ts new file mode 100644 index 0000000000..95ffe281ae --- /dev/null +++ b/packages/engine/src/__tests__/self-healing-fake-overlap-seam.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Settings, Task, TaskStore } from "@fusion/core"; +import { SelfHealingManager } from "../self-healing.js"; + +function makeTask(id: string, overrides: Partial = {}): Task { + return { + id, + title: id, + description: id, + column: "todo", + status: null, + paused: false, + blockedBy: null, + overlapBlockedBy: null, + dependencies: [], + steps: [], + log: [], + createdAt: "2026-06-26T00:00:00.000Z", + updatedAt: "2026-06-26T00:00:00.000Z", + ...overrides, + } as Task; +} + +function createOverlapStore(seed: Task[]): { store: TaskStore; tasks: Map } { + const tasks = new Map(seed.map((task) => [task.id, task])); + const settings = { + globalPause: false, + enginePaused: false, + mergeRequestContractShadowEnabled: true, + } as Settings; + + const store = { + getSettings: vi.fn().mockResolvedValue(settings), + listTasks: vi.fn().mockImplementation(async (opts?: { column?: Task["column"]; includeArchived?: boolean }) => { + const all = [...tasks.values()]; + if (!opts?.column) return all; + return all.filter((task) => task.column === opts.column); + }), + getTask: vi.fn().mockImplementation(async (id: string, opts?: { includeDeleted?: boolean }) => { + const task = tasks.get(id); + if (!task || (task.deletedAt && !opts?.includeDeleted)) return null; + return task; + }), + updateTask: vi.fn().mockImplementation(async (id: string, patch: Partial) => { + const current = tasks.get(id); + if (!current) throw new Error(`Task ${id} missing`); + const next = { ...current, ...patch } as Task; + tasks.set(id, next); + return next; + }), + logEntry: vi.fn().mockResolvedValue(undefined), + /* + FNXC:OverlapSelfHealing 2026-06-26-12:00: + This regression intentionally drives a hand-rolled TaskStore through clearStaleBlockedBy's active file-scope-overlap branch. The fake must include parsed scope and completion-handoff methods so a missing method cannot silently turn a preserved-queued recovery into count 0. + */ + parseFileScopeFromPrompt: vi.fn().mockImplementation(async () => ["packages/engine/src/self-healing.ts"]), + getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null), + } as unknown as TaskStore; + + return { store, tasks }; +} + +describe("SelfHealingManager fake TaskStore overlap seam", () => { + it("preserves queued recovery through active overlap without missing-method drift", async () => { + const staleBlocker = makeTask("FN-DONE-BLOCKER", { column: "done" }); + const overlapBlocker = makeTask("FN-ACTIVE-OVERLAP", { column: "in-progress" }); + const dependent = makeTask("FN-DEPENDENT", { + column: "todo", + status: "queued", + blockedBy: staleBlocker.id, + overlapBlockedBy: overlapBlocker.id, + dependencies: [staleBlocker.id], + }); + const { store, tasks } = createOverlapStore([staleBlocker, overlapBlocker, dependent]); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project", getExecutingTaskIds: () => new Set() }); + + await expect(manager.clearStaleBlockedBy()).resolves.toBe(1); + + expect(store.parseFileScopeFromPrompt).toHaveBeenCalledWith(dependent.id); + expect(store.parseFileScopeFromPrompt).toHaveBeenCalledWith(overlapBlocker.id); + expect(store.getCompletionHandoffAcceptedMarker).toHaveBeenCalledWith(overlapBlocker.id); + expect(store.updateTask).toHaveBeenCalledWith(dependent.id, { blockedBy: null, status: "queued" }); + expect(tasks.get(dependent.id)?.blockedBy).toBeNull(); + expect(tasks.get(dependent.id)?.status).toBe("queued"); + expect(tasks.get(dependent.id)?.overlapBlockedBy).toBe(overlapBlocker.id); + expect(store.logEntry).toHaveBeenCalledWith( + dependent.id, + expect.stringContaining(`still blocked by file scope overlap with ${overlapBlocker.id}`), + ); + + manager.stop(); + }); +}); diff --git a/packages/engine/src/__tests__/self-healing-stale-merge-fanout.test.ts b/packages/engine/src/__tests__/self-healing-stale-merge-fanout.test.ts index 224651ea34..8c80a7455c 100644 --- a/packages/engine/src/__tests__/self-healing-stale-merge-fanout.test.ts +++ b/packages/engine/src/__tests__/self-healing-stale-merge-fanout.test.ts @@ -45,6 +45,13 @@ describe("SelfHealingManager stale merge fanout recovery (FN-4241)", () => { tasks.set(id, { ...current, ...patch }); }), logEntry: vi.fn().mockResolvedValue(undefined), + /* + FNXC:OverlapSelfHealing 2026-06-26-12:00: + clearStaleBlockedBy fakes must expose every store method its file-scope-overlap path can call so recovery-count assertions stay deterministic across shard order. + */ + getTask: vi.fn().mockImplementation(async (id: string) => tasks.get(id) ?? null), + parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), + getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null), } as unknown as TaskStore; const blocker = createTask("FN-4241-BLOCKER", {