feat(FN-5093): complete Step 1 — core detector module

Fusion-Task-Id: FN-5093
Fusion-Task-Lineage: fe96617a-88f8-4a3c-b96c-c516d2206331
This commit is contained in:
Fusion (runfusion.ai)
2026-05-19 03:30:18 -07:00
committed by gsxdsm
parent 85cb845b87
commit d251cbecd1
2 changed files with 252 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_IN_REVIEW_STALLED_THRESHOLD_MS, getInReviewStalledSignal } from "../in-review-stalled.js";
const NOW = Date.parse("2026-05-19T12:00:00.000Z");
function iso(ms: number): string {
return new Date(ms).toISOString();
}
const baseTask = {
id: "FN-5093-A",
column: "in-review" as const,
paused: false,
status: "in-review" as const,
columnMovedAt: iso(NOW - DEFAULT_IN_REVIEW_STALLED_THRESHOLD_MS - 1_000),
updatedAt: iso(NOW - DEFAULT_IN_REVIEW_STALLED_THRESHOLD_MS - 1_000),
mergeDetails: {},
log: [],
};
describe("getInReviewStalledSignal", () => {
it("returns signal for quiet in-review task beyond threshold", () => {
const signal = getInReviewStalledSignal(baseTask, { now: NOW });
expect(signal?.code).toBe("in-review-stalled");
expect(signal?.quietMs).toBeGreaterThanOrEqual(DEFAULT_IN_REVIEW_STALLED_THRESHOLD_MS);
expect(signal?.lastActivitySource).toBe("column-moved");
});
it("returns undefined for paused task", () => {
expect(getInReviewStalledSignal({ ...baseTask, paused: true }, { now: NOW })).toBeUndefined();
});
it("returns undefined for non in-review column", () => {
expect(getInReviewStalledSignal({ ...baseTask, column: "todo" }, { now: NOW })).toBeUndefined();
});
it("returns undefined when autoMerge is disabled", () => {
expect(getInReviewStalledSignal(baseTask, { now: NOW, autoMerge: false })).toBeUndefined();
});
it("returns undefined when merge is already confirmed", () => {
expect(getInReviewStalledSignal({ ...baseTask, mergeDetails: { mergeConfirmed: true } }, { now: NOW })).toBeUndefined();
});
it("returns undefined for awaiting-user-review", () => {
expect(getInReviewStalledSignal({ ...baseTask, status: "awaiting-user-review" }, { now: NOW })).toBeUndefined();
});
it("returns undefined for awaiting-approval", () => {
expect(getInReviewStalledSignal({ ...baseTask, status: "awaiting-approval" }, { now: NOW })).toBeUndefined();
});
it.each(["merging", "merging-pr", "merging-fix"])("returns undefined for active merge status %s", (status) => {
expect(getInReviewStalledSignal({ ...baseTask, status }, { now: NOW })).toBeUndefined();
});
it("returns undefined for active merge owner", () => {
expect(getInReviewStalledSignal(baseTask, { now: NOW, activeMergeTaskId: baseTask.id })).toBeUndefined();
});
it("returns undefined when task is currently executing", () => {
expect(getInReviewStalledSignal(baseTask, {
now: NOW,
executingTaskIds: new Set([baseTask.id]),
})).toBeUndefined();
});
it("suppresses when recent log activity is within threshold and emits once aged out", () => {
const withRecentLog = {
...baseTask,
log: [{ timestamp: iso(NOW - 5_000), action: "random activity" }],
};
expect(getInReviewStalledSignal(withRecentLog, { now: NOW, thresholdMs: 10_000 })).toBeUndefined();
const later = getInReviewStalledSignal(withRecentLog, { now: NOW + 15_000, thresholdMs: 10_000 });
expect(later?.code).toBe("in-review-stalled");
});
it("suppresses while recent reason-driven stall log exists and emits after it ages out", () => {
const task = {
...baseTask,
log: [{ timestamp: iso(NOW - 2_000), action: "In-review stall surfaced [merge-blocker]: blocked" }],
};
expect(getInReviewStalledSignal(task, { now: NOW, thresholdMs: 10_000 })).toBeUndefined();
const later = getInReviewStalledSignal(task, { now: NOW + 12_000, thresholdMs: 10_000 });
expect(later?.code).toBe("in-review-stalled");
});
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])("returns undefined for invalid threshold %s", (thresholdMs) => {
expect(getInReviewStalledSignal(baseTask, { now: NOW, thresholdMs })).toBeUndefined();
});
it("uses log as last activity source when newest", () => {
const signal = getInReviewStalledSignal({
...baseTask,
columnMovedAt: iso(NOW - 30_000),
updatedAt: iso(NOW - 20_000),
log: [{ timestamp: iso(NOW - 10_000), action: "newest" }],
}, { now: NOW, thresholdMs: 10_000 });
expect(signal?.lastActivitySource).toBe("log");
});
it("uses column-moved when it is newest and log is absent", () => {
const signal = getInReviewStalledSignal({
...baseTask,
columnMovedAt: iso(NOW - 12_000),
updatedAt: iso(NOW - 20_000),
log: [],
}, { now: NOW, thresholdMs: 10_000 });
expect(signal?.lastActivitySource).toBe("column-moved");
});
it("uses updated when only updatedAt is parseable", () => {
const signal = getInReviewStalledSignal({
...baseTask,
columnMovedAt: "not-a-date",
updatedAt: iso(NOW - 12_000),
log: [],
}, { now: NOW, thresholdMs: 10_000 });
expect(signal?.lastActivitySource).toBe("updated");
});
});

