diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 338a355ba6..f6be4ee7a8 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -6941,6 +6941,129 @@ describe("FN-4538 overlapBlockedBy self-healing", () => { manager.stop(); }); + it("FN-6276: clearStaleBlockedBy logs unchanged active overlap blocker only once across passes", 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]); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + const message = "Auto-recovered: preserved queued status — still blocked by file scope overlap with FN-ACTIVE"; + + await manager.clearStaleBlockedBy(); + await manager.clearStaleBlockedBy(); + await manager.clearStaleBlockedBy(); + + expect(store.updateTask).toHaveBeenCalledTimes(3); + expect(store.updateTask).toHaveBeenNthCalledWith(1, "FN-TARGET", { blockedBy: null, status: "queued" }); + expect((store.logEntry as ReturnType).mock.calls.filter((call) => call[0] === "FN-TARGET" && call[1] === message)).toHaveLength(1); + manager.stop(); + }); + + it("FN-6276: clearStaleBlockedBy logs again when active overlap blocker changes", async () => { + const overlapBlockerA = makeTask("FN-ACTIVE-A", { column: "in-progress" }); + const overlapBlockerB = makeTask("FN-ACTIVE-B", { column: "in-progress" }); + const target = makeTask("FN-TARGET", { + column: "todo", + status: "queued", + blockedBy: undefined, + overlapBlockedBy: "FN-ACTIVE-A", + dependencies: [], + }); + const store = makeStore([target, overlapBlockerA, overlapBlockerB]); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + + await manager.clearStaleBlockedBy(); + target.overlapBlockedBy = "FN-ACTIVE-B"; + await manager.clearStaleBlockedBy(); + + expect(store.logEntry).toHaveBeenCalledWith( + "FN-TARGET", + "Auto-recovered: preserved queued status — still blocked by file scope overlap with FN-ACTIVE-A", + ); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-TARGET", + "Auto-recovered: preserved queued status — still blocked by file scope overlap with FN-ACTIVE-B", + ); + expect((store.logEntry as ReturnType).mock.calls.filter((call) => call[0] === "FN-TARGET" && String(call[1]).includes("preserved queued status"))).toHaveLength(2); + manager.stop(); + }); + + it("FN-6276: clearStaleBlockedBy resets preserved queued memo after blocker resolves", 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]); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + const message = "Auto-recovered: preserved queued status — still blocked by file scope overlap with FN-ACTIVE"; + + await manager.clearStaleBlockedBy(); + overlapBlocker.column = "done"; + await manager.clearStaleBlockedBy(); + target.status = null; + target.overlapBlockedBy = null; + await manager.clearStaleBlockedBy(); + target.status = "queued"; + target.overlapBlockedBy = "FN-ACTIVE"; + overlapBlocker.column = "in-progress"; + await manager.clearStaleBlockedBy(); + + expect((store.logEntry as ReturnType).mock.calls.filter((call) => call[0] === "FN-TARGET" && call[1] === message)).toHaveLength(2); + manager.stop(); + }); + + it("FN-6276: stop clears preserved queued memo so next pass logs again", 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]); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + const message = "Auto-recovered: preserved queued status — still blocked by file scope overlap with FN-ACTIVE"; + + await manager.clearStaleBlockedBy(); + manager.stop(); + await manager.clearStaleBlockedBy(); + + expect((store.logEntry as ReturnType).mock.calls.filter((call) => call[0] === "FN-TARGET" && call[1] === message)).toHaveLength(2); + manager.stop(); + }); + + it("FN-6276: FN-5488 stale blockedBy overlap-preservation log is idempotent", async () => { + const staleBlocker = makeTask("FN-DONE", { column: "done" }); + const overlapBlocker = makeTask("FN-ACTIVE", { column: "in-progress" }); + const target = makeTask("FN-TARGET", { + column: "todo", + status: "queued", + blockedBy: "FN-DONE", + overlapBlockedBy: "FN-ACTIVE", + dependencies: [], + }); + const store = makeStore([target, staleBlocker, overlapBlocker]); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + const message = "Auto-recovered (FN-5488): preserved queued status — blocker=FN-DONE blockerStatus=none reason=blocker-done; still blocked by file scope overlap with FN-ACTIVE"; + + await manager.clearStaleBlockedBy(); + await manager.clearStaleBlockedBy(); + + expect(store.updateTask).toHaveBeenCalledTimes(2); + expect((store.logEntry as ReturnType).mock.calls.filter((call) => call[0] === "FN-TARGET" && call[1] === message)).toHaveLength(1); + manager.stop(); + }); + it("FN-4538: clearStaleBlockedBy clears overlapBlockedBy when overlap blocker is done", async () => { const overlapBlocker = makeTask("FN-DONE", { column: "done" }); const target = makeTask("FN-TARGET", { diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 0ad184647d..e7fef7342b 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -653,6 +653,7 @@ export class SelfHealingManager { private finalizeUnprovenWarned = new Set(); private metaResolvedSkipAuditMemo = new Map(); private metaStalledSkipAuditMemo = new Map(); + private preservedQueuedOverlapLogged = new Map(); private maintenanceTickCounter = 0; private readonly processBootStartedAt = Date.now(); private dependencyBlockedTodoReporter: DependencyBlockedTodoReporter | null = null; @@ -1098,6 +1099,7 @@ export class SelfHealingManager { this.finalizeUnprovenWarned.clear(); this.metaResolvedSkipAuditMemo.clear(); this.metaStalledSkipAuditMemo.clear(); + this.preservedQueuedOverlapLogged.clear(); log.log("Stopped"); } @@ -4018,6 +4020,18 @@ export class SelfHealingManager { memo.delete(taskId); } + private shouldLogPreservedQueuedOverlap(taskId: string, overlapBlockedBy: string | null | undefined): overlapBlockedBy is string { + if (!overlapBlockedBy) return false; + const previous = this.preservedQueuedOverlapLogged.get(taskId); + if (previous === overlapBlockedBy) return false; + this.preservedQueuedOverlapLogged.set(taskId, overlapBlockedBy); + return true; + } + + private clearPreservedQueuedOverlapMemo(taskId: string): void { + this.preservedQueuedOverlapLogged.delete(taskId); + } + async autoArchiveResolvedMetaTasks(reboundedTargets?: Set): Promise { const tasks = await this.store.listTasks({ slim: false, includeArchived: true }); const byId = new Map(tasks.map((task) => [task.id.toUpperCase(), task])); @@ -4354,7 +4368,10 @@ export class SelfHealingManager { (task) => task.status === "queued" && (task.dependencies.length > 0 || Boolean(task.overlapBlockedBy)), ); - if (blockedTasks.length === 0 && queuedDependencyTasks.length === 0) return 0; + if (blockedTasks.length === 0 && queuedDependencyTasks.length === 0) { + this.preservedQueuedOverlapLogged.clear(); + return 0; + } const allTasks = await this.store.listTasks({ includeArchived: true }); const taskById = new Map(allTasks.map((task) => [task.id, task])); @@ -4367,6 +4384,24 @@ export class SelfHealingManager { for (const task of blockedTasks) candidates.set(task.id, task); for (const task of queuedDependencyTasks) candidates.set(task.id, task); + 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)), + ); + if ( + !candidates.has(taskId) + || memoTask?.column !== "todo" + || memoTask.status !== "queued" + || memoTask.overlapBlockedBy !== lastLoggedBlockerId + || !memoHasActiveOverlapBlocker + ) { + this.clearPreservedQueuedOverlapMemo(taskId); + } + } + for (const task of candidates.values()) { const blockerId = task.blockedBy; @@ -4462,26 +4497,36 @@ export class SelfHealingManager { if (reason) { try { + let didRecover = false; if (todoTaskIds.has(task.id)) { if (unresolvedDeps.length > 0) { + this.clearPreservedQueuedOverlapMemo(task.id); const nextBlocker = unresolvedDeps[0]!; if (nextBlocker === blockerId) { continue; } await this.store.updateTask(task.id, { blockedBy: nextBlocker, status: "queued" }); await this.store.logEntry(task.id, `Auto-recovered (FN-5488): refreshed stale blockedBy — blocker=${blockerId} blockerStatus=${blocker?.status ?? "none"} reason=${reasonCode ?? "unspecified"}; ${reason}; now blocked by ${nextBlocker}`); + didRecover = true; } else if (hasActiveOverlapBlocker) { await this.store.updateTask(task.id, { blockedBy: null, status: "queued" }); - await this.store.logEntry(task.id, `Auto-recovered (FN-5488): preserved queued status — blocker=${blockerId} blockerStatus=${blocker?.status ?? "none"} reason=${reasonCode ?? "unspecified"}; still blocked by file scope overlap with ${task.overlapBlockedBy}`); + if (this.shouldLogPreservedQueuedOverlap(task.id, task.overlapBlockedBy)) { + await this.store.logEntry(task.id, `Auto-recovered (FN-5488): preserved queued status — blocker=${blockerId} blockerStatus=${blocker?.status ?? "none"} reason=${reasonCode ?? "unspecified"}; still blocked by file scope overlap with ${task.overlapBlockedBy}`); + didRecover = true; + } } else { + this.clearPreservedQueuedOverlapMemo(task.id); await this.store.updateTask(task.id, { blockedBy: null, overlapBlockedBy: null, status: null }); await this.store.logEntry(task.id, `Auto-recovered (FN-5488): cleared stale blockedBy — blocker=${blockerId} blockerStatus=${blocker?.status ?? "none"} reason=${reasonCode ?? "unspecified"}; ${reason}`); + didRecover = true; } } else { + this.clearPreservedQueuedOverlapMemo(task.id); await this.store.updateTask(task.id, { blockedBy: null }); await this.store.logEntry(task.id, `Auto-recovered (FN-4091): cleared stale blockedBy — ${reason}`); + didRecover = true; } - recovered++; + if (didRecover) recovered++; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); log.error(`Failed to clear stale blockedBy for ${task.id}: ${errorMessage}`); @@ -4499,14 +4544,15 @@ export class SelfHealingManager { try { if (hasActiveOverlapBlocker) { await this.store.updateTask(task.id, { blockedBy: null, status: "queued" }); - await this.store.logEntry(task.id, `Auto-recovered: preserved queued status — still blocked by file scope overlap with ${task.overlapBlockedBy}`); + if (this.shouldLogPreservedQueuedOverlap(task.id, task.overlapBlockedBy)) { + await this.store.logEntry(task.id, `Auto-recovered: preserved queued status — still blocked by file scope overlap with ${task.overlapBlockedBy}`); + recovered++; + } } else { + this.clearPreservedQueuedOverlapMemo(task.id); // FN-5434: routine scheduler↔self-healing queued-status churn should stay silent; keep state cleanup only. await this.store.updateTask(task.id, { blockedBy: null, overlapBlockedBy: null, status: null }); } - if (hasActiveOverlapBlocker) { - recovered++; - } } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); log.error(`Failed to clear stale queued status for ${task.id}: ${errorMessage}`); @@ -4515,6 +4561,7 @@ export class SelfHealingManager { continue; } + this.clearPreservedQueuedOverlapMemo(task.id); const nextBlocker = unresolvedDeps[0] ?? null; if (nextBlocker && task.blockedBy !== nextBlocker) { try {