feat(FN-5091): complete Step 3 — add dependency blocked todo reporter

Fusion-Task-Id: FN-5091
Fusion-Task-Lineage: f59dc7dd-abc2-4d1d-a755-1fe446314131
This commit is contained in:
Fusion (runfusion.ai)
2026-05-19 03:04:15 -07:00
committed by gsxdsm
parent 8e3e71744b
commit 2bc1c023c8
2 changed files with 331 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Task, TaskStore } from "@fusion/core";
import {
DependencyBlockedTodoReporter,
DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX,
} from "../dependency-blocked-todo-reporter.js";
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-1",
description: "test",
title: "Test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
paused: false,
status: undefined,
blockedBy: "",
overlapBlockedBy: "",
log: [],
createdAt: "2026-05-18T00:00:00.000Z",
updatedAt: "2026-05-18T00:00:00.000Z",
...overrides,
} as Task;
}
function createStore(params: {
settings?: Record<string, unknown>;
tasks?: Task[];
insightStore?: { upsertInsight: ReturnType<typeof vi.fn>; listInsights: ReturnType<typeof vi.fn> };
throwInsightStore?: boolean;
}): TaskStore {
return {
getSettings: vi.fn().mockResolvedValue(params.settings ?? {}),
listTasks: vi.fn().mockResolvedValue(params.tasks ?? []),
getInsightStore: vi.fn().mockImplementation(() => {
if (params.throwInsightStore) throw new Error("missing insight store");
return params.insightStore;
}),
logEntry: vi.fn().mockResolvedValue(undefined),
} as unknown as TaskStore;
}
describe("DependencyBlockedTodoReporter", () => {
const logger = { warn: vi.fn(), error: vi.fn() };
const now = Date.parse("2026-05-18T12:00:00.000Z");
beforeEach(() => vi.clearAllMocks());
it("no-ops when disabled", async () => {
const store = createStore({ settings: { dependencyBlockedTodoReportEnabled: false } });
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "disabled" });
expect(store.logEntry).not.toHaveBeenCalled();
});
it("rejects invalid config", async () => {
const store = createStore({ settings: { dependencyBlockedTodoFreshAgeMs: -1 } });
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "invalid-config" });
expect(logger.warn).toHaveBeenCalled();
});
it("returns no-blocked-groups when no dependency-blocked todos exist", async () => {
const store = createStore({ tasks: [createTask({ id: "FN-0", column: "in-progress" })] });
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "no-blocked-groups" });
});
it("suppresses single fresh group noise", async () => {
const tasks = [
createTask({ id: "FN-B", column: "in-progress", columnMovedAt: "2026-05-18T11:58:00.000Z" }),
createTask({ id: "FN-T1", dependencies: ["FN-B"] }),
];
const store = createStore({ tasks });
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "below-significance" });
});
it("emits stale grouped insight payload", async () => {
const tasks = [
createTask({ id: "FN-5090", column: "in-progress", title: "Blocker", columnMovedAt: "2026-05-18T05:00:00.000Z" }),
createTask({ id: "FN-5034", dependencies: ["FN-5090"] }),
createTask({ id: "FN-5085", dependencies: ["FN-5090"] }),
createTask({ id: "FN-5089", dependencies: ["FN-5090"] }),
];
const insightStore = { upsertInsight: vi.fn(), listInsights: vi.fn().mockReturnValue([]) };
const store = createStore({ tasks, insightStore });
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
const result = await reporter.report();
expect(result).toEqual({ alerted: true, groupCount: 1 });
expect(insightStore.upsertInsight).toHaveBeenCalledTimes(1);
const payload = insightStore.upsertInsight.mock.calls[0][1];
expect(payload.title).toBe(`${DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX} 2026-05-18`);
expect(payload.category).toBe("workflow");
expect(payload.provenance.relatedEntityIds).toEqual(["FN-5090"]);
const content = JSON.parse(payload.content);
expect(content.groups).toHaveLength(1);
expect(content.groups[0]).toMatchObject({ blockerId: "FN-5090", blockedTodoCount: 3, blockerTitle: "Blocker" });
});
it("suppresses under cooldown", async () => {
const tasks = [
createTask({ id: "FN-5090", column: "in-progress", columnMovedAt: "2026-05-18T05:00:00.000Z" }),
createTask({ id: "FN-5034", dependencies: ["FN-5090"] }),
createTask({ id: "FN-5085", dependencies: ["FN-5090"] }),
createTask({ id: "FN-5089", dependencies: ["FN-5090"] }),
];
const insightStore = {
upsertInsight: vi.fn(),
listInsights: vi.fn().mockReturnValue([{ title: `${DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX} 2026-05-18`, updatedAt: "2026-05-18T11:59:30.000Z" }]),
};
const store = createStore({ tasks, insightStore, settings: { dependencyBlockedTodoReportCooldownMs: 60_000 } });
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "cooldown" });
});
it("re-emits outside cooldown", async () => {
const tasks = [
createTask({ id: "FN-5090", column: "in-progress", columnMovedAt: "2026-05-18T05:00:00.000Z" }),
createTask({ id: "FN-5034", dependencies: ["FN-5090"] }),
createTask({ id: "FN-5085", dependencies: ["FN-5090"] }),
createTask({ id: "FN-5089", dependencies: ["FN-5090"] }),
];
const insightStore = {
upsertInsight: vi.fn(),
listInsights: vi.fn().mockReturnValue([{ title: `${DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX} 2026-05-17`, updatedAt: "2026-05-18T11:58:00.000Z" }]),
};
const store = createStore({ tasks, insightStore, settings: { dependencyBlockedTodoReportCooldownMs: 60_000 } });
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
await expect(reporter.report()).resolves.toEqual({ alerted: true, groupCount: 1 });
expect(insightStore.upsertInsight).toHaveBeenCalledTimes(1);
});
it("falls back to logEntry when insight store unavailable", async () => {
const tasks = [
createTask({ id: "FN-5090", column: "in-progress", columnMovedAt: "2026-05-18T05:00:00.000Z" }),
createTask({ id: "FN-5034", dependencies: ["FN-5090"] }),
createTask({ id: "FN-5085", dependencies: ["FN-5090"] }),
createTask({ id: "FN-5089", dependencies: ["FN-5090"] }),
];
const store = createStore({ tasks, throwInsightStore: true });
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
await expect(reporter.report()).resolves.toEqual({ alerted: true, groupCount: 1 });
expect(store.logEntry).toHaveBeenCalledWith("FN-5090", expect.stringContaining("[dependency-blocked-todo]"));
expect(logger.warn).toHaveBeenCalled();
});
it("honors injected now for deterministic age bucket", async () => {
const tasks = [
createTask({ id: "FN-5090", column: "in-progress", columnMovedAt: "2026-05-18T11:20:00.000Z" }),
createTask({ id: "FN-5034", dependencies: ["FN-5090"] }),
createTask({ id: "FN-5085", dependencies: ["FN-5090"] }),
createTask({ id: "FN-5089", dependencies: ["FN-5090"] }),
];
const insightStore = { upsertInsight: vi.fn(), listInsights: vi.fn().mockReturnValue([]) };
const store = createStore({ tasks, insightStore });
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => Date.parse("2026-05-18T12:00:00.000Z") });
await reporter.report();
const content = JSON.parse(insightStore.upsertInsight.mock.calls[0][1].content);
expect(content.groups[0].ageBucket).toBe("aging");
});
});