View File

@@ -0,0 +1,129 @@
import { IN_REVIEW_STALL_LOG_PREFIX } from "./in-review-stall.js";
import type { Task } from "./types.js";
export type InReviewStalledCode = "in-review-stalled";
export interface InReviewStalledSignal {
code: InReviewStalledCode;
reason: string;
observedAt: string;
ageMs: number;
quietMs: number;
thresholdMs: number;
lastActivityAt: string;
lastActivitySource: "log" | "column-moved" | "updated";
}
export interface InReviewStalledContext {
now?: number;
thresholdMs?: number;
autoMerge?: boolean;
activeMergeTaskId?: string | null;
executingTaskIds?: ReadonlySet<string>;
}
export const DEFAULT_IN_REVIEW_STALLED_THRESHOLD_MS = 24 * 60 * 60_000;
const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]);
type InReviewStalledTask = Pick<Task, "id" | "column" | "paused" | "status" | "columnMovedAt" | "updatedAt" | "log" | "mergeDetails">;
type ActivityCandidate = {
time: number;
source: "log" | "column-moved" | "updated";
tiePriority: number;
};
export function getInReviewStalledSignal(
task: InReviewStalledTask,
context: InReviewStalledContext = {},
): InReviewStalledSignal | undefined {
if (task.column !== "in-review" || task.paused === true) return undefined;
if (context.autoMerge === false) return undefined;
if (task.mergeDetails?.mergeConfirmed === true) return undefined;
if (task.status === "awaiting-user-review" || task.status === "awaiting-approval") return undefined;
if (task.status && ACTIVE_MERGE_STATUSES.has(task.status)) return undefined;
if (context.activeMergeTaskId === task.id || context.executingTaskIds?.has(task.id)) return undefined;
const thresholdMs = context.thresholdMs ?? DEFAULT_IN_REVIEW_STALLED_THRESHOLD_MS;
if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) return undefined;
const now = context.now ?? Date.now();
if (hasRecentReasonDrivenStall(task.log ?? [], now - thresholdMs)) return undefined;
const lastActivity = getLastActivity(task);
if (!lastActivity) return undefined;
const quietMs = Math.max(0, now - lastActivity.time);
if (quietMs < thresholdMs) return undefined;
const ageAnchor = Date.parse(task.columnMovedAt ?? task.updatedAt);
if (!Number.isFinite(ageAnchor)) return undefined;
const ageMs = Math.max(0, now - ageAnchor);
const thresholdHours = thresholdMs / 3_600_000;
const quietHours = quietMs / 3_600_000;
return {
code: "in-review-stalled",
reason: `In-review task quiet for ${quietHours.toFixed(1)}h beyond ${thresholdHours.toFixed(1)}h threshold`,
observedAt: new Date(now).toISOString(),
ageMs,
quietMs,
thresholdMs,
lastActivityAt: new Date(lastActivity.time).toISOString(),
lastActivitySource: lastActivity.source,
};
}
function hasRecentReasonDrivenStall(log: readonly Pick<Task["log"][number], "action" | "timestamp">[], floor: number): boolean {
let latestTime = Number.NEGATIVE_INFINITY;
for (const entry of log) {
if (!entry.action.startsWith(IN_REVIEW_STALL_LOG_PREFIX)) continue;
const entryTime = Date.parse(entry.timestamp);
if (!Number.isFinite(entryTime)) continue;
if (entryTime > latestTime) latestTime = entryTime;
}
return Number.isFinite(latestTime) && latestTime >= floor;
}
function getLastActivity(task: InReviewStalledTask): ActivityCandidate | undefined {
const candidates: ActivityCandidate[] = [];
const logTime = getLatestLogTimestamp(task.log ?? []);
if (Number.isFinite(logTime)) {
candidates.push({ time: logTime, source: "log", tiePriority: 0 });
}
const columnMovedTime = Date.parse(task.columnMovedAt ?? "");
if (Number.isFinite(columnMovedTime)) {
candidates.push({ time: columnMovedTime, source: "column-moved", tiePriority: 1 });
}
const updatedAtTime = Date.parse(task.updatedAt);
if (Number.isFinite(updatedAtTime)) {
candidates.push({ time: updatedAtTime, source: "updated", tiePriority: 2 });
}
if (candidates.length === 0) return undefined;
candidates.sort((a, b) => {
if (a.time !== b.time) return b.time - a.time;
return a.tiePriority - b.tiePriority;
});
return candidates[0];
}
function getLatestLogTimestamp(log: readonly Pick<Task["log"][number], "timestamp">[]): number {
let latest = Number.NEGATIVE_INFINITY;
for (const entry of log) {
const entryTime = Date.parse(entry.timestamp);
if (Number.isFinite(entryTime) && entryTime > latest) {
latest = entryTime;
}
}
return latest;
}