FN-6873: exclude archived tasks from auto-claim scans
Auto-claim now revalidates cached candidates so archived tasks cannot be surfaced or claimed. - Restrict auto-claim candidate runnability to live todo rows while still allowing archived dependencies to satisfy blockers. - Rebuild fresh auto-claim candidates from canonical task fields before heartbeat prompt rendering and claim selection. - Cover archived-while-cached snapshots and executor/engineer heartbeat behavior with regression tests. Files changed: .../src/__tests__/auto-claim-snapshot.test.ts | 52 ++++++++++++++++-- .../src/__tests__/heartbeat-executor.test.ts | 63 ++++++++++++++++++++++ packages/engine/src/auto-claim-snapshot.ts | 6 +++ 3 files changed, 118 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-6873 Fusion-Task-Lineage: 3bd438f8-bbf8-428e-a7e2-56aebbbc6522
This commit is contained in:
@@ -26,9 +26,10 @@ function makeTask(overrides: Partial<Task> & Pick<Task, "id">): Task {
|
||||
|
||||
describe("AutoClaimSnapshotManager", () => {
|
||||
it("uses the shared predicate for unchanged runnability filter cases", () => {
|
||||
const runnable = makeTask({ id: "FN-1", dependencies: ["FN-done", "FN-archived"] });
|
||||
const firstRunnable = makeTask({ id: "FN-1", dependencies: ["FN-done", "FN-archived"] });
|
||||
const secondRunnable = makeTask({ id: "FN-2" });
|
||||
const tasks = [
|
||||
runnable,
|
||||
firstRunnable,
|
||||
makeTask({ id: "FN-paused", paused: true }),
|
||||
makeTask({ id: "FN-assigned", assignedAgentId: "agent-1" }),
|
||||
makeTask({ id: "FN-checked", checkedOutBy: "agent-2" }),
|
||||
@@ -38,10 +39,12 @@ describe("AutoClaimSnapshotManager", () => {
|
||||
makeTask({ id: "FN-done", column: "done" }),
|
||||
makeTask({ id: "FN-archived", column: "archived" }),
|
||||
makeTask({ id: "FN-open", column: "in-progress" }),
|
||||
makeTask({ id: "FN-review", column: "in-review" }),
|
||||
secondRunnable,
|
||||
];
|
||||
const tasksById = new Map(tasks.map((task) => [task.id, task]));
|
||||
|
||||
expect(tasks.filter((task) => isRunnableAutoClaimCandidate(task, tasksById)).map((task) => task.id)).toEqual(["FN-1"]);
|
||||
expect(tasks.filter((task) => isRunnableAutoClaimCandidate(task, tasksById)).map((task) => task.id)).toEqual(["FN-1", "FN-2"]);
|
||||
});
|
||||
|
||||
it("shares one listTasks call across concurrent getSnapshot calls", async () => {
|
||||
@@ -137,6 +140,49 @@ describe("AutoClaimSnapshotManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drops archived-while-cached candidates but keeps runnable siblings with canonical fields", async () => {
|
||||
const initialTasks = [
|
||||
makeTask({ id: "FN-6872", title: "Re-ratchet line-count baseline", description: "archived later", createdAt: "2026-01-01T00:00:00.000Z" }),
|
||||
makeTask({ id: "FN-TODO", title: "Old sibling title", description: "old sibling desc", createdAt: "2026-01-02T00:00:00.000Z" }),
|
||||
];
|
||||
const canonicalTasks = [
|
||||
makeTask({ id: "FN-6872", title: "Re-ratchet line-count baseline", description: "now archived", column: "archived", createdAt: "2026-01-01T00:00:00.000Z" }),
|
||||
makeTask({ id: "FN-TODO", title: "Canonical sibling title", description: "canonical first line\nsecond", createdAt: "2026-01-02T00:00:00.000Z" }),
|
||||
];
|
||||
const listTasks = vi.fn()
|
||||
.mockResolvedValueOnce(initialTasks)
|
||||
.mockResolvedValueOnce(canonicalTasks);
|
||||
const manager = new AutoClaimSnapshotManager({ taskStore: { listTasks }, now: () => Date.parse("2026-01-12T00:00:00.000Z") });
|
||||
|
||||
const snapshot = await manager.getSnapshot();
|
||||
expect(snapshot.tasks.map((candidate) => candidate.id)).toEqual(["FN-6872", "FN-TODO"]);
|
||||
|
||||
const resolved = await resolveFreshAutoClaimCandidates({ listTasks }, snapshot.tasks, () => Date.parse("2026-01-12T00:00:00.000Z"));
|
||||
|
||||
expect(resolved.map((candidate) => candidate.id)).toEqual(["FN-TODO"]);
|
||||
expect(resolved[0]).toMatchObject({
|
||||
title: "Canonical sibling title",
|
||||
description: "canonical first line\nsecond",
|
||||
descriptionFirstLine: "canonical first line",
|
||||
column: "todo",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats archived dependencies as satisfied without making archived tasks candidates", async () => {
|
||||
const dependent = makeTask({ id: "FN-dependent", dependencies: ["FN-archived-dependency"] });
|
||||
const archivedDependency = makeTask({ id: "FN-archived-dependency", column: "archived" });
|
||||
const tasks = [dependent, archivedDependency];
|
||||
const tasksById = new Map(tasks.map((task) => [task.id, task]));
|
||||
|
||||
expect(isRunnableAutoClaimCandidate(dependent, tasksById)).toBe(true);
|
||||
expect(isRunnableAutoClaimCandidate(archivedDependency, tasksById)).toBe(false);
|
||||
|
||||
const manager = new AutoClaimSnapshotManager({ taskStore: { listTasks: vi.fn(async () => tasks) } });
|
||||
const snapshot = await manager.getSnapshot();
|
||||
|
||||
expect(snapshot.tasks.map((candidate) => candidate.id)).toEqual(["FN-dependent"]);
|
||||
});
|
||||
|
||||
it("sorts by columnMovedAt then createdAt ascending", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "FN-3", createdAt: "2026-01-03T00:00:00.000Z" }),
|
||||
|
||||
@@ -1277,6 +1277,69 @@ describe("executeHeartbeat", () => {
|
||||
expect(executionPrompt).toContain("- FN-RENAMED: Updated canonical backlog title");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "executor display", role: "executor" as const, soul: "Re-ratchet line-count baseline specialist", runtimeConfig: undefined, expectedStatus: "auto-claim relevant tasks: enabled" },
|
||||
{ name: "engineer role fallback", role: "engineer" as const, soul: "Re-ratchet line-count baseline specialist", runtimeConfig: { engineerBacklogAutoClaim: false }, expectedStatus: "auto-claim relevant tasks: enabled (compatible backlog blocked; engineerBacklogAutoClaim disabled)" },
|
||||
])("drops archived-while-cached candidates from heartbeat prompt and claim path for $name", async (scenario) => {
|
||||
const archivedCachedTask = makeAutoClaimTask({
|
||||
id: "FN-6872",
|
||||
title: "Re-ratchet line-count baseline archived cached title",
|
||||
description: "Re-ratchet line-count baseline work that matched this agent before archive",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const siblingCachedTask = makeAutoClaimTask({
|
||||
id: "FN-TODO",
|
||||
title: "Old neutral queue title",
|
||||
description: "neutral queue work",
|
||||
createdAt: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
const archivedCanonicalTask = makeAutoClaimTask({
|
||||
id: "FN-6872",
|
||||
title: "Re-ratchet line-count baseline archived canonical title",
|
||||
description: "archived within the snapshot TTL",
|
||||
column: "archived",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const siblingCanonicalTask = makeAutoClaimTask({
|
||||
id: "FN-TODO",
|
||||
title: "Canonical neutral queue title",
|
||||
description: "canonical neutral work",
|
||||
createdAt: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
const listTasks = vi.fn()
|
||||
.mockResolvedValueOnce([archivedCachedTask, siblingCachedTask])
|
||||
.mockResolvedValue([archivedCanonicalTask, siblingCanonicalTask]);
|
||||
const store = createStoreWithAgentForExec({
|
||||
taskId: undefined,
|
||||
role: scenario.role,
|
||||
soul: scenario.soul,
|
||||
runtimeConfig: scenario.runtimeConfig,
|
||||
});
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||
mockTaskStore = createMockTaskStore({
|
||||
listTasks,
|
||||
getTask: vi.fn().mockResolvedValue(siblingCanonicalTask),
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(store.claimTaskForAgent).not.toHaveBeenCalledWith("agent-001", "FN-6872", expect.anything());
|
||||
if (scenario.role === "executor") {
|
||||
expect(store.claimTaskForAgent).toHaveBeenCalledWith("agent-001", "FN-TODO", expect.anything());
|
||||
} else {
|
||||
expect(store.claimTaskForAgent).not.toHaveBeenCalled();
|
||||
}
|
||||
const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] as string;
|
||||
expect(executionPrompt).toContain(scenario.expectedStatus);
|
||||
expect(executionPrompt).toContain("Open Task Candidates (auto-claim scan):");
|
||||
expect(executionPrompt).not.toContain("FN-6872");
|
||||
expect(executionPrompt).not.toContain("Re-ratchet line-count baseline archived cached title");
|
||||
expect(executionPrompt).not.toContain("Re-ratchet line-count baseline archived canonical title");
|
||||
expect(executionPrompt).toContain("- FN-TODO: Canonical neutral queue title");
|
||||
});
|
||||
|
||||
it("reuses one snapshot rebuild across concurrent no-task heartbeats", async () => {
|
||||
const listTasks = vi.fn().mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -33,6 +33,9 @@ const autoClaimSnapshotLog = createLogger("auto-claim-snapshot");
|
||||
/*
|
||||
FNXC:AutoClaim 2026-06-21-10:35:
|
||||
Auto-claim runnability must have one source of truth so the snapshot rebuild and canonical freshness gate exclude the same stale, assigned, checked-out, deleted, paused, and dependency-blocked tasks.
|
||||
|
||||
FNXC:AutoClaim 2026-06-21-16:09:
|
||||
FN-6873 pins `column === "todo"` as the candidate gate after FN-6872 appeared in a heartbeat prompt while archived from a stale cache. Archived, done, triage, in-progress, in-review, soft-deleted, paused, assigned, checked-out, and dependency-blocked rows can satisfy dependencies where allowed, but must never be surfaced or claimed as auto-claim candidates.
|
||||
*/
|
||||
export function isRunnableAutoClaimCandidate(task: Task, tasksById: ReadonlyMap<string, Task>): boolean {
|
||||
return task.column === "todo"
|
||||
@@ -68,6 +71,9 @@ export function toAutoClaimCandidate(task: Task, now: number): AutoClaimCandidat
|
||||
FNXC:AutoClaim 2026-06-21-10:35:
|
||||
FN-6850 requires a canonical re-resolution gate before cached candidates are displayed or claimed, because FN-6812 showed a superseded triage task could remain in the 30s cache with an old runnable title.
|
||||
Use one fresh slim task list for the bounded candidate subset and rebuild survivors from current rows instead of fanning out per-candidate getTask calls.
|
||||
|
||||
FNXC:AutoClaim 2026-06-21-16:09:
|
||||
The fresh slim list intentionally includes archived rows by default so the shared predicate, not storage filtering, proves archived-while-cached rows are dropped before heartbeat prompt rendering or winner selection.
|
||||
*/
|
||||
export async function resolveFreshAutoClaimCandidates(
|
||||
taskStore: Pick<TaskStore, "listTasks">,
|
||||
|
||||
Reference in New Issue
Block a user