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:
129
packages/core/src/__tests__/task-age-staleness.test.ts
Normal file
129
packages/core/src/__tests__/task-age-staleness.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
107
packages/core/src/task-age-staleness.ts
Normal file
107
packages/core/src/task-age-staleness.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user