FN-6850: refresh auto-claim candidates before heartbeat claims

Auto-claim heartbeats now validate cached todo candidates against current task rows before displaying or claiming them.

- Share the auto-claim runnability predicate between snapshot generation and canonical freshness checks.
- Rebuild cached candidates from fresh slim task rows before role filtering, prompt rendering, and claim selection.
- Cover stale triage, assigned, checked-out, deleted, dependency-blocked, and retitled candidate cases in snapshot and heartbeat tests.

Files changed:
 .../src/__tests__/auto-claim-snapshot.test.ts      | 65 +++++++++++++++-
 .../src/__tests__/heartbeat-executor.test.ts       | 85 +++++++++++++++++++-
 packages/engine/src/agent-heartbeat.ts             | 15 ++--
 packages/engine/src/auto-claim-snapshot.ts         | 91 +++++++++++++++-------
 4 files changed, 220 insertions(+), 36 deletions(-)

Fusion-Task-Id: FN-6850

Fusion-Task-Lineage: 5af79319-46f4-4ee7-b6dc-d846f45abaa4
This commit is contained in:
gsxdsm
2026-06-21 12:01:50 -07:00
parent eb9635a406
commit 9620a50756
4 changed files with 220 additions and 36 deletions

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
import { AutoClaimSnapshotManager, extractDescriptionFirstLine } from "../auto-claim-snapshot.js";
import { AutoClaimSnapshotManager, extractDescriptionFirstLine, isRunnableAutoClaimCandidate, resolveFreshAutoClaimCandidates } from "../auto-claim-snapshot.js";
function makeTask(overrides: Partial<Task> & Pick<Task, "id">): Task {
return {
@@ -19,11 +19,31 @@ function makeTask(overrides: Partial<Task> & Pick<Task, "id">): Task {
assignedAgentId: overrides.assignedAgentId,
checkedOutBy: overrides.checkedOutBy,
paused: overrides.paused,
deletedAt: overrides.deletedAt,
columnMovedAt: overrides.columnMovedAt,
} as unknown as 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 tasks = [
runnable,
makeTask({ id: "FN-paused", paused: true }),
makeTask({ id: "FN-assigned", assignedAgentId: "agent-1" }),
makeTask({ id: "FN-checked", checkedOutBy: "agent-2" }),
makeTask({ id: "FN-deleted", deletedAt: "2026-01-02T00:00:00.000Z" } as Partial<Task> & Pick<Task, "id">),
makeTask({ id: "FN-blocked", dependencies: ["FN-open"] }),
makeTask({ id: "FN-triage", column: "triage" }),
makeTask({ id: "FN-done", column: "done" }),
makeTask({ id: "FN-archived", column: "archived" }),
makeTask({ id: "FN-open", column: "in-progress" }),
];
const tasksById = new Map(tasks.map((task) => [task.id, task]));
expect(tasks.filter((task) => isRunnableAutoClaimCandidate(task, tasksById)).map((task) => task.id)).toEqual(["FN-1"]);
});
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") });
@@ -74,6 +94,49 @@ describe("AutoClaimSnapshotManager", () => {
expect(snapshot.tasks.map((t) => t.id)).toEqual(["FN-1"]);
});
it("re-resolves cached candidates against canonical runnable rows", async () => {
const initialTasks = [
makeTask({ id: "FN-stale-triage", title: "Old title", description: "old desc", createdAt: "2026-01-01T00:00:00.000Z" }),
makeTask({ id: "FN-retitled", title: "Old runnable title", description: "old runnable desc", createdAt: "2026-01-02T00:00:00.000Z" }),
makeTask({ id: "FN-paused", createdAt: "2026-01-03T00:00:00.000Z" }),
makeTask({ id: "FN-assigned", createdAt: "2026-01-04T00:00:00.000Z" }),
makeTask({ id: "FN-checked", createdAt: "2026-01-05T00:00:00.000Z" }),
makeTask({ id: "FN-deleted", createdAt: "2026-01-06T00:00:00.000Z" }),
makeTask({ id: "FN-blocked", dependencies: ["FN-dep"], createdAt: "2026-01-07T00:00:00.000Z" }),
makeTask({ id: "FN-missing", createdAt: "2026-01-08T00:00:00.000Z" }),
makeTask({ id: "FN-dep", column: "done" }),
makeTask({ id: "FN-survivor", title: "Survivor", createdAt: "2026-01-09T00:00:00.000Z" }),
];
const canonicalTasks = [
makeTask({ id: "FN-stale-triage", title: "Superseded stale title", column: "triage" }),
makeTask({ id: "FN-retitled", title: "Updated runnable title", description: "updated first line\nsecond", createdAt: "2026-01-02T00:00:00.000Z" }),
makeTask({ id: "FN-paused", paused: true }),
makeTask({ id: "FN-assigned", assignedAgentId: "agent-1" }),
makeTask({ id: "FN-checked", checkedOutBy: "agent-2" }),
makeTask({ id: "FN-deleted", deletedAt: "2026-01-10T00:00:00.000Z" } as Partial<Task> & Pick<Task, "id">),
makeTask({ id: "FN-blocked", dependencies: ["FN-dep"] }),
makeTask({ id: "FN-dep", column: "in-progress" }),
makeTask({ id: "FN-survivor", title: "Survivor", createdAt: "2026-01-09T00: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();
const resolved = await resolveFreshAutoClaimCandidates({ listTasks }, snapshot.tasks, () => Date.parse("2026-01-12T00:00:00.000Z"));
expect(listTasks).toHaveBeenCalledTimes(2);
expect(resolved.map((candidate) => candidate.id)).toEqual(["FN-retitled", "FN-survivor"]);
expect(resolved[0]).toMatchObject({
id: "FN-retitled",
title: "Updated runnable title",
description: "updated first line\nsecond",
descriptionFirstLine: "updated first line",
column: "todo",
});
});
it("sorts by columnMovedAt then createdAt ascending", async () => {
const tasks = [
makeTask({ id: "FN-3", createdAt: "2026-01-03T00:00:00.000Z" }),

View File

@@ -944,6 +944,26 @@ describe("executeHeartbeat", () => {
// sessions even without a task assignment, enabling them to do ambient work like
// messaging, memory management, task creation, and delegation.
describe("identity agents without tasks", () => {
function makeAutoClaimTask(overrides: Partial<TaskDetail> & Pick<TaskDetail, "id">): TaskDetail {
return {
id: overrides.id,
description: overrides.description ?? "executor reliability follow-up",
title: overrides.title ?? "Executor reliability",
prompt: overrides.prompt ?? "",
steps: overrides.steps ?? [],
column: overrides.column ?? "todo",
dependencies: overrides.dependencies ?? [],
log: overrides.log ?? [],
attachments: overrides.attachments ?? [],
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z",
paused: overrides.paused,
assignedAgentId: overrides.assignedAgentId,
checkedOutBy: overrides.checkedOutBy,
deletedAt: overrides.deletedAt,
} as unknown as TaskDetail;
}
it("agent WITH soul but no task creates session and completes successfully", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator agent who monitors project health" });
const mockSession = createMockAgentSession();
@@ -1195,6 +1215,68 @@ describe("executeHeartbeat", () => {
expect(executionPrompt).toContain("Snapshot found 1 eligible Todo task(s), but this agent role cannot auto-claim implementation work.");
});
it.each([
{ name: "executor display", role: "executor" as const, soul: "ambient gardener", runtimeConfig: undefined, expectedStatus: "auto-claim relevant tasks: enabled" },
{ name: "engineer role fallback", role: "engineer" as const, soul: "ambient gardener", runtimeConfig: { engineerBacklogAutoClaim: false }, expectedStatus: "auto-claim relevant tasks: enabled (compatible backlog blocked; engineerBacklogAutoClaim disabled)" },
])("re-resolves stale cached candidates for $name", async (scenario) => {
const promptOnlyCreatedAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
const staleCachedTask = makeAutoClaimTask({
id: "FN-6812",
title: "Executor stale cached title",
description: "executor matching stale task",
createdAt: "2026-01-01T00:00:00.000Z",
});
const renamedCachedTask = makeAutoClaimTask({
id: "FN-RENAMED",
title: "Old queued title",
description: "old queued description",
createdAt: promptOnlyCreatedAt,
});
const staleCanonicalTask = makeAutoClaimTask({
id: "FN-6812",
title: "Superseded pending Shadcn-family sidebar accent gap check",
description: "superseded and back in planning",
column: "triage",
dependencies: ["FN-6830"],
createdAt: "2026-01-01T00:00:00.000Z",
});
const renamedCanonicalTask = makeAutoClaimTask({
id: "FN-RENAMED",
title: "Updated canonical backlog title",
description: "updated queued description",
createdAt: promptOnlyCreatedAt,
});
const listTasks = vi.fn()
.mockResolvedValueOnce([staleCachedTask, renamedCachedTask])
.mockResolvedValue([staleCanonicalTask, renamedCanonicalTask]);
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(renamedCanonicalTask),
});
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-6812", expect.anything());
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-6812");
expect(executionPrompt).not.toContain("Executor stale cached title");
expect(executionPrompt).not.toContain("Superseded pending Shadcn-family sidebar accent gap check");
expect(executionPrompt).not.toContain("Old queued title");
expect(executionPrompt).toContain("- FN-RENAMED: Updated canonical backlog title");
});
it("reuses one snapshot rebuild across concurrent no-task heartbeats", async () => {
const listTasks = vi.fn().mockResolvedValue([
{
@@ -1220,7 +1302,8 @@ describe("executeHeartbeat", () => {
monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" }),
]);
expect(listTasks).toHaveBeenCalledTimes(1);
// FNXC:AutoClaim 2026-06-21-10:35: FN-6850 keeps the snapshot rebuild shared while each no-task heartbeat runs its own canonical freshness gate.
expect(listTasks).toHaveBeenCalledTimes(3);
});
it("omits candidate section when autoClaimCandidatesInPrompt resolves to zero", async () => {

View File

@@ -18,7 +18,7 @@
*/
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, RunMutationContext, Settings, AgentConfigRevision, ReflectionStore, ChatStore, ChatRoom, ChatRoomMessage, AgentMemoryInclusionMode } from "@fusion/core";
import { AutoClaimSnapshotManager, type AutoClaimCandidate } from "./auto-claim-snapshot.js";
import { AutoClaimSnapshotManager, resolveFreshAutoClaimCandidates, type AutoClaimCandidate } from "./auto-claim-snapshot.js";
import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity, resolveEffectiveAgentPermissionPolicy, canAgentTakeImplementationTask, canAgentTakeImplementationTaskForExplicitRouting, resolvePersistAgentThinkingLog, resolveAgentMemoryInclusionMode } from "@fusion/core";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type, type Static } from "@earendil-works/pi-ai";
@@ -2100,10 +2100,15 @@ export class HeartbeatMonitor {
if (!taskId && canRunNoTaskHeartbeat && autoClaimEnabled && this.snapshotManager) {
try {
const snapshot = await this.snapshotManager.getSnapshot();
autoClaimSnapshotCandidateCount = snapshot.tasks.length;
autoClaimPromptCandidates = snapshot.tasks;
const roleCompatibleCandidates = snapshot.tasks.filter((candidate) => canAgentTakeImplementationTask(agent, candidate, { allowEngineer: engineerBacklogAutoClaim }));
const skippedIncompatibleCount = snapshot.tasks.length - roleCompatibleCandidates.length;
/*
FNXC:AutoClaim 2026-06-21-10:35:
FN-6850 requires the heartbeat consumer to re-resolve cached auto-claim candidates against canonical task rows before both ranking and prompt rendering, preventing superseded FN-6812-style triage tasks from being surfaced or claimed within the snapshot TTL.
*/
const freshCandidates = await resolveFreshAutoClaimCandidates(taskStore, snapshot.tasks);
autoClaimSnapshotCandidateCount = freshCandidates.length;
autoClaimPromptCandidates = freshCandidates;
const roleCompatibleCandidates = freshCandidates.filter((candidate) => canAgentTakeImplementationTask(agent, candidate, { allowEngineer: engineerBacklogAutoClaim }));
const skippedIncompatibleCount = freshCandidates.length - roleCompatibleCandidates.length;
autoClaimRoleFilteredCount = skippedIncompatibleCount;
if (skippedIncompatibleCount > 0) {
heartbeatLog.log(

View File

@@ -30,6 +30,66 @@ interface AutoClaimSnapshotManagerOptions {
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.
*/
export function isRunnableAutoClaimCandidate(task: Task, tasksById: ReadonlyMap<string, Task>): boolean {
return task.column === "todo"
&& task.paused !== true
&& !task.assignedAgentId
&& !task.checkedOutBy
&& !task.deletedAt
&& task.dependencies.every((dependencyId) => {
const dependency = tasksById.get(dependencyId);
return dependency?.column === "done" || dependency?.column === "archived";
});
}
export function toAutoClaimCandidate(task: Task, 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,
};
}
/*
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.
*/
export async function resolveFreshAutoClaimCandidates(
taskStore: Pick<TaskStore, "listTasks">,
candidates: ReadonlyArray<AutoClaimCandidate>,
now: () => number = Date.now,
): Promise<AutoClaimCandidate[]> {
if (candidates.length === 0) {
return [];
}
const allTasks = await taskStore.listTasks({ slim: true });
const tasksById = new Map(allTasks.map((task) => [task.id, task]));
const resolvedAt = now();
return candidates.flatMap((candidate) => {
const canonicalTask = tasksById.get(candidate.id);
if (!canonicalTask || !isRunnableAutoClaimCandidate(canonicalTask, tasksById)) {
return [];
}
return [toAutoClaimCandidate(canonicalTask, resolvedAt)];
});
}
export class AutoClaimSnapshotManager {
private readonly taskStore: Pick<TaskStore, "listTasks">;
private readonly ttlMs: number;
@@ -81,24 +141,14 @@ export class AutoClaimSnapshotManager {
const now = this.now();
const tasks = allTasks
.filter((candidate) => (
candidate.column === "todo"
&& candidate.paused !== true
&& !candidate.assignedAgentId
&& !candidate.checkedOutBy
&& !candidate.deletedAt
&& candidate.dependencies.every((dependencyId) => {
const dependency = tasksById.get(dependencyId);
return dependency?.column === "done" || dependency?.column === "archived";
})
))
.filter((candidate) => isRunnableAutoClaimCandidate(candidate, tasksById))
.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));
.map((candidate) => toAutoClaimCandidate(candidate, now));
const snapshot: AutoClaimSnapshot = {
generatedAt: now,
@@ -110,23 +160,6 @@ export class AutoClaimSnapshotManager {
return snapshot;
}
private toCandidate(task: Task, 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 {