feat(FN-4465): complete Step 1 — core detector helper and types

Fusion-Task-Id: FN-4465
Fusion-Task-Lineage: 4365d4cb-ab93-4ed5-add9-3e14b26237ad
This commit is contained in:
Fusion
2026-05-14 05:21:33 -07:00
committed by gsxdsm
parent ecaeaf0ff5
commit 82d6de9bc4
5 changed files with 265 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_TASK_AGE_STALENESS_THRESHOLDS,
getTaskAgeStalenessSignal,
} from "../task-age-staleness.js";
const NOW = Date.parse("2026-05-14T12:00:00.000Z");
const baseTask = {
column: "in-progress" as const,
paused: false,
columnMovedAt: new Date(NOW).toISOString(),
updatedAt: new Date(NOW).toISOString(),
mergeDetails: {},
};
describe("getTaskAgeStalenessSignal", () => {
it("returns undefined when under warning threshold", () => {
const signal = getTaskAgeStalenessSignal(
{ ...baseTask, columnMovedAt: new Date(NOW - 60_000).toISOString() },
{ now: NOW },
);
expect(signal).toBeUndefined();
});
it("returns warning at warning threshold", () => {
const signal = getTaskAgeStalenessSignal(
{ ...baseTask, columnMovedAt: new Date(NOW - DEFAULT_TASK_AGE_STALENESS_THRESHOLDS.inProgressWarningMs).toISOString() },
{ now: NOW },
);
expect(signal?.level).toBe("warning");
});
it("returns warning between warning and critical", () => {
const signal = getTaskAgeStalenessSignal(
{ ...baseTask, columnMovedAt: new Date(NOW - DEFAULT_TASK_AGE_STALENESS_THRESHOLDS.inProgressWarningMs - 1_000).toISOString() },
{ now: NOW },
);
expect(signal?.level).toBe("warning");
});
it("returns critical at/over critical threshold", () => {
const signal = getTaskAgeStalenessSignal(
{ ...baseTask, columnMovedAt: new Date(NOW - DEFAULT_TASK_AGE_STALENESS_THRESHOLDS.inProgressCriticalMs).toISOString() },
{ now: NOW },
);
expect(signal?.level).toBe("critical");
});
it("returns undefined for non-applicable columns", () => {
expect(getTaskAgeStalenessSignal({ ...baseTask, column: "todo" }, { now: NOW })).toBeUndefined();
expect(getTaskAgeStalenessSignal({ ...baseTask, column: "done" }, { now: NOW })).toBeUndefined();
});
it("includes paused=true in payload", () => {
const signal = getTaskAgeStalenessSignal(
{
...baseTask,
column: "in-review",
paused: true,
columnMovedAt: new Date(NOW - DEFAULT_TASK_AGE_STALENESS_THRESHOLDS.inReviewWarningMs).toISOString(),
},
{ now: NOW },
);
expect(signal?.paused).toBe(true);
});
it("suppresses signal when merge is confirmed", () => {
expect(
getTaskAgeStalenessSignal(
{
...baseTask,
columnMovedAt: new Date(NOW - DEFAULT_TASK_AGE_STALENESS_THRESHOLDS.inProgressCriticalMs).toISOString(),
mergeDetails: { mergeConfirmed: true },
},
{ now: NOW },
),
).toBeUndefined();
});
it("falls back to updatedAt when columnMovedAt missing", () => {
const signal = getTaskAgeStalenessSignal(
{
...baseTask,
columnMovedAt: undefined,
updatedAt: new Date(NOW - DEFAULT_TASK_AGE_STALENESS_THRESHOLDS.inProgressWarningMs).toISOString(),
},
{ now: NOW },
);
expect(signal?.level).toBe("warning");
});
it("treats 0/undefined thresholds as disabled levels", () => {
const signal = getTaskAgeStalenessSignal(
{
...baseTask,
columnMovedAt: new Date(NOW - DEFAULT_TASK_AGE_STALENESS_THRESHOLDS.inProgressWarningMs).toISOString(),
},
{
now: NOW,
thresholds: {
inProgressWarningMs: DEFAULT_TASK_AGE_STALENESS_THRESHOLDS.inProgressWarningMs,
inProgressCriticalMs: 0,
},
},
);
expect(signal?.level).toBe("warning");
expect(signal?.criticalThresholdMs).toBe(0);
});
it("throws when critical threshold is below warning", () => {
expect(() =>
getTaskAgeStalenessSignal(
{
...baseTask,
column: "in-review",
columnMovedAt: new Date(NOW - DEFAULT_TASK_AGE_STALENESS_THRESHOLDS.inReviewWarningMs).toISOString(),
},
{
now: NOW,
thresholds: {
inReviewWarningMs: 10_000,
inReviewCriticalMs: 9_000,
},
},
)
).toThrowError(new RangeError("critical threshold must be >= warning threshold"));
});
});

View File

