feat(FN-5091): complete Step 1 — add core dependency blocked detector

Fusion-Task-Id: FN-5091
Fusion-Task-Lineage: f59dc7dd-abc2-4d1d-a755-1fe446314131
This commit is contained in:
Fusion (runfusion.ai)
2026-05-19 02:58:32 -07:00
committed by gsxdsm
parent 0b9c652619
commit e97efc9f3c
2 changed files with 309 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
import { describe, expect, it } from "vitest";
import {
computeDependencyBlockedTodoReport,
DEFAULT_DEPENDENCY_BLOCKED_TODO_FRESH_MS,
} from "../dependency-blocked-todo-report.js";
import type { Task } from "../types.js";
const MAX_AUTO_MERGE_RETRIES = 3;
const NOW_ISO = "2026-01-01T12:00:00.000Z";
const NOW_MS = Date.parse(NOW_ISO);
function createTask(id: string, column: Task["column"], overrides: Partial<Task> = {}): Task {
return {
id,
description: id,
column,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
describe("computeDependencyBlockedTodoReport", () => {
it("returns an empty report for empty input", () => {
const report = computeDependencyBlockedTodoReport([], MAX_AUTO_MERGE_RETRIES, { now: NOW_MS });
expect(report.totalBlockedTodoCount).toBe(0);
expect(report.uniqueBlockerCount).toBe(0);
expect(report.groups).toEqual([]);
});
it("reports dependency-linked todo tasks under one blocker", () => {
const tasks = [
createTask("FN-5090", "in-progress"),
createTask("FN-5034", "todo", { dependencies: ["FN-5090"] }),
createTask("FN-5085", "todo", { dependencies: ["FN-5090"] }),
createTask("FN-5089", "todo", { dependencies: ["FN-5090"] }),
];
const report = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { now: NOW_MS });
expect(report.uniqueBlockerCount).toBe(1);
expect(report.groups[0]).toMatchObject({
blockerId: "FN-5090",
blockedTodoCount: 3,
viaDependencies: ["FN-5034", "FN-5085", "FN-5089"],
viaBlockedBy: [],
blockedTodoIds: ["FN-5034", "FN-5085", "FN-5089"],
});
});
it("dedupes todos across dependencies and blockedBy overlap", () => {
const tasks = [
createTask("B", "in-progress"),
createTask("T1", "todo", { dependencies: ["B"] }),
createTask("T2", "todo", { blockedBy: "B", dependencies: ["B"] }),
];
const report = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { now: NOW_MS });
expect(report.groups[0]?.viaDependencies).toEqual(["T1", "T2"]);
expect(report.groups[0]?.viaBlockedBy).toEqual(["T2"]);
expect(report.groups[0]?.blockedTodoIds).toEqual(["T1", "T2"]);
expect(report.groups[0]?.blockedTodoCount).toBe(2);
});
it("classifies age buckets as fresh, aging, and stale", () => {
const tasks = [
createTask("fresh", "in-progress", { columnMovedAt: "2026-01-01T11:55:00.000Z" }),
createTask("aging", "in-progress", { columnMovedAt: "2026-01-01T11:00:00.000Z" }),
createTask("stale", "in-progress", { columnMovedAt: "2026-01-01T06:00:00.000Z" }),
createTask("tf", "todo", { dependencies: ["fresh"] }),
createTask("ta", "todo", { dependencies: ["aging"] }),
createTask("ts", "todo", { dependencies: ["stale"] }),
];
const report = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { now: NOW_MS });
const byId = new Map(report.groups.map((group) => [group.blockerId, group.ageBucket]));
expect(byId.get("fresh")).toBe("fresh");
expect(byId.get("aging")).toBe("aging");
expect(byId.get("stale")).toBe("stale");
});
it("sorts by age bucket priority then count then age then id", () => {
const tasks = [
createTask("A-stale-older", "in-progress", { columnMovedAt: "2026-01-01T03:00:00.000Z" }),
createTask("B-stale-more", "in-progress", { columnMovedAt: "2026-01-01T05:00:00.000Z" }),
createTask("C-aging", "in-progress", { columnMovedAt: "2026-01-01T11:00:00.000Z" }),
createTask("D-fresh", "in-progress", { columnMovedAt: "2026-01-01T11:50:00.000Z" }),
createTask("t1", "todo", { dependencies: ["A-stale-older"] }),
createTask("t2", "todo", { dependencies: ["B-stale-more"] }),
createTask("t3", "todo", { dependencies: ["B-stale-more"] }),
createTask("t4", "todo", { dependencies: ["C-aging"] }),
createTask("t5", "todo", { dependencies: ["D-fresh"] }),
];
const report = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { now: NOW_MS });
expect(report.groups.map((group) => group.blockerId)).toEqual(["B-stale-more", "A-stale-older", "C-aging", "D-fresh"]);
});
it("applies minBlockedTodoCount and maxGroups limits", () => {
const tasks = [
createTask("B1", "in-progress"),
createTask("B2", "in-progress"),
createTask("T1", "todo", { dependencies: ["B1"] }),
createTask("T2", "todo", { dependencies: ["B1"] }),
createTask("T3", "todo", { dependencies: ["B2"] }),
];
const filtered = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, {
now: NOW_MS,
minBlockedTodoCount: 2,
});
expect(filtered.groups.map((group) => group.blockerId)).toEqual(["B1"]);
const capped = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, {
now: NOW_MS,
maxGroups: 1,
});
expect(capped.groups).toHaveLength(1);
});
it("excludes done and archived blockers", () => {
const tasks = [
createTask("done-blocker", "done"),
createTask("archived-blocker", "archived"),
createTask("td", "todo", { dependencies: ["done-blocker"] }),
createTask("ta", "todo", { dependencies: ["archived-blocker"] }),
];
const report = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { now: NOW_MS });
expect(report.groups).toEqual([]);
});
it("sanitizes invalid thresholds", () => {
const tasks = [
createTask("B", "in-progress", { columnMovedAt: "2026-01-01T11:40:00.000Z" }),
createTask("T", "todo", { dependencies: ["B"] }),
];
const report = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, {
now: NOW_MS,
freshAgeMs: -100,
staleAgeMs: 10,
minBlockedTodoCount: 0,
maxGroups: 0,
});
expect(report.thresholds.freshMs).toBe(DEFAULT_DEPENDENCY_BLOCKED_TODO_FRESH_MS);
expect(report.thresholds.staleMs).toBe(DEFAULT_DEPENDENCY_BLOCKED_TODO_FRESH_MS + 1);
expect(report.thresholds.minBlockedTodoCount).toBe(1);
expect(report.groups).toHaveLength(1);
});
it("reproduces FN-5091 motivating fixture", () => {
const tasks = [
createTask("FN-5090", "in-progress"),
createTask("FN-5034", "todo", { dependencies: ["FN-5090"] }),
createTask("FN-5085", "todo", { dependencies: ["FN-5090"] }),
createTask("FN-5089", "todo", { dependencies: ["FN-5090"] }),
];
const report = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { now: NOW_MS });
expect(report.groups).toHaveLength(1);
expect(report.groups[0]?.blockerId).toBe("FN-5090");
expect(report.groups[0]?.blockedTodoCount).toBe(3);
expect(report.groups[0]?.blockedTodoIds).toEqual(["FN-5034", "FN-5085", "FN-5089"]);
expect(report.observedAt).toBe(NOW_ISO);
});
});

