feat(FN-4465): complete Step 2 — hydrate task age staleness

Fusion-Task-Id: FN-4465
Fusion-Task-Lineage: 4365d4cb-ab93-4ed5-add9-3e14b26237ad
This commit is contained in:
Fusion
2026-05-14 05:25:38 -07:00
committed by gsxdsm
parent 6b637eaf69
commit f6951e7f4a
2 changed files with 151 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "../store.js";
describe("TaskStore ageStaleness hydration", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = await mkdtemp(join(tmpdir(), "store-task-age-staleness-"));
globalDir = join(rootDir, ".fusion-global-settings");
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
await store.close();
await rm(rootDir, { recursive: true, force: true });
});
async function seedTask(
id: string,
overrides: { column: "in-progress" | "in-review" | "todo"; paused?: boolean; ageMs: number; mergeConfirmed?: boolean },
) {
const now = Date.now();
const movedAt = new Date(now - overrides.ageMs).toISOString();
await store.createTaskWithReservedId(
{ description: id, column: overrides.column },
{ taskId: id, createdAt: movedAt, updatedAt: movedAt, applyDefaultWorkflowSteps: false },
);
const db = (store as unknown as { db: { prepare: (sql: string) => { run: (...params: unknown[]) => unknown } } }).db;
db.prepare(`UPDATE tasks
SET paused = ?, mergeDetails = ?, columnMovedAt = ?, updatedAt = ?
WHERE id = ?`).run(
overrides.paused ? 1 : 0,
JSON.stringify(overrides.mergeConfirmed ? { mergeConfirmed: true } : {}),
movedAt,
movedAt,
id,
);
}
it("hydrates warning for stale in-progress", async () => {
await seedTask("FN-STALE-WARN", { column: "in-progress", ageMs: 4 * 60 * 60_000 + 1_000 });
const task = (await store.listTasks({ slim: true })).find((entry) => entry.id === "FN-STALE-WARN");
expect(task?.ageStaleness?.level).toBe("warning");
});
it("hydrates critical when over critical threshold", async () => {
await seedTask("FN-STALE-CRIT", { column: "in-progress", ageMs: 24 * 60 * 60_000 + 1_000 });
const task = (await store.listTasks({ slim: true })).find((entry) => entry.id === "FN-STALE-CRIT");
expect(task?.ageStaleness?.level).toBe("critical");
});
it("hydrates for paused in-review tasks", async () => {
await seedTask("FN-STALE-PAUSED", { column: "in-review", paused: true, ageMs: 24 * 60 * 60_000 + 1_000 });
const task = (await store.listTasks({ slim: true })).find((entry) => entry.id === "FN-STALE-PAUSED");
expect(task?.ageStaleness?.level).toBe("warning");
expect(task?.ageStaleness?.paused).toBe(true);
});
it("omits signal for todo", async () => {
await seedTask("FN-STALE-TODO", { column: "todo", ageMs: 7 * 24 * 60 * 60_000 });
const task = (await store.listTasks({ slim: true })).find((entry) => entry.id === "FN-STALE-TODO");
expect(task?.ageStaleness).toBeUndefined();
});
it("respects settings overrides", async () => {
await store.updateSettings({ staleInProgressWarningMs: 1_000, staleInProgressCriticalMs: 2_000 });
await seedTask("FN-STALE-OVERRIDE", { column: "in-progress", ageMs: 2_500 });
const task = (await store.listTasks({ slim: true })).find((entry) => entry.id === "FN-STALE-OVERRIDE");
expect(task?.ageStaleness?.level).toBe("critical");
});
it("omits signal when both levels are disabled", async () => {
await store.updateSettings({ staleInProgressWarningMs: 0, staleInProgressCriticalMs: 0 });
await seedTask("FN-STALE-DISABLED", { column: "in-progress", ageMs: 48 * 60 * 60_000 });
const task = (await store.listTasks({ slim: true })).find((entry) => entry.id === "FN-STALE-DISABLED");
expect(task?.ageStaleness).toBeUndefined();
});
});

View File

