feat(FN-3977): complete Step 1 — overlap fanout semantics
Fusion-Task-Id: FN-3977 Fusion-Task-Lineage: c84f2ce0-3726-49b2-ad84-6703edbfecf1
This commit is contained in:
@@ -20,12 +20,12 @@ function createTask(id: string, column: Task["column"], overrides: Partial<Task>
|
||||
}
|
||||
|
||||
describe("computeBlockerFanoutMap escalation", () => {
|
||||
it("escalates high fan-out blockers when age crosses threshold", () => {
|
||||
it("escalates high overlap fan-out blockers when age crosses threshold", () => {
|
||||
const nowMs = Date.parse("2026-01-01T06:00:00.000Z");
|
||||
const blocker = createTask("B", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z" });
|
||||
const dependents = [1, 2, 3, 4, 5].map((n) => createTask(`D${n}`, "todo", { dependencies: ["B"] }));
|
||||
const blockedByDependents = [1, 2, 3, 4, 5].map((n) => createTask(`D${n}`, "todo", { blockedBy: "B" }));
|
||||
|
||||
const entry = computeBlockerFanoutMap([blocker, ...dependents], MAX_AUTO_MERGE_RETRIES, {
|
||||
const entry = computeBlockerFanoutMap([blocker, ...blockedByDependents], MAX_AUTO_MERGE_RETRIES, {
|
||||
nowMs,
|
||||
staleHighFanoutAgeThresholdMs: 60 * 60 * 1000,
|
||||
}).get("B");
|
||||
@@ -38,12 +38,24 @@ describe("computeBlockerFanoutMap escalation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps short-lived high fan-out blockers quiet", () => {
|
||||
it("does not classify dependency-only fan-out as overlap bottleneck", () => {
|
||||
const blocker = createTask("B", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z" });
|
||||
const dependencyDependents = [1, 2, 3, 4, 5].map((n) => createTask(`D${n}`, "todo", { dependencies: ["B"] }));
|
||||
|
||||
const entry = computeBlockerFanoutMap([blocker, ...dependencyDependents], MAX_AUTO_MERGE_RETRIES).get("B");
|
||||
|
||||
expect(entry?.overlapBlockedTodoCount).toBe(0);
|
||||
expect(entry?.activeTodoCount).toBe(5);
|
||||
expect(entry?.isHighFanout).toBe(false);
|
||||
expect(entry?.escalation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps short-lived high overlap fan-out blockers quiet", () => {
|
||||
const nowMs = Date.parse("2026-01-01T00:10:00.000Z");
|
||||
const blocker = createTask("B", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z" });
|
||||
const dependents = [1, 2, 3, 4, 5].map((n) => createTask(`D${n}`, "todo", { dependencies: ["B"] }));
|
||||
const blockedByDependents = [1, 2, 3, 4, 5].map((n) => createTask(`D${n}`, "todo", { blockedBy: "B" }));
|
||||
|
||||
const entry = computeBlockerFanoutMap([blocker, ...dependents], MAX_AUTO_MERGE_RETRIES, {
|
||||
const entry = computeBlockerFanoutMap([blocker, ...blockedByDependents], MAX_AUTO_MERGE_RETRIES, {
|
||||
nowMs,
|
||||
staleHighFanoutAgeThresholdMs: 60 * 60 * 1000,
|
||||
}).get("B");
|
||||
@@ -51,18 +63,4 @@ describe("computeBlockerFanoutMap escalation", () => {
|
||||
expect(entry?.isHighFanout).toBe(true);
|
||||
expect(entry?.escalation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not escalate sub-threshold chains", () => {
|
||||
const nowMs = Date.parse("2026-01-01T10:00:00.000Z");
|
||||
const blocker = createTask("B", "in-review", { columnMovedAt: "2026-01-01T00:00:00.000Z" });
|
||||
const dependents = [1, 2, 3, 4].map((n) => createTask(`D${n}`, "todo", { dependencies: ["B"] }));
|
||||
|
||||
const entry = computeBlockerFanoutMap([blocker, ...dependents], MAX_AUTO_MERGE_RETRIES, {
|
||||
nowMs,
|
||||
staleHighFanoutAgeThresholdMs: 60 * 60 * 1000,
|
||||
}).get("B");
|
||||
|
||||
expect(entry?.isHighFanout).toBe(false);
|
||||
expect(entry?.escalation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface BlockerFanoutEntry {
|
||||
totalCount: number;
|
||||
activeTodoCount: number;
|
||||
dependentIds: string[];
|
||||
dependencyDependentIds: string[];
|
||||
overlapBlockedDependentIds: string[];
|
||||
overlapBlockedActiveCount: number;
|
||||
overlapBlockedTodoCount: number;
|
||||
staleBlockedByDependentIds: string[];
|
||||
isHighFanout: boolean;
|
||||
escalation?: BlockerEscalation;
|
||||
@@ -32,9 +36,12 @@ const ACTIVE_COLUMNS = new Set<Task["column"]>(["triage", "todo", "in-progress",
|
||||
|
||||
interface MutableEntry {
|
||||
dependentIds: string[];
|
||||
dependencyDependentIds: string[];
|
||||
blockedByDependentIds: string[];
|
||||
activeCount: number;
|
||||
activeTodoCount: number;
|
||||
overlapBlockedActiveCount: number;
|
||||
overlapBlockedTodoCount: number;
|
||||
}
|
||||
|
||||
export function isStaleBlockedByBlocker(blocker: Task | undefined, maxAutoMergeRetries: number): boolean {
|
||||
@@ -70,7 +77,15 @@ export function computeBlockerFanoutMap(
|
||||
const ensureEntry = (blockerId: string): MutableEntry => {
|
||||
let entry = fanout.get(blockerId);
|
||||
if (!entry) {
|
||||
entry = { dependentIds: [], blockedByDependentIds: [], activeCount: 0, activeTodoCount: 0 };
|
||||
entry = {
|
||||
dependentIds: [],
|
||||
dependencyDependentIds: [],
|
||||
blockedByDependentIds: [],
|
||||
activeCount: 0,
|
||||
activeTodoCount: 0,
|
||||
overlapBlockedActiveCount: 0,
|
||||
overlapBlockedTodoCount: 0,
|
||||
};
|
||||
fanout.set(blockerId, entry);
|
||||
}
|
||||
return entry;
|
||||
@@ -84,6 +99,7 @@ export function computeBlockerFanoutMap(
|
||||
if (!depId) continue;
|
||||
const entry = ensureEntry(depId);
|
||||
entry.dependentIds.push(task.id);
|
||||
entry.dependencyDependentIds.push(task.id);
|
||||
if (active) entry.activeCount += 1;
|
||||
if (isTodo) entry.activeTodoCount += 1;
|
||||
}
|
||||
@@ -92,8 +108,14 @@ export function computeBlockerFanoutMap(
|
||||
const entry = ensureEntry(task.blockedBy);
|
||||
entry.dependentIds.push(task.id);
|
||||
entry.blockedByDependentIds.push(task.id);
|
||||
if (active) entry.activeCount += 1;
|
||||
if (isTodo) entry.activeTodoCount += 1;
|
||||
if (active) {
|
||||
entry.activeCount += 1;
|
||||
entry.overlapBlockedActiveCount += 1;
|
||||
}
|
||||
if (isTodo) {
|
||||
entry.activeTodoCount += 1;
|
||||
entry.overlapBlockedTodoCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +126,7 @@ export function computeBlockerFanoutMap(
|
||||
? [...entry.blockedByDependentIds]
|
||||
: [];
|
||||
|
||||
const isHighFanout = entry.activeTodoCount >= highFanoutTodoThreshold;
|
||||
const isHighFanout = entry.overlapBlockedTodoCount >= highFanoutTodoThreshold;
|
||||
const blockingAgeMs = blocker ? getBlockingAgeMs(blocker, nowMs) : 0;
|
||||
const blockerColumn = blocker?.column;
|
||||
const shouldEscalate =
|
||||
@@ -117,13 +139,17 @@ export function computeBlockerFanoutMap(
|
||||
totalCount: entry.activeCount,
|
||||
activeTodoCount: entry.activeTodoCount,
|
||||
dependentIds: entry.dependentIds,
|
||||
dependencyDependentIds: entry.dependencyDependentIds,
|
||||
overlapBlockedDependentIds: entry.blockedByDependentIds,
|
||||
overlapBlockedActiveCount: entry.overlapBlockedActiveCount,
|
||||
overlapBlockedTodoCount: entry.overlapBlockedTodoCount,
|
||||
staleBlockedByDependentIds,
|
||||
isHighFanout,
|
||||
escalation: shouldEscalate
|
||||
? {
|
||||
blockerId,
|
||||
activeTodoCount: entry.activeTodoCount,
|
||||
totalActiveCount: entry.activeCount,
|
||||
activeTodoCount: entry.overlapBlockedTodoCount,
|
||||
totalActiveCount: entry.overlapBlockedActiveCount,
|
||||
blockingAgeMs,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -40,6 +40,10 @@ describe("computeBlockerFanoutMap", () => {
|
||||
totalCount: 1,
|
||||
activeTodoCount: 1,
|
||||
dependentIds: ["FN-2"],
|
||||
dependencyDependentIds: ["FN-2"],
|
||||
overlapBlockedDependentIds: [],
|
||||
overlapBlockedActiveCount: 0,
|
||||
overlapBlockedTodoCount: 0,
|
||||
staleBlockedByDependentIds: [],
|
||||
isHighFanout: false,
|
||||
escalation: undefined,
|
||||
@@ -57,24 +61,10 @@ describe("computeBlockerFanoutMap", () => {
|
||||
totalCount: 2,
|
||||
activeTodoCount: 1,
|
||||
dependentIds: ["FN-2", "FN-3"],
|
||||
staleBlockedByDependentIds: [],
|
||||
isHighFanout: false,
|
||||
escalation: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes done/archived dependents from totalCount but keeps dependentIds", () => {
|
||||
const tasks = [
|
||||
createTask("FN-1", "in-progress"),
|
||||
createTask("FN-2", "done", { dependencies: ["FN-1"] }),
|
||||
createTask("FN-3", "archived", { blockedBy: "FN-1" }),
|
||||
createTask("FN-4", "todo", { dependencies: ["FN-1"] }),
|
||||
];
|
||||
|
||||
expect(computeBlockerFanoutMap(tasks).get("FN-1")).toEqual({
|
||||
totalCount: 1,
|
||||
activeTodoCount: 1,
|
||||
dependentIds: ["FN-2", "FN-3", "FN-4"],
|
||||
dependencyDependentIds: ["FN-2"],
|
||||
overlapBlockedDependentIds: ["FN-3"],
|
||||
overlapBlockedActiveCount: 1,
|
||||
overlapBlockedTodoCount: 0,
|
||||
staleBlockedByDependentIds: [],
|
||||
isHighFanout: false,
|
||||
escalation: undefined,
|
||||
@@ -87,107 +77,47 @@ describe("computeBlockerFanoutMap", () => {
|
||||
createTask("FN-3", "todo", { blockedBy: "MISSING" }),
|
||||
];
|
||||
|
||||
expect(computeBlockerFanoutMap(tasks).get("MISSING")).toEqual({
|
||||
totalCount: 2,
|
||||
activeTodoCount: 2,
|
||||
dependentIds: ["FN-2", "FN-3"],
|
||||
staleBlockedByDependentIds: ["FN-3"],
|
||||
isHighFanout: false,
|
||||
escalation: undefined,
|
||||
});
|
||||
expect(computeBlockerFanoutMap(tasks).get("MISSING")?.staleBlockedByDependentIds).toEqual(["FN-3"]);
|
||||
});
|
||||
|
||||
it("marks blockedBy edges stale when blocker is done", () => {
|
||||
const tasks = [createTask("B", "done"), createTask("D", "todo", { blockedBy: "B" })];
|
||||
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
|
||||
});
|
||||
|
||||
it("marks blockedBy edges stale when blocker is archived", () => {
|
||||
const tasks = [createTask("B", "archived"), createTask("D", "todo", { blockedBy: "B" })];
|
||||
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
|
||||
});
|
||||
|
||||
it("marks blockedBy edges stale when blocker is in-review and paused", () => {
|
||||
const tasks = [createTask("B", "in-review", { paused: true }), createTask("D", "todo", { blockedBy: "B" })];
|
||||
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
|
||||
});
|
||||
|
||||
it("marks blockedBy edges stale when blocker failed in-review at max retries", () => {
|
||||
const tasks = [
|
||||
createTask("B", "in-review", { status: "failed", mergeRetries: MAX_AUTO_MERGE_RETRIES }),
|
||||
createTask("D", "todo", { blockedBy: "B" }),
|
||||
];
|
||||
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
|
||||
});
|
||||
|
||||
it("FN-3897 regression: reports active and todo downstream counts for high fan-out blockers", () => {
|
||||
it("flags overlap fan-out blockers with at least 5 blockedBy todo dependents as high fan-out", () => {
|
||||
const tasks = [
|
||||
createTask("B", "in-progress"),
|
||||
createTask("D1", "todo", { dependencies: ["B"] }),
|
||||
createTask("D2", "todo", { blockedBy: "B" }),
|
||||
createTask("D3", "in-review", { dependencies: ["B"] }),
|
||||
createTask("D4", "done", { dependencies: ["B"] }),
|
||||
];
|
||||
|
||||
expect(computeBlockerFanoutMap(tasks).get("B")).toEqual({
|
||||
totalCount: 3,
|
||||
activeTodoCount: 2,
|
||||
dependentIds: ["D1", "D2", "D3", "D4"],
|
||||
staleBlockedByDependentIds: [],
|
||||
isHighFanout: false,
|
||||
escalation: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("flags blockers with at least 5 active todo dependents as high fan-out", () => {
|
||||
const tasks = [
|
||||
createTask("B", "in-progress"),
|
||||
createTask("D1", "todo", { dependencies: ["B"] }),
|
||||
createTask("D2", "todo", { dependencies: ["B"] }),
|
||||
createTask("D3", "todo", { blockedBy: "B" }),
|
||||
createTask("D4", "todo", { blockedBy: "B" }),
|
||||
createTask("D5", "todo", { dependencies: ["B"] }),
|
||||
createTask("DONE", "done", { dependencies: ["B"] }),
|
||||
createTask("D5", "todo", { blockedBy: "B" }),
|
||||
createTask("D6", "todo", { blockedBy: "B" }),
|
||||
];
|
||||
|
||||
expect(computeBlockerFanoutMap(tasks).get("B")).toEqual({
|
||||
totalCount: 5,
|
||||
activeTodoCount: 5,
|
||||
dependentIds: ["D1", "D2", "D3", "D4", "D5", "DONE"],
|
||||
staleBlockedByDependentIds: [],
|
||||
isHighFanout: true,
|
||||
escalation: undefined,
|
||||
});
|
||||
expect(computeBlockerFanoutMap(tasks).get("B")?.isHighFanout).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag ordinary fan-out chains below threshold", () => {
|
||||
it("does not flag dependency-only chains as overlap high fan-out", () => {
|
||||
const tasks = [
|
||||
createTask("B", "in-review"),
|
||||
createTask("B", "in-progress"),
|
||||
createTask("D1", "todo", { dependencies: ["B"] }),
|
||||
createTask("D2", "todo", { blockedBy: "B" }),
|
||||
createTask("D2", "todo", { dependencies: ["B"] }),
|
||||
createTask("D3", "todo", { dependencies: ["B"] }),
|
||||
createTask("D4", "todo", { dependencies: ["B"] }),
|
||||
createTask("ARCH", "archived", { dependencies: ["B"] }),
|
||||
createTask("D5", "todo", { dependencies: ["B"] }),
|
||||
];
|
||||
|
||||
expect(computeBlockerFanoutMap(tasks).get("B")).toEqual({
|
||||
totalCount: 4,
|
||||
activeTodoCount: 4,
|
||||
dependentIds: ["D1", "D2", "D3", "D4", "ARCH"],
|
||||
staleBlockedByDependentIds: [],
|
||||
isHighFanout: false,
|
||||
escalation: undefined,
|
||||
});
|
||||
const entry = computeBlockerFanoutMap(tasks).get("B");
|
||||
expect(entry?.activeTodoCount).toBe(5);
|
||||
expect(entry?.overlapBlockedTodoCount).toBe(0);
|
||||
expect(entry?.isHighFanout).toBe(false);
|
||||
});
|
||||
|
||||
it("escalates aged high fan-out blockers only when old enough", () => {
|
||||
it("escalates aged overlap high fan-out blockers only when old enough", () => {
|
||||
const tasks = [
|
||||
createTask("B", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z" }),
|
||||
createTask("D1", "todo", { dependencies: ["B"] }),
|
||||
createTask("D2", "todo", { dependencies: ["B"] }),
|
||||
createTask("D3", "todo", { dependencies: ["B"] }),
|
||||
createTask("D4", "todo", { dependencies: ["B"] }),
|
||||
createTask("D5", "todo", { dependencies: ["B"] }),
|
||||
createTask("D1", "todo", { blockedBy: "B" }),
|
||||
createTask("D2", "todo", { blockedBy: "B" }),
|
||||
createTask("D3", "todo", { blockedBy: "B" }),
|
||||
createTask("D4", "todo", { blockedBy: "B" }),
|
||||
createTask("D5", "todo", { blockedBy: "B" }),
|
||||
];
|
||||
|
||||
const entry = computeBlockerFanoutMap(tasks, {
|
||||
@@ -195,27 +125,7 @@ describe("computeBlockerFanoutMap", () => {
|
||||
}).get("B");
|
||||
|
||||
expect(entry?.isHighFanout).toBe(true);
|
||||
expect(entry?.escalation?.blockerId).toBe("B");
|
||||
expect(entry?.escalation?.activeTodoCount).toBe(5);
|
||||
expect((entry?.escalation?.blockingAgeMs ?? 0) / (60 * 60 * 1000)).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("keeps short-lived high fan-out blockers quiet", () => {
|
||||
const tasks = [
|
||||
createTask("B", "in-progress", { columnMovedAt: new Date().toISOString() }),
|
||||
createTask("D1", "todo", { dependencies: ["B"] }),
|
||||
createTask("D2", "todo", { dependencies: ["B"] }),
|
||||
createTask("D3", "todo", { dependencies: ["B"] }),
|
||||
createTask("D4", "todo", { dependencies: ["B"] }),
|
||||
createTask("D5", "todo", { dependencies: ["B"] }),
|
||||
];
|
||||
|
||||
const entry = computeBlockerFanoutMap(tasks, {
|
||||
staleHighFanoutAgeThresholdMs: 60 * 60 * 1000,
|
||||
}).get("B");
|
||||
|
||||
expect(entry?.isHighFanout).toBe(true);
|
||||
expect(entry?.escalation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps MAX_AUTO_MERGE_RETRIES aligned with engine self-healing source", () => {
|
||||
|
||||
Reference in New Issue
Block a user