feat(FN-4452): complete Step 1 — core stale paused review detector

Fusion-Task-Id: FN-4452
Fusion-Task-Lineage: 7d8b1c13-883b-4815-94ee-fdf18ad24e5f
This commit is contained in:
Fusion
2026-05-14 18:18:36 -07:00
committed by gsxdsm
parent 2cd7f43627
commit 18695c860f
5 changed files with 115 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_STALE_PAUSED_REVIEW_THRESHOLD_MS, getStalePausedReviewSignal } from "../stale-paused-review.js";
const NOW = Date.parse("2026-05-14T12:00:00.000Z");
const baseTask = {
column: "in-review" as const,
paused: true,
columnMovedAt: new Date(NOW - DEFAULT_STALE_PAUSED_REVIEW_THRESHOLD_MS).toISOString(),
updatedAt: new Date(NOW - DEFAULT_STALE_PAUSED_REVIEW_THRESHOLD_MS).toISOString(),
mergeDetails: {},
pausedReason: "manual-hold",
pausedByAgentId: "agent-1",
};
describe("getStalePausedReviewSignal", () => {
it("returns undefined under threshold", () => {
const signal = getStalePausedReviewSignal(
{ ...baseTask, columnMovedAt: new Date(NOW - DEFAULT_STALE_PAUSED_REVIEW_THRESHOLD_MS + 1).toISOString() },
{ now: NOW },
);
expect(signal).toBeUndefined();
});
it("returns signal at threshold with pause metadata", () => {
const signal = getStalePausedReviewSignal({ ...baseTask }, { now: NOW });
expect(signal?.code).toBe("stale-paused-review");
expect(signal?.ageMs).toBe(DEFAULT_STALE_PAUSED_REVIEW_THRESHOLD_MS);
expect(signal?.thresholdMs).toBe(DEFAULT_STALE_PAUSED_REVIEW_THRESHOLD_MS);
expect(signal?.pausedReason).toBe("manual-hold");
});
it("returns undefined for non-paused in-review", () => {
expect(getStalePausedReviewSignal({ ...baseTask, paused: false }, { now: NOW })).toBeUndefined();
});
it("returns undefined for paused task outside in-review", () => {
expect(getStalePausedReviewSignal({ ...baseTask, column: "todo" }, { now: NOW })).toBeUndefined();
});
it("returns undefined for merge-confirmed task", () => {
expect(getStalePausedReviewSignal({ ...baseTask, mergeDetails: { mergeConfirmed: true } }, { now: NOW })).toBeUndefined();
});
it("falls back to updatedAt when columnMovedAt missing", () => {
const signal = getStalePausedReviewSignal({
...baseTask,
columnMovedAt: undefined,
updatedAt: new Date(NOW - DEFAULT_STALE_PAUSED_REVIEW_THRESHOLD_MS - 1_000).toISOString(),
}, { now: NOW });
expect(signal?.ageMs).toBe(DEFAULT_STALE_PAUSED_REVIEW_THRESHOLD_MS + 1_000);
});
});

View File

@@ -162,6 +162,11 @@ export {
DEFAULT_MAX_AUTO_MERGE_RETRIES,
} from "./in-review-stall.js";
export type { InReviewStallSignal, InReviewStallCode } from "./in-review-stall.js";
export {
getStalePausedReviewSignal,
DEFAULT_STALE_PAUSED_REVIEW_THRESHOLD_MS,
} from "./stale-paused-review.js";
export type { StalePausedReviewCode, StalePausedReviewSignal } from "./stale-paused-review.js";
export {
getTaskAgeStalenessSignal,
DEFAULT_TASK_AGE_STALENESS_THRESHOLDS,

View File

@@ -246,6 +246,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
specStalenessEnabled: false,
specStalenessMaxAgeMs: 6 * 60 * 60 * 1000,
taskStuckTimeoutMs: 600_000,
stalePausedReviewThresholdMs: 24 * 60 * 60_000,
// Capacity risk warning default: only warn once todo is meaningfully backlogged.
capacityRiskBannerEnabled: false,
capacityRiskTodoThreshold: 20,

View File

@@ -0,0 +1,48 @@
import type { Task } from "./types.js";
export type StalePausedReviewCode = "stale-paused-review";
export interface StalePausedReviewSignal {
code: StalePausedReviewCode;
reason: string;
observedAt: string;
ageMs: number;
thresholdMs: number;
pausedReason?: string;
pausedByAgentId?: string;
}
export interface StalePausedReviewContext {
now?: number;
thresholdMs?: number;
}
export const DEFAULT_STALE_PAUSED_REVIEW_THRESHOLD_MS = 24 * 60 * 60_000;
export function getStalePausedReviewSignal(
task: Pick<Task, "column" | "paused" | "columnMovedAt" | "updatedAt" | "mergeDetails" | "pausedReason" | "pausedByAgentId">,
context: StalePausedReviewContext = {},
): StalePausedReviewSignal | undefined {
if (task.column !== "in-review" || task.paused !== true) return undefined;
if (task.mergeDetails?.mergeConfirmed === true) return undefined;
const thresholdMs = context.thresholdMs ?? DEFAULT_STALE_PAUSED_REVIEW_THRESHOLD_MS;
if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) return undefined;
const now = context.now ?? Date.now();
const anchor = Date.parse(task.columnMovedAt ?? task.updatedAt);
if (!Number.isFinite(anchor)) return undefined;
const ageMs = now - anchor;
if (ageMs < thresholdMs) return undefined;
return {
code: "stale-paused-review",
reason: "Task has remained paused in review beyond threshold",
observedAt: new Date(now).toISOString(),
ageMs,
thresholdMs,
pausedReason: task.pausedReason,
pausedByAgentId: task.pausedByAgentId,
};
}

View File

@@ -1,4 +1,5 @@
import type { InReviewStallSignal } from "./in-review-stall.js";
import type { StalePausedReviewSignal } from "./stale-paused-review.js";
import type { StalledReviewSignal } from "./stalled-review-detector.js";
import type { TaskAgeStalenessSignal } from "./task-age-staleness.js";
@@ -1393,6 +1394,9 @@ export interface Task {
/** Server-computed task age staleness signal. Undefined when no staleness rule matches.
* Diagnostic-only: must not be used as an auto-completion signal. */
ageStaleness?: TaskAgeStalenessSignal;
/** Server-computed stale paused review diagnostic signal. Undefined when no rule matches.
* Diagnostic-only: must not trigger automatic state mutation. */
stalePausedReview?: StalePausedReviewSignal;
/** Heuristic stalled-review diagnostic signal (legacy compatibility contract). */
stalledReview?: StalledReviewSignal;
/** Durable aggregate token usage totals for the task. Undefined when no usage has been recorded yet. */
@@ -2585,6 +2589,10 @@ export interface ProjectSettings {
* than this duration, the task is considered stuck and will be terminated and retried.
* Default: 600000 (10 minutes). Set to 0 to disable. */
taskStuckTimeoutMs?: number;
/** Threshold in milliseconds for surfacing paused in-review tasks as stale.
* Age is measured from columnMovedAt when present, otherwise updatedAt.
* Default: 86400000 (24 hours). Set to 0 or undefined to disable surfacing. */
stalePausedReviewThresholdMs?: number;
/** Age threshold in milliseconds before a blocker with high todo fan-out is escalated.
* Blocker age is measured from columnMovedAt when available, otherwise updatedAt.
* Only blockers currently in in-progress or in-review are eligible. */