@@ -24,6 +24,7 @@ import { BackwardCompat, ProjectRequiredError } from "./migration.js";
import { CentralCore } from "./central-core.js";
import { getTaskMergeBlocker, resolveTaskMergeTarget } from "./task-merge.js";
import { getInReviewStallReason } from "./in-review-stall.js";
import { getTaskAgeStalenessSignal, type TaskAgeStalenessThresholds } from "./task-age-staleness.js";
import { ensureMemoryFileWithBackend } from "./project-memory.js";
import { runCommandAsync } from "./run-command.js";
import { createLogger } from "./logger.js";
@@ -3230,9 +3231,31 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const rows = this.db.prepare(sql).all(...params);
const now = Date.now();
const settings = await this.getSettingsFast();
const staleThresholds: TaskAgeStalenessThresholds = {
inProgressWarningMs: settings.staleInProgressWarningMs,
inProgressCriticalMs: settings.staleInProgressCriticalMs,
inReviewWarningMs: settings.staleInReviewWarningMs,
inReviewCriticalMs: settings.staleInReviewCriticalMs,
};
let disableAgeStalenessHydration = false;
const activeTasks = await Promise.all((rows as unknown as TaskRow[]).map(async (row) => {
const task = this.rowToTask(row);
task.inReviewStall = getInReviewStallReason(task, { now });
if (!disableAgeStalenessHydration) {
try {
task.ageStaleness = getTaskAgeStalenessSignal(task, { now, thresholds: staleThresholds });
} catch (error) {
if (error instanceof RangeError) {
disableAgeStalenessHydration = true;
storeLog.warn("Invalid stale task thresholds; skipping age staleness hydration for this listTasks pass", {
error: error.message,
});
} else {
throw error;
}
}
}
task.stalledReview = detectStalledReview(task, { now });
// Slim path: aggregate the timed-execution total server-side, then
@@ -3315,9 +3338,31 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const hasMore = rows.length > resolvedLimit;
const now = Date.now();
const settings = await this.getSettingsFast();
const staleThresholds: TaskAgeStalenessThresholds = {
inProgressWarningMs: settings.staleInProgressWarningMs,
inProgressCriticalMs: settings.staleInProgressCriticalMs,
inReviewWarningMs: settings.staleInReviewWarningMs,
inReviewCriticalMs: settings.staleInReviewCriticalMs,
};
let disableAgeStalenessHydration = false;
const tasks = rows.slice(0, resolvedLimit).map((row) => {
const task = this.rowToTask(row);
task.inReviewStall = getInReviewStallReason(task, { now });
if (!disableAgeStalenessHydration) {
try {
task.ageStaleness = getTaskAgeStalenessSignal(task, { now, thresholds: staleThresholds });
} catch (error) {
if (error instanceof RangeError) {
disableAgeStalenessHydration = true;
storeLog.warn("Invalid stale task thresholds; skipping age staleness hydration for this modified-since pass", {
error: error.message,
});
} else {
throw error;
}
}
}
task.timedExecutionMs = this.computeTimedExecutionMs(task.log);
task.stalledReview = detectStalledReview(task, { now });
task.log = [];
@@ -3425,9 +3470,31 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
const now = Date.now();
const settings = await this.getSettingsFast();
const staleThresholds: TaskAgeStalenessThresholds = {
inProgressWarningMs: settings.staleInProgressWarningMs,
inProgressCriticalMs: settings.staleInProgressCriticalMs,
inReviewWarningMs: settings.staleInReviewWarningMs,
inReviewCriticalMs: settings.staleInReviewCriticalMs,
};
let disableAgeStalenessHydration = false;
const activeMatches = await Promise.all(rows.map(async (row) => {
const task = this.rowToTask(row);
task.inReviewStall = getInReviewStallReason(task, { now });
if (!disableAgeStalenessHydration) {
try {
task.ageStaleness = getTaskAgeStalenessSignal(task, { now, thresholds: staleThresholds });
} catch (error) {
if (error instanceof RangeError) {
disableAgeStalenessHydration = true;
storeLog.warn("Invalid stale task thresholds; skipping age staleness hydration for this searchTasks pass", {
error: error.message,
});
} else {
throw error;
}
}
}
// Slim path mirrors `listTasks`: aggregate timed execution server-side
// before stripping the heavy log payload from the wire response.