feat(FN-5034): complete Step 2 — add stale paused todo detector

Fusion-Task-Id: FN-5034
Fusion-Task-Lineage: 1e315656-30d8-41d2-9802-0d9e155f0ba8
This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 15:07:53 -07:00
committed by gsxdsm
parent e53cf7e0b1
commit 8e1ee3efa0
2 changed files with 96 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_STALE_PAUSED_TODO_THRESHOLD_MS, getStalePausedTodoSignal } from "../stale-paused-todo.js";
const NOW = Date.parse("2026-05-14T12:00:00.000Z");
const baseTask = {
column: "todo" as const,
paused: true,
columnMovedAt: new Date(NOW - DEFAULT_STALE_PAUSED_TODO_THRESHOLD_MS).toISOString(),
updatedAt: new Date(NOW - DEFAULT_STALE_PAUSED_TODO_THRESHOLD_MS).toISOString(),
pausedReason: "manual-hold",
pausedByAgentId: "agent-1",
};
describe("getStalePausedTodoSignal", () => {
it("returns signal for paused todo older than threshold", () => {
const signal = getStalePausedTodoSignal({ ...baseTask }, { now: NOW });
expect(signal?.code).toBe("stale-paused-todo");
expect(signal?.ageMs).toBe(DEFAULT_STALE_PAUSED_TODO_THRESHOLD_MS);
expect(signal?.thresholdMs).toBe(DEFAULT_STALE_PAUSED_TODO_THRESHOLD_MS);
});
it("returns undefined when not paused", () => {
expect(getStalePausedTodoSignal({ ...baseTask, paused: false }, { now: NOW })).toBeUndefined();
});
it("returns undefined for non-todo columns", () => {
expect(getStalePausedTodoSignal({ ...baseTask, column: "in-progress" }, { now: NOW })).toBeUndefined();
expect(getStalePausedTodoSignal({ ...baseTask, column: "in-review" }, { now: NOW })).toBeUndefined();
});
it("returns undefined when age is under threshold", () => {
const signal = getStalePausedTodoSignal(
{ ...baseTask, columnMovedAt: new Date(NOW - DEFAULT_STALE_PAUSED_TODO_THRESHOLD_MS + 1).toISOString() },
{ now: NOW },
);
expect(signal).toBeUndefined();
});
it("respects custom threshold override", () => {
const signal = getStalePausedTodoSignal({ ...baseTask }, { now: NOW, thresholdMs: DEFAULT_STALE_PAUSED_TODO_THRESHOLD_MS + 1_000 });
expect(signal).toBeUndefined();
});
it("returns undefined when threshold is zero or negative", () => {
expect(getStalePausedTodoSignal({ ...baseTask }, { now: NOW, thresholdMs: 0 })).toBeUndefined();
expect(getStalePausedTodoSignal({ ...baseTask }, { now: NOW, thresholdMs: -1 })).toBeUndefined();
});
});

View File

@@ -0,0 +1,47 @@
import type { Task } from "./types.js";
export type StalePausedTodoCode = "stale-paused-todo";
export interface StalePausedTodoSignal {
code: StalePausedTodoCode;
reason: string;
observedAt: string;
ageMs: number;
thresholdMs: number;
pausedReason?: string;
pausedByAgentId?: string;
}
export interface StalePausedTodoContext {
now?: number;
thresholdMs?: number;
}
export const DEFAULT_STALE_PAUSED_TODO_THRESHOLD_MS = 24 * 60 * 60_000;
export function getStalePausedTodoSignal(
task: Pick<Task, "column" | "paused" | "columnMovedAt" | "updatedAt" | "pausedReason" | "pausedByAgentId">,
context: StalePausedTodoContext = {},
): StalePausedTodoSignal | undefined {
if (task.column !== "todo" || task.paused !== true) return undefined;
const thresholdMs = context.thresholdMs ?? DEFAULT_STALE_PAUSED_TODO_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-todo",
reason: "Task has remained paused in todo beyond threshold",
observedAt: new Date(now).toISOString(),
ageMs,
thresholdMs,
pausedReason: task.pausedReason,
pausedByAgentId: task.pausedByAgentId,
};
}