FN-7070: harden self-healing fake overlap seams

Strengthen self-healing tests so TaskStore fakes cover overlap-related seams deterministically.

- Add a focused regression for active file-scope overlap preservation in clearStaleBlockedBy().
- Teach existing self-healing fake stores to expose parsed file-scope and completion-handoff seams.
- Adjust assertions around queued, soft-delete, retry-exhausted, and finalize recovery paths to preserve overlap blockers.

Files changed:
 .../invariant-stranded-in-review-recovery.test.ts  | 16 +++-
 .../engine/src/__tests__/project-engine.test.ts    |  6 ++
 .../completion-fanout-x-self-healing.test.ts       |  6 ++
 ...view-retry-exhausted-policy-convergence.test.ts | 11 ++-
 .../self-healing-interactions.test.ts              |  6 ++
 ...ker-auto-finalize-interactions.real-git.test.ts |  8 +-
 .../soft-delete-deadlock-scan-exclusion.test.ts    |  6 ++
 .../self-healing-fake-overlap-seam.test.ts         | 93 ++++++++++++++++++++++
 .../self-healing-stale-merge-fanout.test.ts        |  7 ++
 9 files changed, 153 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-7070

Fusion-Task-Lineage: adb51873-9d8f-418f-ba60-c8a49601a552
This commit is contained in:
gsxdsm
2026-06-26 13:46:54 -07:00
parent 74d37785d2
commit a61127de3b
9 changed files with 153 additions and 6 deletions

View File

@@ -33,12 +33,16 @@ function createStore(tasks: Task[], settingsOverrides: Partial<Settings> = {}):
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<Task>) => {
const cur = map.get(id)!;
map.set(id, { ...cur, ...updates } as Task);
@@ -49,6 +53,12 @@ function createStore(tasks: Task[], settingsOverrides: Partial<Settings> = {}):
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;
}

View File

@@ -225,6 +225,12 @@ function createMockStore(initialSettings: Record<string, unknown>) {
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),

View File

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

View File

@@ -54,9 +54,10 @@ function createStore(seed: Task[]): { store: Store; tasks: Map<string, Task> } {
.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<Task>) => {
const current = tasks.get(id);
@@ -81,6 +82,12 @@ function createStore(seed: Task[]): { store: Store; tasks: Map<string, Task> } {
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 };

View File

@@ -29,6 +29,12 @@ function makeStore(tasks: Map<string, Task>): TaskStore & EventEmitter {
updateTask: vi.fn(async (id: string, updates: Partial<Task>) => { 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(() => []),

View File

@@ -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<Set
},
logEntry: async () => 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: () => [],

View File

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

View File

@@ -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> = {}): 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<string, Task> } {
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<Task>) => {
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<string>() });
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();
});
});

View File

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