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:
114
packages/core/src/__tests__/stalled-review-detector.test.ts
Normal file
114
packages/core/src/__tests__/stalled-review-detector.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
92
packages/core/src/stalled-review-detector.ts
Normal file
92
packages/core/src/stalled-review-detector.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user