feat(FN-783): complete Step 4 — reconcile stale overlap blockers safely
Fusion-Task-Id: FN-783 Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
@@ -360,4 +360,99 @@ describe("scheduler overlap starvation regression (FN-057)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not preserve FN-779/FN-756 poisoned cross-repository blocker after write-scope sanitization", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "FN-756", column: "in-progress", priority: "normal", title: "iPad mobile XCUITest work" }),
|
||||
makeTask({
|
||||
id: "FN-779",
|
||||
column: "todo",
|
||||
status: "queued",
|
||||
priority: "high",
|
||||
overlapBlockedBy: "FN-756",
|
||||
title: "Fusion engine heartbeat suppression",
|
||||
}),
|
||||
];
|
||||
const store = createStore(tasks, {
|
||||
"FN-756": ["project.yml", "AtlasNotes.xcodeproj/**", "Tests/AtlasNotesMobileUITests/**", "Packages/MobileApp/**"],
|
||||
"FN-779": ["packages/core/**", "packages/engine/**", "packages/dashboard/**", "packages/cli/**"],
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-779", { overlapBlockedBy: null });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-779", "in-progress", expect.anything());
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith(
|
||||
"FN-779",
|
||||
expect.objectContaining({ status: "queued", overlapBlockedBy: "FN-756" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps true Atlas hot-file-family overlaps blocked", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "FN-756", column: "in-progress", priority: "normal" }),
|
||||
makeTask({ id: "FN-800", column: "todo", priority: "high" }),
|
||||
];
|
||||
const store = createStore(tasks, {
|
||||
"FN-756": ["project.yml", "AtlasNotes.xcodeproj/**", "Tests/AtlasNotesMobileUITests/**", "Packages/MobileApp/**"],
|
||||
"FN-800": ["Tests/AtlasNotesMobileUITests/**", "Packages/MobileApp/**"],
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-800", {
|
||||
status: "queued",
|
||||
blockedBy: null,
|
||||
overlapBlockedBy: "FN-756",
|
||||
});
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-800", "in-progress", expect.anything());
|
||||
});
|
||||
|
||||
it("reroutes a stale overlap blocker to another current active lease", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "FN-OLD", column: "done", priority: "normal" }),
|
||||
makeTask({ id: "FN-NEW", column: "in-progress", priority: "normal" }),
|
||||
makeTask({ id: "FN-900", column: "todo", status: "queued", priority: "high", overlapBlockedBy: "FN-OLD" }),
|
||||
];
|
||||
const store = createStore(tasks, {
|
||||
"FN-OLD": ["packages/core/src/store.ts"],
|
||||
"FN-NEW": ["packages/engine/src/scheduler.ts"],
|
||||
"FN-900": ["packages/engine/src/scheduler.ts"],
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-900", {
|
||||
status: "queued",
|
||||
blockedBy: null,
|
||||
overlapBlockedBy: "FN-NEW",
|
||||
});
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-900",
|
||||
"queued — blocked by active file-scope lease FN-NEW (column=in-progress)",
|
||||
);
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-900", "in-progress", expect.anything());
|
||||
});
|
||||
|
||||
it("clears an absent overlap blocker only after confirming no current overlap remains", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "FN-901", column: "todo", status: "queued", priority: "normal", overlapBlockedBy: "FN-MISSING" }),
|
||||
];
|
||||
const store = createStore(tasks, {
|
||||
"FN-901": ["packages/engine/src/scheduler.ts"],
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-901", { overlapBlockedBy: null });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-901", "in-progress", expect.anything());
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -7184,7 +7184,7 @@ describe("FN-4538 overlapBlockedBy self-healing", () => {
|
||||
return { id, column: "todo", paused: false, blockedBy: null, dependencies: [], mergeRetries: 0, ...overrides };
|
||||
}
|
||||
|
||||
function makeStore(tasks: Record<string, unknown>[]) {
|
||||
function makeStore(tasks: Record<string, unknown>[], scopes: Record<string, string[]> = {}) {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
autoUnpauseEnabled: false,
|
||||
@@ -7192,6 +7192,7 @@ describe("FN-4538 overlapBlockedBy self-healing", () => {
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
} as unknown as Settings),
|
||||
parseFileScopeFromPrompt: vi.fn(async (taskId: string) => scopes[taskId] ?? ["packages/engine/src/scheduler.ts"]),
|
||||
});
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockImplementation(async (options?: { column?: string }) => {
|
||||
if (options?.column === "todo") return tasks.filter((task) => task.column === "todo");
|
||||
@@ -7391,6 +7392,31 @@ describe("FN-4538 overlapBlockedBy self-healing", () => {
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-TARGET", expect.objectContaining({ status: null }));
|
||||
manager.stop();
|
||||
});
|
||||
it("FN-783: clearStaleBlockedBy clears queued status when overlap blocker no longer shares effective write scope", async () => {
|
||||
const overlapBlocker = makeTask("FN-ACTIVE", { column: "in-progress" });
|
||||
const target = makeTask("FN-TARGET", {
|
||||
column: "todo",
|
||||
status: "queued",
|
||||
blockedBy: undefined,
|
||||
overlapBlockedBy: "FN-ACTIVE",
|
||||
dependencies: [],
|
||||
});
|
||||
const store = makeStore([target, overlapBlocker], {
|
||||
"FN-ACTIVE": ["project.yml", "Tests/AtlasNotesMobileUITests/**"],
|
||||
"FN-TARGET": ["packages/core/**", "packages/engine/**"],
|
||||
});
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
|
||||
await manager.clearStaleBlockedBy();
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-TARGET", { blockedBy: null, overlapBlockedBy: null, status: null });
|
||||
expect(store.logEntry).not.toHaveBeenCalledWith(
|
||||
"FN-TARGET",
|
||||
"Auto-recovered: preserved queued status — still blocked by file scope overlap with FN-ACTIVE",
|
||||
);
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("stale triage processing eviction before recovery", () => {
|
||||
|
||||
@@ -3651,6 +3651,27 @@ export class SelfHealingManager {
|
||||
await this.reconcileTaskWorktreeMetadata({ includeTaskIds: new Set([taskId]) });
|
||||
const allTasks = await this.store.listTasks({ slim: true, includeArchived: true });
|
||||
const taskById = new Map(allTasks.map((t) => [t.id, t]));
|
||||
const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
|
||||
const filteredScopeByTaskId = new Map<string, string[]>();
|
||||
const getFilteredFileScope = async (scopeTaskId: string): Promise<string[]> => {
|
||||
const cached = filteredScopeByTaskId.get(scopeTaskId);
|
||||
if (cached) return cached;
|
||||
const scope = await this.store.parseFileScopeFromPrompt(scopeTaskId);
|
||||
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
|
||||
filteredScopeByTaskId.set(scopeTaskId, filteredScope);
|
||||
return filteredScope;
|
||||
};
|
||||
const hasActiveFileScopeOverlapBlocker = async (dependent: Task, blockerId: string | null | undefined): Promise<boolean> => {
|
||||
if (!blockerId) return false;
|
||||
const blocker = taskById.get(blockerId);
|
||||
if (!blocker || blocker.paused || blocker.userPaused) return false;
|
||||
if (blocker.column !== "in-progress" && !(blocker.column === "in-review" && !blocker.paused)) return false;
|
||||
const dependentScope = await getFilteredFileScope(dependent.id);
|
||||
if (dependentScope.length === 0 || isCoordinationOnlyTask(dependent, dependentScope)) return false;
|
||||
const blockerScope = await getFilteredFileScope(blocker.id);
|
||||
if (blockerScope.length === 0 || isCoordinationOnlyTask(blocker, blockerScope)) return false;
|
||||
return pathsOverlap(dependentScope, blockerScope);
|
||||
};
|
||||
const todoTasks = await this.store.listTasks({ column: "todo", slim: true });
|
||||
const inProgressTasks = await this.store.listTasks({ column: "in-progress", slim: true });
|
||||
const inReviewTasks = (await this.store.listTasks({ column: "in-review", slim: true })).filter((t) => !t.paused);
|
||||
@@ -3666,11 +3687,7 @@ export class SelfHealingManager {
|
||||
return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived";
|
||||
});
|
||||
const overlapBlockedBy = dependent.overlapBlockedBy === taskId ? null : (dependent.overlapBlockedBy ?? null);
|
||||
const overlapBlockerTask = overlapBlockedBy ? taskById.get(overlapBlockedBy) : undefined;
|
||||
const hasActiveOverlapBlocker = Boolean(
|
||||
overlapBlockerTask
|
||||
&& (overlapBlockerTask.column === "in-progress" || (overlapBlockerTask.column === "in-review" && !overlapBlockerTask.paused)),
|
||||
);
|
||||
const hasActiveOverlapBlocker = await hasActiveFileScopeOverlapBlocker(dependent, overlapBlockedBy);
|
||||
|
||||
if (todoTaskIds.has(dependent.id)) {
|
||||
if (unresolvedDeps.length > 0) {
|
||||
@@ -4748,6 +4765,28 @@ export class SelfHealingManager {
|
||||
|
||||
const allTasks = await this.store.listTasks({ includeArchived: true });
|
||||
const taskById = new Map(allTasks.map((task) => [task.id, task]));
|
||||
const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
|
||||
const filteredScopeByTaskId = new Map<string, string[]>();
|
||||
const getFilteredFileScope = async (taskId: string): Promise<string[]> => {
|
||||
const cached = filteredScopeByTaskId.get(taskId);
|
||||
if (cached) return cached;
|
||||
const scope = await this.store.parseFileScopeFromPrompt(taskId);
|
||||
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
|
||||
filteredScopeByTaskId.set(taskId, filteredScope);
|
||||
return filteredScope;
|
||||
};
|
||||
const hasActiveFileScopeOverlapBlocker = async (task: Task, blockerId: string | null | undefined): Promise<boolean> => {
|
||||
if (!blockerId) return false;
|
||||
const blocker = taskById.get(blockerId);
|
||||
if (!blocker || blocker.paused || blocker.userPaused) return false;
|
||||
if (blocker.column !== "in-progress" && !(blocker.column === "in-review" && !blocker.paused)) return false;
|
||||
|
||||
const taskScope = await getFilteredFileScope(task.id);
|
||||
if (taskScope.length === 0 || isCoordinationOnlyTask(task, taskScope)) return false;
|
||||
const blockerScope = await getFilteredFileScope(blocker.id);
|
||||
if (blockerScope.length === 0 || isCoordinationOnlyTask(blocker, blockerScope)) return false;
|
||||
return pathsOverlap(taskScope, blockerScope);
|
||||
};
|
||||
|
||||
let recovered = 0;
|
||||
const todoTaskIds = new Set(todoTasks.map((task) => task.id));
|
||||
@@ -4759,11 +4798,9 @@ export class SelfHealingManager {
|
||||
|
||||
for (const [taskId, lastLoggedBlockerId] of this.preservedQueuedOverlapLogged) {
|
||||
const memoTask = taskById.get(taskId);
|
||||
const memoOverlapBlocker = memoTask?.overlapBlockedBy ? taskById.get(memoTask.overlapBlockedBy) : undefined;
|
||||
const memoHasActiveOverlapBlocker = Boolean(
|
||||
memoOverlapBlocker
|
||||
&& (memoOverlapBlocker.column === "in-progress" || (memoOverlapBlocker.column === "in-review" && !memoOverlapBlocker.paused)),
|
||||
);
|
||||
const memoHasActiveOverlapBlocker = memoTask
|
||||
? await hasActiveFileScopeOverlapBlocker(memoTask, memoTask.overlapBlockedBy)
|
||||
: false;
|
||||
if (
|
||||
!candidates.has(taskId)
|
||||
|| memoTask?.column !== "todo"
|
||||
@@ -4784,11 +4821,7 @@ export class SelfHealingManager {
|
||||
// treated as resolved here by design.
|
||||
return dep && !dep.deletedAt && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived";
|
||||
});
|
||||
const overlapBlocker = task.overlapBlockedBy ? taskById.get(task.overlapBlockedBy) : undefined;
|
||||
const hasActiveOverlapBlocker = Boolean(
|
||||
overlapBlocker
|
||||
&& (overlapBlocker.column === "in-progress" || (overlapBlocker.column === "in-review" && !overlapBlocker.paused)),
|
||||
);
|
||||
const hasActiveOverlapBlocker = await hasActiveFileScopeOverlapBlocker(task, task.overlapBlockedBy);
|
||||
|
||||
if (blockedTaskIds.has(task.id)) {
|
||||
if (!blockerId) continue;
|
||||
|
||||
Reference in New Issue
Block a user