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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(() => []),
|
||||
|
||||
@@ -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: () => [],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user