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:
@@ -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" }),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user