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:
53
packages/core/src/__tests__/stale-paused-review.test.ts
Normal file
53
packages/core/src/__tests__/stale-paused-review.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
48
packages/core/src/stale-paused-review.ts
Normal file
48
packages/core/src/stale-paused-review.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user