diff --git a/packages/core/src/__tests__/stale-paused-review.test.ts b/packages/core/src/__tests__/stale-paused-review.test.ts new file mode 100644 index 000000000..20a4c151b --- /dev/null +++ b/packages/core/src/__tests__/stale-paused-review.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cd0ab582e..0a8abd67f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 70f1fd95d..728856a2a 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -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, diff --git a/packages/core/src/stale-paused-review.ts b/packages/core/src/stale-paused-review.ts new file mode 100644 index 000000000..2c1382773 --- /dev/null +++ b/packages/core/src/stale-paused-review.ts @@ -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, + 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, + }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 90ff8b923..4533f72c1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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. */