View File

@@ -0,0 +1,165 @@
import {
computeDependencyBlockedTodoReport,
computeInsightFingerprint,
DEFAULT_DEPENDENCY_BLOCKED_TODO_MAX_GROUPS,
type TaskStore,
} from "@fusion/core";
import { createLogger } from "./logger.js";
const reporterLog = createLogger("dependency-blocked-todo");
const TITLE_PREFIX = "Backlog health: dependency-blocked todos";
type DependencyBlockedTodoReporterLogger = {
warn: (message: string, ...args: unknown[]) => void;
error?: (message: string, ...args: unknown[]) => void;
};
interface DependencyBlockedTodoReporterOptions {
store: TaskStore;
projectId: string;
logger?: DependencyBlockedTodoReporterLogger;
now?: () => number;
}
export class DependencyBlockedTodoReporter {
private readonly store: TaskStore;
private readonly projectId: string;
private readonly logger: DependencyBlockedTodoReporterLogger;
private readonly now: () => number;
constructor(options: DependencyBlockedTodoReporterOptions) {
this.store = options.store;
this.projectId = options.projectId;
this.logger = options.logger ?? reporterLog;
this.now = options.now ?? (() => Date.now());
}
async report(): Promise<{ alerted: boolean; reason?: string; groupCount?: number }> {
try {
const settings = await this.store.getSettings();
if (settings.dependencyBlockedTodoReportEnabled === false) {
return { alerted: false, reason: "disabled" };
}
const freshAgeMs = settings.dependencyBlockedTodoFreshAgeMs ?? 30 * 60_000;
const staleAgeMs = settings.dependencyBlockedTodoStaleAgeMs ?? 4 * 60 * 60_000;
const minBlockedTodoCount = settings.dependencyBlockedTodoMinCount ?? 1;
const cooldownMs = settings.dependencyBlockedTodoReportCooldownMs ?? 6 * 60 * 60_000;
if (
!Number.isFinite(freshAgeMs) ||
freshAgeMs <= 0 ||
!Number.isFinite(staleAgeMs) ||
staleAgeMs <= 0 ||
!Number.isFinite(minBlockedTodoCount) ||
minBlockedTodoCount <= 0 ||
!Number.isFinite(cooldownMs) ||
cooldownMs < 0
) {
this.logger.warn("[dependency-blocked-todo] invalid config: thresholds must be valid finite values");
return { alerted: false, reason: "invalid-config" };
}
const maxAutoMergeRetries = settings.maxAutoMergeRetries ?? 3;
const tasks = await this.store.listTasks({ slim: true, includeArchived: false });
const taskById = new Map(tasks.map((task) => [task.id, task]));
const nowMs = this.now();
const report = computeDependencyBlockedTodoReport(tasks, maxAutoMergeRetries, {
now: nowMs,
freshAgeMs,
staleAgeMs,
minBlockedTodoCount,
maxGroups: DEFAULT_DEPENDENCY_BLOCKED_TODO_MAX_GROUPS,
});
if (report.uniqueBlockerCount === 0) {
return { alerted: false, reason: "no-blocked-groups" };
}
const hasAgingOrStale = report.groups.some((group) => group.ageBucket !== "fresh");
if (!hasAgingOrStale && report.totalBlockedTodoCount < 3) {
return { alerted: false, reason: "below-significance" };
}
const detectedAt = new Date(nowMs).toISOString();
const title = `${TITLE_PREFIX} ${detectedAt.slice(0, 10)}`;
const contentPayload = {
observedAt: report.observedAt,
totalBlockedTodoCount: report.totalBlockedTodoCount,
uniqueBlockerCount: report.uniqueBlockerCount,
thresholds: report.thresholds,
groups: report.groups.map((group) => ({
blockerId: group.blockerId,
blockerColumn: group.blockerColumn,
blockerTitle: taskById.get(group.blockerId)?.title,
blockedTodoCount: group.blockedTodoCount,
ageBucket: group.ageBucket,
blockingAgeMs: group.blockingAgeMs,
blockedTodoIds: group.blockedTodoIds.slice(0, 10),
viaDependencies: group.viaDependencies.slice(0, 10),
viaBlockedBy: group.viaBlockedBy.slice(0, 10),
})),
};
const content = JSON.stringify(contentPayload);
let insightStore;
try {
if (!this.projectId) throw new Error("empty projectId");
insightStore = this.store.getInsightStore();
} catch (error) {
await this.store.logEntry(report.groups[0].blockerId, `[dependency-blocked-todo] ${content}`);
this.logger.warn("[dependency-blocked-todo] insight store unavailable; logged fallback payload", error);
this.logger.warn(
`[dependency-blocked-todo] alert: groups=${report.uniqueBlockerCount} blockedTodos=${report.totalBlockedTodoCount} blockers=${report.groups
.slice(0, 3)
.map((group) => group.blockerId)
.join(",")}`,
);
return { alerted: true, groupCount: report.uniqueBlockerCount };
}
if (cooldownMs > 0) {
const insights = insightStore.listInsights({
projectId: this.projectId,
category: "workflow",
status: "generated",
limit: 10,
});
const latest = [...insights]
.filter((insight) => insight.title.startsWith(TITLE_PREFIX))
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0];
if (latest) {
const updatedAtMs = Date.parse(latest.updatedAt);
if (Number.isFinite(updatedAtMs) && nowMs - updatedAtMs < cooldownMs) {
return { alerted: false, reason: "cooldown" };
}
}
}
insightStore.upsertInsight(this.projectId, {
title,
content,
category: "workflow",
fingerprint: computeInsightFingerprint(title, "workflow"),
provenance: {
trigger: "schedule",
description: "Dependency-blocked Todo grouping (generated by dependency-blocked-todo-reporter)",
relatedEntityIds: report.groups.map((group) => group.blockerId),
metadata: { generator: "dependency-blocked-todo-reporter" },
},
});
this.logger.warn(
`[dependency-blocked-todo] alert: groups=${report.uniqueBlockerCount} blockedTodos=${report.totalBlockedTodoCount} blockers=${report.groups
.slice(0, 3)
.map((group) => group.blockerId)
.join(",")}`,
);
return { alerted: true, groupCount: report.uniqueBlockerCount };
} catch (error) {
this.logger.error?.("[dependency-blocked-todo] reporter failed", error);
return { alerted: false, reason: "error" };
}
}
}
export { TITLE_PREFIX as DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX };