feat(FN-4168): complete Step 2 — add stalled review detector

Fusion-Task-Id: FN-4168
Fusion-Task-Lineage: 781ffc66-cb1c-42b9-85e1-e5cbe0655863
This commit is contained in:
Fusion
2026-05-13 01:56:25 -07:00
committed by gsxdsm
parent bf553bb91f
commit 5fc5a07a3f
2 changed files with 206 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
STALLED_REVIEW_INVALID_TRANSITION_PATTERN,
STALLED_REVIEW_INVALID_TRANSITION_THRESHOLD,
STALLED_REVIEW_REENQUEUE_PATTERN,
STALLED_REVIEW_REENQUEUE_THRESHOLD,
STALLED_REVIEW_WINDOW_MS,
detectStalledReview,
} from "../stalled-review-detector.js";
import type { TaskLogEntry } from "../types.js";
function entry(timestamp: string, action: string, outcome?: string): TaskLogEntry {
return { timestamp, action, outcome };
}
describe("detectStalledReview", () => {
const now = Date.parse("2026-05-12T12:00:00.000Z");
it("fires reenqueue-churn at threshold", () => {
const signal = detectStalledReview({
column: "in-review",
paused: false,
log: [
entry("2026-05-12T11:30:00.000Z", STALLED_REVIEW_REENQUEUE_PATTERN),
entry("2026-05-12T11:40:00.000Z", `noise ${STALLED_REVIEW_REENQUEUE_PATTERN}`),
entry("2026-05-12T11:50:00.000Z", STALLED_REVIEW_REENQUEUE_PATTERN),
],
}, { now });
expect(signal?.heuristic).toBe("reenqueue-churn");
expect(signal?.matchCount).toBe(STALLED_REVIEW_REENQUEUE_THRESHOLD);
});
it("does not fire reenqueue-churn below threshold", () => {
const signal = detectStalledReview({
column: "in-review",
paused: false,
log: [
entry("2026-05-12T11:30:00.000Z", STALLED_REVIEW_REENQUEUE_PATTERN),
entry("2026-05-12T11:40:00.000Z", STALLED_REVIEW_REENQUEUE_PATTERN),
],
}, { now });
expect(signal).toBeUndefined();
});
it("fires invalid-transition-loop at threshold", () => {
const invalid = "Invalid transition: 'todo' → 'done'";
const signal = detectStalledReview({
column: "in-review",
paused: false,
log: [
entry("2026-05-12T11:30:00.000Z", "merge recovery", invalid),
entry("2026-05-12T11:40:00.000Z", invalid),
],
}, { now });
expect(invalid).toMatch(STALLED_REVIEW_INVALID_TRANSITION_PATTERN);
expect(signal?.heuristic).toBe("invalid-transition-loop");
expect(signal?.matchCount).toBe(STALLED_REVIEW_INVALID_TRANSITION_THRESHOLD);
});
it("does not fire invalid-transition-loop below threshold", () => {
const signal = detectStalledReview({
column: "in-review",
paused: false,
log: [entry("2026-05-12T11:30:00.000Z", "Invalid transition: 'todo' → 'done'")],
}, { now });
expect(signal).toBeUndefined();
});
it("excludes older entries outside window", () => {
const signal = detectStalledReview({
column: "in-review",
paused: false,
log: [
entry("2026-05-12T09:00:00.000Z", STALLED_REVIEW_REENQUEUE_PATTERN),
entry("2026-05-12T11:40:00.000Z", STALLED_REVIEW_REENQUEUE_PATTERN),
entry("2026-05-12T11:50:00.000Z", STALLED_REVIEW_REENQUEUE_PATTERN),
],
}, { now, windowMs: STALLED_REVIEW_WINDOW_MS });
expect(signal).toBeUndefined();
});
it("returns undefined when not in-review", () => {
expect(detectStalledReview({ column: "todo", paused: false, log: [entry("2026-05-12T11:50:00.000Z", STALLED_REVIEW_REENQUEUE_PATTERN)] }, { now })).toBeUndefined();
});
it("returns undefined when paused", () => {
expect(detectStalledReview({ column: "in-review", paused: true, log: [entry("2026-05-12T11:50:00.000Z", STALLED_REVIEW_REENQUEUE_PATTERN)] }, { now })).toBeUndefined();
});
it("returns undefined when log is empty", () => {
expect(detectStalledReview({ column: "in-review", paused: false, log: [] }, { now })).toBeUndefined();
});
it("prioritizes reenqueue-churn when both heuristics match", () => {
const invalid = "Invalid transition: 'todo' → 'done'";
const signal = detectStalledReview({
column: "in-review",
paused: false,
log: [
entry("2026-05-12T11:30:00.000Z", STALLED_REVIEW_REENQUEUE_PATTERN, invalid),
entry("2026-05-12T11:40:00.000Z", STALLED_REVIEW_REENQUEUE_PATTERN, invalid),
entry("2026-05-12T11:50:00.000Z", STALLED_REVIEW_REENQUEUE_PATTERN),
],
}, { now });
expect(signal?.heuristic).toBe("reenqueue-churn");
});
});

