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:
95
packages/engine/src/__tests__/auto-claim-snapshot.test.ts
Normal file
95
packages/engine/src/__tests__/auto-claim-snapshot.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
133
packages/engine/src/auto-claim-snapshot.ts
Normal file
133
packages/engine/src/auto-claim-snapshot.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user