diff --git a/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts b/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts index 457282ee4a..46f5a71302 100644 --- a/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts +++ b/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts @@ -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()); + }); + }); diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 4305fd3963..e494363222 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -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[]) { + function makeStore(tasks: Record[], scopes: Record = {}) { 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).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", () => { diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 2a0807bf7e..217fe8ec70 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -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(); + const getFilteredFileScope = async (scopeTaskId: string): Promise => { + 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 => { + 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(); + const getFilteredFileScope = async (taskId: string): Promise => { + 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 => { + 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;