@@ -159,6 +159,15 @@ export {
DEFAULT_MAX_AUTO_MERGE_RETRIES,
} from "./in-review-stall.js";
export type { InReviewStallSignal, InReviewStallCode } from "./in-review-stall.js";
export {
getTaskAgeStalenessSignal,
DEFAULT_TASK_AGE_STALENESS_THRESHOLDS,
} from "./task-age-staleness.js";
export type {
TaskAgeStalenessLevel,
TaskAgeStalenessSignal,
TaskAgeStalenessThresholds,
} from "./task-age-staleness.js";
export {
isGhAvailable,
isGhAuthenticated,

View File

@@ -242,6 +242,10 @@ export const DEFAULT_PROJECT_SETTINGS = {
capacityRiskBannerEnabled: false,
capacityRiskTodoThreshold: 20,
staleHighFanoutBlockerAgeThresholdMs: 2 * 60 * 60 * 1000,
staleInProgressWarningMs: 4 * 60 * 60_000,
staleInProgressCriticalMs: 24 * 60 * 60_000,
staleInReviewWarningMs: 24 * 60 * 60_000,
staleInReviewCriticalMs: 3 * 24 * 60 * 60_000,
aiSessionTtlMs: 7 * 24 * 60 * 60 * 1000,
aiSessionCleanupIntervalMs: 60 * 60 * 1000,
autoUnpauseEnabled: true,

View File

@@ -0,0 +1,107 @@
import type { Task } from "./types.js";
export type TaskAgeStalenessLevel = "warning" | "critical";
export interface TaskAgeStalenessSignal {
level: TaskAgeStalenessLevel;
reason: string;
observedAt: string;
ageMs: number;
warningThresholdMs: number;
criticalThresholdMs: number;
column: "in-progress" | "in-review";
paused: boolean;
}
export interface TaskAgeStalenessThresholds {
inProgressWarningMs?: number;
inProgressCriticalMs?: number;
inReviewWarningMs?: number;
inReviewCriticalMs?: number;
}
export const DEFAULT_TASK_AGE_STALENESS_THRESHOLDS: Required<TaskAgeStalenessThresholds> = {
inProgressWarningMs: 4 * 60 * 60_000,
inProgressCriticalMs: 24 * 60 * 60_000,
inReviewWarningMs: 24 * 60 * 60_000,
inReviewCriticalMs: 3 * 24 * 60 * 60_000,
};
interface TaskAgeStalenessContext {
now?: number;
thresholds?: TaskAgeStalenessThresholds;
}
type TaskAgeStalenessTask = Pick<Task, "column" | "paused" | "columnMovedAt" | "updatedAt" | "mergeDetails">;
function getNormalizedThreshold(value: number | undefined): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return undefined;
}
return value;
}
export function getTaskAgeStalenessSignal(
task: TaskAgeStalenessTask,
context: TaskAgeStalenessContext = {},
): TaskAgeStalenessSignal | undefined {
if (task.column !== "in-progress" && task.column !== "in-review") {
return undefined;
}
if (task.mergeDetails?.mergeConfirmed === true) {
return undefined;
}
const now = context.now ?? Date.now();
const observedAt = new Date(now).toISOString();
const resolvedThresholds = {
...DEFAULT_TASK_AGE_STALENESS_THRESHOLDS,
...(context.thresholds ?? {}),
};
const warningThresholdMs = getNormalizedThreshold(
task.column === "in-progress" ? resolvedThresholds.inProgressWarningMs : resolvedThresholds.inReviewWarningMs,
);
const criticalThresholdMs = getNormalizedThreshold(
task.column === "in-progress" ? resolvedThresholds.inProgressCriticalMs : resolvedThresholds.inReviewCriticalMs,
);
if (warningThresholdMs === undefined && criticalThresholdMs === undefined) {
return undefined;
}
if (
warningThresholdMs !== undefined
&& criticalThresholdMs !== undefined
&& criticalThresholdMs < warningThresholdMs
) {
throw new RangeError("critical threshold must be >= warning threshold");
}
const ageAnchorMs = Date.parse(task.columnMovedAt ?? task.updatedAt);
if (!Number.isFinite(ageAnchorMs)) {
return undefined;
}
const ageMs = Math.max(0, now - ageAnchorMs);
let level: TaskAgeStalenessLevel | undefined;
if (criticalThresholdMs !== undefined && ageMs >= criticalThresholdMs) {
level = "critical";
} else if (warningThresholdMs !== undefined && ageMs >= warningThresholdMs) {
level = "warning";
}
if (!level) {
return undefined;
}
return {
level,
reason: `Task has been in ${task.column} for ${ageMs}ms`,
observedAt,
ageMs,
warningThresholdMs: warningThresholdMs ?? 0,
criticalThresholdMs: criticalThresholdMs ?? 0,
column: task.column,
paused: task.paused === true,
};
}

View File

@@ -1,5 +1,6 @@
import type { InReviewStallSignal } from "./in-review-stall.js";
import type { StalledReviewSignal } from "./stalled-review-detector.js";
import type { TaskAgeStalenessSignal } from "./task-age-staleness.js";
export {
computeCapacityRisk,
@@ -1260,6 +1261,9 @@ export interface Task {
/** Server-computed in-review stall signal. Undefined when no stall rule matches.
* Diagnostic-only: must not be used as an auto-completion signal. */
inReviewStall?: InReviewStallSignal;
/** 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;
/** 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. */
@@ -2411,6 +2415,18 @@ export interface ProjectSettings {
* Blocker age is measured from columnMovedAt when available, otherwise updatedAt.
* Only blockers currently in in-progress or in-review are eligible. */
staleHighFanoutBlockerAgeThresholdMs?: number;
/** Staleness warning threshold for tasks in in-progress, measured by column age.
* 0 or undefined disables surfacing at this level. */
staleInProgressWarningMs?: number;
/** Staleness critical threshold for tasks in in-progress, measured by column age.
* 0 or undefined disables surfacing at this level. */
staleInProgressCriticalMs?: number;
/** Staleness warning threshold for tasks in in-review, measured by column age.
* 0 or undefined disables surfacing at this level. */
staleInReviewWarningMs?: number;
/** Staleness critical threshold for tasks in in-review, measured by column age.
* 0 or undefined disables surfacing at this level. */
staleInReviewCriticalMs?: number;
/** When true, the dashboard shows the capacity-risk banner once
* capacityRiskTodoThreshold is exceeded with zero idle non-ephemeral agents.
* Default: false. */