feat(FN-4401): complete Step 1 — add auto-claim snapshot manager

Fusion-Task-Id: FN-4401
Fusion-Task-Lineage: c1b6c497-b22c-48d5-b1c8-299877bf09ac
This commit is contained in:
Fusion
2026-05-14 00:07:08 -07:00
committed by gsxdsm
parent 6edb5ed8ea
commit 90ed849e07
3 changed files with 230 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail } from "@fusion/core";
import { AutoClaimSnapshotManager, extractDescriptionFirstLine } from "../auto-claim-snapshot.js";
function makeTask(overrides: Partial<TaskDetail> & Pick<TaskDetail, "id">): TaskDetail {
return {
id: overrides.id,
title: overrides.title ?? null,
description: overrides.description ?? "desc",
status: overrides.status ?? "open",
column: overrides.column ?? "todo",
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z",
dependencies: overrides.dependencies ?? [],
labels: overrides.labels ?? [],
comments: overrides.comments ?? [],
activityLog: overrides.activityLog ?? [],
metadata: overrides.metadata ?? {},
assignedAgentId: overrides.assignedAgentId,
checkedOutBy: overrides.checkedOutBy,
paused: overrides.paused,
columnMovedAt: overrides.columnMovedAt,
} as TaskDetail;
}
describe("AutoClaimSnapshotManager", () => {
it("shares one listTasks call across concurrent getSnapshot calls", async () => {
const listTasks = vi.fn(async () => [makeTask({ id: "FN-1" })]);
const manager = new AutoClaimSnapshotManager({ taskStore: { listTasks }, now: () => Date.parse("2026-01-03T00:00:00.000Z") });
await Promise.all([manager.getSnapshot(), manager.getSnapshot(), manager.getSnapshot()]);
expect(listTasks).toHaveBeenCalledTimes(1);
});
it("rebuilds after TTL expiry", async () => {
let now = Date.parse("2026-01-03T00:00:00.000Z");
const listTasks = vi.fn(async () => [makeTask({ id: "FN-1" })]);
const manager = new AutoClaimSnapshotManager({ taskStore: { listTasks }, ttlMs: 10, now: () => now });
await manager.getSnapshot();
now += 20;
await manager.getSnapshot();
expect(listTasks).toHaveBeenCalledTimes(2);
});
it("rebuilds after explicit invalidation", async () => {
const listTasks = vi.fn(async () => [makeTask({ id: "FN-1" })]);
const manager = new AutoClaimSnapshotManager({ taskStore: { listTasks } });
await manager.getSnapshot();
manager.invalidate("test");
await manager.getSnapshot();
expect(listTasks).toHaveBeenCalledTimes(2);
});
it("filters paused/assigned/checked-out/blocked tasks", async () => {
const tasks = [
makeTask({ id: "FN-1", dependencies: ["FN-done"] }),
makeTask({ id: "FN-paused", paused: true }),
makeTask({ id: "FN-assigned", assignedAgentId: "agent-1" }),
makeTask({ id: "FN-checked", checkedOutBy: "agent-2" }),
makeTask({ id: "FN-blocked", dependencies: ["FN-open"] }),
makeTask({ id: "FN-done", column: "done" }),
makeTask({ id: "FN-open", column: "in-progress" }),
];
const listTasks = vi.fn(async () => tasks);
const manager = new AutoClaimSnapshotManager({ taskStore: { listTasks } });
const snapshot = await manager.getSnapshot();
expect(snapshot.tasks.map((t) => t.id)).toEqual(["FN-1"]);
});
it("sorts by columnMovedAt then createdAt ascending", async () => {
const tasks = [
makeTask({ id: "FN-3", createdAt: "2026-01-03T00:00:00.000Z" }),
makeTask({ id: "FN-1", createdAt: "2026-01-01T00:00:00.000Z" }),
makeTask({ id: "FN-2", createdAt: "2026-01-02T00:00:00.000Z", columnMovedAt: "2026-01-01T12:00:00.000Z" }),
];
const manager = new AutoClaimSnapshotManager({ taskStore: { listTasks: vi.fn(async () => tasks) } });
const snapshot = await manager.getSnapshot();
expect(snapshot.tasks.map((t) => t.id)).toEqual(["FN-1", "FN-2", "FN-3"]);
});
it("extracts first non-empty description line and caps length", () => {
expect(extractDescriptionFirstLine("\n\nfirst line\nsecond line")).toBe("first line");
expect(extractDescriptionFirstLine(" \n\t\n")).toBe("");
expect(extractDescriptionFirstLine("x".repeat(400))).toHaveLength(160);
});
});

