FN-6276: suppress repeated queued overlap recovery logs
Deduplicate self-healing log entries for queued tasks that remain blocked by the same active file-scope overlap. - Track the last active overlap blocker logged per task and skip duplicate preserved-queued log entries across self-healing passes. - Clear the memo when blockers change, resolve, or the manager stops so legitimate future recoveries are still logged. - Cover unchanged blockers, blocker changes, resolved blockers, stop/reset behavior, and FN-5488 stale-blockedBy preservation with regression tests. Files changed: packages/engine/src/__tests__/self-healing.test.ts | 123 +++++++++++++++++++++ packages/engine/src/self-healing.ts | 61 ++++++++-- 2 files changed, 177 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-6276 Fusion-Task-Lineage: 169d4402-ec4e-4fa9-8225-2f545c11a3f7
This commit is contained in:
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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", {
|
||||
|
||||
@@ -653,6 +653,7 @@ export class SelfHealingManager {
|
||||
private finalizeUnprovenWarned = new Set<string>();
|
||||
private metaResolvedSkipAuditMemo = new Map<string, string>();
|
||||
private metaStalledSkipAuditMemo = new Map<string, string>();
|
||||
private preservedQueuedOverlapLogged = new Map<string, string>();
|
||||
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<string>): Promise<number> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user