View File

@@ -0,0 +1,138 @@
import { computeBlockerFanoutMap } from "./blocker-fanout.js";
import type { Task } from "./types.js";
export type DependencyBlockedTodoCode = "dependency-blocked-todo";
export interface DependencyBlockedTodoGroup {
blockerId: string;
blockerColumn: Task["column"] | "unknown";
blockerTitle?: string;
blockedTodoIds: string[];
blockedTodoCount: number;
blockingAgeMs: number;
ageBucket: "fresh" | "aging" | "stale";
viaDependencies: string[];
viaBlockedBy: string[];
}
export interface DependencyBlockedTodoReport {
observedAt: string;
totalBlockedTodoCount: number;
uniqueBlockerCount: number;
groups: DependencyBlockedTodoGroup[];
thresholds: {
freshMs: number;
staleMs: number;
minBlockedTodoCount: number;
};
}
export interface DependencyBlockedTodoReportContext {
now?: number;
freshAgeMs?: number;
staleAgeMs?: number;
minBlockedTodoCount?: number;
maxGroups?: number;
}
export const DEFAULT_DEPENDENCY_BLOCKED_TODO_FRESH_MS = 30 * 60_000;
export const DEFAULT_DEPENDENCY_BLOCKED_TODO_STALE_MS = 4 * 60 * 60_000;
export const DEFAULT_DEPENDENCY_BLOCKED_TODO_MIN_COUNT = 1;
export const DEFAULT_DEPENDENCY_BLOCKED_TODO_MAX_GROUPS = 10;
const AGE_BUCKET_PRIORITY: Record<DependencyBlockedTodoGroup["ageBucket"], number> = {
stale: 0,
aging: 1,
fresh: 2,
};
function sanitizeContext(context: DependencyBlockedTodoReportContext | undefined) {
const now = context?.now ?? Date.now();
const freshMs = Number.isFinite(context?.freshAgeMs) && (context?.freshAgeMs ?? 0) > 0
? Math.floor(context!.freshAgeMs as number)
: DEFAULT_DEPENDENCY_BLOCKED_TODO_FRESH_MS;
const requestedStaleMs = Number.isFinite(context?.staleAgeMs) && (context?.staleAgeMs ?? 0) > 0
? Math.floor(context!.staleAgeMs as number)
: DEFAULT_DEPENDENCY_BLOCKED_TODO_STALE_MS;
const staleMs = requestedStaleMs <= freshMs ? freshMs + 1 : requestedStaleMs;
const minBlockedTodoCount = Number.isFinite(context?.minBlockedTodoCount)
? Math.max(1, Math.floor(context!.minBlockedTodoCount as number))
: DEFAULT_DEPENDENCY_BLOCKED_TODO_MIN_COUNT;
const maxGroups = Number.isFinite(context?.maxGroups)
? Math.max(1, Math.floor(context!.maxGroups as number))
: DEFAULT_DEPENDENCY_BLOCKED_TODO_MAX_GROUPS;
return { now, freshMs, staleMs, minBlockedTodoCount, maxGroups };
}
export function computeDependencyBlockedTodoReport(
tasks: Task[],
maxAutoMergeRetries: number,
context: DependencyBlockedTodoReportContext = {},
): DependencyBlockedTodoReport {
const { now, freshMs, staleMs, minBlockedTodoCount, maxGroups } = sanitizeContext(context);
const blockerFanout = computeBlockerFanoutMap(tasks, maxAutoMergeRetries, { nowMs: now });
const todoTaskIds = new Set(tasks.filter((task) => task.column === "todo").map((task) => task.id));
const taskById = new Map(tasks.map((task) => [task.id, task]));
const groups: DependencyBlockedTodoGroup[] = [];
for (const [blockerId, entry] of blockerFanout.entries()) {
if (entry.activeTodoCount < minBlockedTodoCount) {
continue;
}
const blocker = taskById.get(blockerId);
if (!blocker || blocker.column === "done" || blocker.column === "archived") {
continue;
}
const viaDependencies = [...new Set(entry.dependencyDependentIds.filter((id) => todoTaskIds.has(id)))].sort();
const viaBlockedBy = [...new Set(entry.overlapBlockedDependentIds.filter((id) => todoTaskIds.has(id)))].sort();
const blockedTodoIds = [...new Set([...viaDependencies, ...viaBlockedBy])].sort();
const blockedTodoCount = blockedTodoIds.length;
if (blockedTodoCount < minBlockedTodoCount) {
continue;
}
const anchor = Date.parse(blocker.columnMovedAt ?? blocker.updatedAt);
const blockingAgeMs = Number.isFinite(anchor) ? Math.max(0, now - anchor) : 0;
const ageBucket: DependencyBlockedTodoGroup["ageBucket"] =
blockingAgeMs < freshMs ? "fresh" : blockingAgeMs >= staleMs ? "stale" : "aging";
groups.push({
blockerId,
blockerColumn: blocker.column ?? "unknown",
blockerTitle: blocker.title,
blockedTodoIds,
blockedTodoCount,
blockingAgeMs,
ageBucket,
viaDependencies,
viaBlockedBy,
});
}
groups.sort((a, b) => {
const agePriority = AGE_BUCKET_PRIORITY[a.ageBucket] - AGE_BUCKET_PRIORITY[b.ageBucket];
if (agePriority !== 0) return agePriority;
if (a.blockedTodoCount !== b.blockedTodoCount) return b.blockedTodoCount - a.blockedTodoCount;
if (a.blockingAgeMs !== b.blockingAgeMs) return b.blockingAgeMs - a.blockingAgeMs;
return a.blockerId.localeCompare(b.blockerId);
});
const keptGroups = groups.slice(0, maxGroups);
const totalBlockedTodoCount = new Set(keptGroups.flatMap((group) => group.blockedTodoIds)).size;
return {
observedAt: new Date(now).toISOString(),
totalBlockedTodoCount,
uniqueBlockerCount: keptGroups.length,
groups: keptGroups,
thresholds: {
freshMs,
staleMs,
minBlockedTodoCount,
},
};
}