View File

@@ -0,0 +1,133 @@
import type { TaskDetail, TaskStore } from "@fusion/core";
import { createLogger, type Logger } from "./logger.js";
/**
* In-memory only by design (FN-4401): 30s TTL + event invalidation are enough,
* and filesystem persistence would couple this cache to storage/multi-project concerns.
*/
export interface AutoClaimCandidate {
id: string;
title: string | null;
description: string;
descriptionFirstLine: string;
createdAt: string;
columnMovedAt?: string;
baseScore: number;
column: TaskDetail["column"];
}
export interface AutoClaimSnapshot {
generatedAt: number;
tasks: ReadonlyArray<AutoClaimCandidate>;
}
interface AutoClaimSnapshotManagerOptions {
taskStore: Pick<TaskStore, "listTasks">;
ttlMs?: number;
logger?: Logger;
now?: () => number;
}
const autoClaimSnapshotLog = createLogger("auto-claim-snapshot");
export class AutoClaimSnapshotManager {
private readonly taskStore: Pick<TaskStore, "listTasks">;
private readonly ttlMs: number;
private readonly logger: Logger;
private readonly now: () => number;
private cache: AutoClaimSnapshot | null = null;
private staleReason: "ttl" | "invalidate" = "ttl";
private inFlight: Promise<AutoClaimSnapshot> | null = null;
constructor({ taskStore, ttlMs = 30_000, logger = autoClaimSnapshotLog, now = Date.now }: AutoClaimSnapshotManagerOptions) {
this.taskStore = taskStore;
this.ttlMs = ttlMs;
this.logger = logger;
this.now = now;
}
invalidate(reason: string): void {
this.cache = null;
this.staleReason = "invalidate";
this.logger.log(`invalidate reason=${reason}`);
}
async getSnapshot(): Promise<AutoClaimSnapshot> {
const current = this.cache;
if (current && this.now() - current.generatedAt < this.ttlMs) {
return current;
}
if (this.inFlight) {
return this.inFlight;
}
this.inFlight = this.rebuild();
try {
const next = await this.inFlight;
this.cache = next;
return next;
} finally {
this.inFlight = null;
}
}
private async rebuild(): Promise<AutoClaimSnapshot> {
const allTasks = await this.taskStore.listTasks({ slim: true });
const tasksById = new Map(allTasks.map((candidate) => [candidate.id, candidate]));
const now = this.now();
const tasks = allTasks
.filter((candidate) => (
candidate.column === "todo"
&& candidate.paused !== true
&& !candidate.assignedAgentId
&& !candidate.checkedOutBy
&& candidate.dependencies.every((dependencyId) => {
const dependency = tasksById.get(dependencyId);
return dependency?.column === "done" || dependency?.column === "archived";
})
))
.sort((a, b) => {
const aSortAt = a.columnMovedAt ?? a.createdAt;
const bSortAt = b.columnMovedAt ?? b.createdAt;
return aSortAt.localeCompare(bSortAt);
})
.slice(0, 50)
.map((candidate) => this.toCandidate(candidate, now));
const snapshot: AutoClaimSnapshot = {
generatedAt: now,
tasks,
};
this.logger.log(`rebuild generated=${tasks.length} reason=${this.staleReason}`);
this.staleReason = "ttl";
return snapshot;
}
private toCandidate(task: TaskDetail, now: number): AutoClaimCandidate {
const reference = task.columnMovedAt ?? task.createdAt;
const ageMs = Math.max(0, now - Date.parse(reference));
const ageHours = ageMs / (1000 * 60 * 60);
// One base point per day in todo, capped at +5, to keep aged tasks visible even without keyword overlap.
const baseScore = Math.max(0, Math.min(5, Math.floor(ageHours / 24)));
return {
id: task.id,
title: task.title ?? null,
description: task.description,
descriptionFirstLine: extractDescriptionFirstLine(task.description),
createdAt: task.createdAt,
columnMovedAt: task.columnMovedAt,
baseScore,
column: task.column,
};
}
}
export function extractDescriptionFirstLine(description: string): string {
const firstLine = description
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0) ?? "";
return firstLine.slice(0, 160);
}