View File

@@ -0,0 +1,92 @@
import type { Task } from "./types.js";
/**
* Heuristic-only stalled review detector.
*
* This scans recent task log entries for repeat recovery-loop signatures seen in
* FN-2997/FN-3050 (re-enqueue churn) and FN-3946/FN-3951 (invalid transition
* loop) and returns a non-destructive signal for UI surfacing.
*/
/**
* Threshold for re-enqueue churn: observed incidents required at least 3
* repeated merge re-enqueue messages within a short window before queues backed
* up (FN-2997/FN-3050).
*/
export const STALLED_REVIEW_REENQUEUE_THRESHOLD = 3;
/**
* Threshold for invalid-transition loop errors: repeated recoveries were noisy
* and actionable by the second hit in a one-hour window (FN-3946/FN-3951).
*/
export const STALLED_REVIEW_INVALID_TRANSITION_THRESHOLD = 2;
/**
* Lookback window for the stall heuristics. Tune conservatively: widening this
* increases sensitivity/noise, shrinking it can miss active loops.
*/
export const STALLED_REVIEW_WINDOW_MS = 60 * 60 * 1000;
export const STALLED_REVIEW_REENQUEUE_PATTERN = "Auto-recovered: eligible in-review task re-enqueued for merge";
export const STALLED_REVIEW_INVALID_TRANSITION_PATTERN = /Invalid transition: '[^']+' → '[^']+'/;
export interface StalledReviewSignal {
reason: string;
heuristic: "reenqueue-churn" | "invalid-transition-loop";
matchCount: number;
firstMatchAt: string;
lastMatchAt: string;
}
export function detectStalledReview(
task: Pick<Task, "column" | "paused" | "log">,
options?: { now?: number; windowMs?: number },
): StalledReviewSignal | undefined {
if (task.column !== "in-review" || task.paused === true || task.log.length === 0) {
return undefined;
}
const now = options?.now ?? Date.now();
const windowMs = options?.windowMs ?? STALLED_REVIEW_WINDOW_MS;
const windowStart = now - windowMs;
const windowedEntries = task.log.filter((entry) => {
const ts = Date.parse(entry.timestamp);
return Number.isFinite(ts) && ts >= windowStart && ts <= now;
});
if (windowedEntries.length === 0) {
return undefined;
}
const reenqueueMatches = windowedEntries.filter((entry) => entry.action.includes(STALLED_REVIEW_REENQUEUE_PATTERN));
if (reenqueueMatches.length >= STALLED_REVIEW_REENQUEUE_THRESHOLD) {
const minutes = Math.floor(windowMs / (60 * 1000));
return {
reason: `Re-enqueued for merge ${reenqueueMatches.length} times in the last ${minutes} minutes without leaving in-review`,
heuristic: "reenqueue-churn",
matchCount: reenqueueMatches.length,
firstMatchAt: reenqueueMatches[0]!.timestamp,
lastMatchAt: reenqueueMatches[reenqueueMatches.length - 1]!.timestamp,
};
}
const invalidTransitionMatches = windowedEntries.filter((entry) => {
const action = entry.action ?? "";
const outcome = entry.outcome ?? "";
return STALLED_REVIEW_INVALID_TRANSITION_PATTERN.test(action)
|| STALLED_REVIEW_INVALID_TRANSITION_PATTERN.test(outcome);
});
if (invalidTransitionMatches.length >= STALLED_REVIEW_INVALID_TRANSITION_THRESHOLD) {
const minutes = Math.floor(windowMs / (60 * 1000));
return {
reason: `Repeated invalid-transition recovery errors (${invalidTransitionMatches.length}) in the last ${minutes} minutes`,
heuristic: "invalid-transition-loop",
matchCount: invalidTransitionMatches.length,
firstMatchAt: invalidTransitionMatches[0]!.timestamp,
lastMatchAt: invalidTransitionMatches[invalidTransitionMatches.length - 1]!.timestamp,
};
}
return undefined;
}