feat(FN-3250): add auto-claim setting UI and documentation

Added auto-claim setting UI to the Agent detail view with corresponding documentation in agents.md and test coverage for the new component behavior. The changeset marks this as a minor feature for the published `@runfusion/fusion` package.

Fusion-Task-Id: FN-3250
This commit is contained in:
Fusion
2026-05-05 01:03:28 -07:00
committed by gsxdsm
parent dc5bb9254d
commit 995faf28e8
9 changed files with 433 additions and 1 deletions

View File

@@ -71,6 +71,7 @@ describe("executeHeartbeat", () => {
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn().mockResolvedValue({
id: "FN-002",
description: "Created task",
@@ -132,6 +133,10 @@ describe("executeHeartbeat", () => {
mockAgent.taskId = taskId;
return mockAgent;
}),
claimTaskForAgent: vi.fn().mockImplementation(async (_agentId: string, _taskId: string) => ({
ok: false,
reason: "task_not_found",
})),
startHeartbeatRun: vi.fn().mockResolvedValue({
id: "run-001",
agentId: "agent-001",
@@ -423,6 +428,94 @@ describe("executeHeartbeat", () => {
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "no_assignment_identity_run" }));
});
it("auto-claim disabled skips candidate claiming during no-task runs", async () => {
const store = createStoreWithAgentForExec({
taskId: undefined,
soul: "I am a coordinator",
runtimeConfig: { autoClaimRelevantTasks: false },
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
mockTaskStore = createMockTaskStore({
listTasks: vi.fn().mockResolvedValue([
{
id: "FN-CANDIDATE",
description: "executor workflow cleanup",
title: "Executor cleanup",
prompt: "",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail,
]),
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect((store.claimTaskForAgent as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] as string;
expect(executionPrompt).toContain("auto-claim relevant tasks: disabled");
});
it("auto-claim enabled attempts to claim relevant no-task candidates", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "executor reliability owner" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
mockTaskStore = createMockTaskStore({
listTasks: vi.fn().mockResolvedValue([
{
id: "FN-CANDIDATE",
description: "executor reliability follow-up",
title: "Executor reliability",
prompt: "",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail,
]),
getTask: vi.fn().mockImplementation(async (id: string) => ({
id,
title: "Executor reliability",
description: "executor reliability follow-up",
prompt: "# PROMPT",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail)),
});
(store.claimTaskForAgent as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
task: { id: "FN-CANDIDATE" },
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(store.claimTaskForAgent).toHaveBeenCalledWith(
"agent-001",
"FN-CANDIDATE",
expect.objectContaining({ agentId: "agent-001", source: "timer" }),
);
const toolNames = mockedCreateFnAgent.mock.calls[0]![0]!.customTools!.map((tool: any) => tool.name);
expect(toolNames).toContain("fn_task_log");
});
it("agent WITH instructionsText but no task creates session and completes successfully", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, instructionsText: "Monitor task board and create follow-up tasks" });
const mockSession = createMockAgentSession();

View File

@@ -136,6 +136,35 @@ function formatRelativeTime(iso?: string | null): string {
return `${formatDuration(elapsed)} ago`;
}
function isAutoClaimRelevantTasksEnabled(agent: Agent): boolean {
const runtimeConfig = (agent.runtimeConfig ?? {}) as Record<string, unknown>;
return runtimeConfig.autoClaimRelevantTasks !== false;
}
function taskRelevanceScore(agent: Agent, task: TaskDetail): number {
const haystack = `${task.title ?? ""} ${task.description}`.toLowerCase();
let score = 0;
const role = agent.role.toLowerCase();
if (haystack.includes(role)) {
score += 3;
}
const soulWords = (agent.soul ?? "")
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((word) => word.length >= 4)
.slice(0, 8);
for (const word of soulWords) {
if (haystack.includes(word)) {
score += 1;
}
}
return score;
}
/** Compare blocked-state snapshots to decide whether blocked messaging is duplicate noise. */
export function isBlockedStateDuplicate(current: BlockedStateSnapshot, previous: BlockedStateSnapshot): boolean {
return current.blockedBy === previous.blockedBy && current.contextHash === previous.contextHash;
@@ -1157,6 +1186,52 @@ export class HeartbeatMonitor {
engineRunContext.taskId = taskId;
}
let autoClaimCandidates: TaskDetail[] = [];
const autoClaimEnabled = isAutoClaimRelevantTasksEnabled(agent);
if (!taskId && canRunNoTaskHeartbeat && autoClaimEnabled) {
const listTasks = (taskStore as TaskStore & { listTasks?: (options?: { slim?: boolean }) => Promise<TaskDetail[]> }).listTasks;
if (typeof listTasks === "function") {
try {
const allTasks = await listTasks.call(taskStore, { slim: true });
const tasksById = new Map(allTasks.map((candidate) => [candidate.id, candidate]));
const openCandidates = 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, 10);
autoClaimCandidates = openCandidates;
const ranked = openCandidates
.map((candidate) => ({ candidate, score: taskRelevanceScore(agent, candidate as TaskDetail) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score || (a.candidate.columnMovedAt ?? a.candidate.createdAt).localeCompare(b.candidate.columnMovedAt ?? b.candidate.createdAt));
if (ranked.length > 0) {
const claimResult = await this.store.claimTaskForAgent(agentId, ranked[0].candidate.id, runContext);
if (claimResult.ok) {
taskId = ranked[0].candidate.id;
heartbeatLog.log(`Agent ${agentId} auto-claimed relevant task ${taskId}`);
} else {
heartbeatLog.log(`Agent ${agentId} auto-claim skipped (${claimResult.reason})`);
}
}
} catch (autoClaimError) {
heartbeatLog.warn(`Auto-claim scan failed for ${agentId}: ${autoClaimError instanceof Error ? autoClaimError.message : String(autoClaimError)}`);
}
}
}
if (!taskId) {
// Agents with identity (soul, instructions, memory) should run a full heartbeat
// session even without a task, so they can do ambient work like messaging,
@@ -1545,6 +1620,14 @@ export class HeartbeatMonitor {
);
}
const candidateLines = autoClaimCandidates.length > 0
? [
"",
"Open Task Candidates (auto-claim scan):",
...autoClaimCandidates.slice(0, 10).map((candidate) => `- ${candidate.id}: ${candidate.title ?? candidate.description.slice(0, 80)}`),
]
: ["", "Open Task Candidates (auto-claim scan): none found"];
executionPrompt = [
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
@@ -1556,6 +1639,7 @@ export class HeartbeatMonitor {
`- wake reason: ${wakeReason}`,
`- assigned task: none`,
`- pending messages: ${pendingMessages.length}`,
`- auto-claim relevant tasks: ${autoClaimEnabled ? "enabled" : "disabled"}`,
"",
"Treat this wake delta as the highest-priority change for this heartbeat.",
"This is an autonomous heartbeat run (manual or automatic): re-anchor on",
@@ -1584,6 +1668,10 @@ export class HeartbeatMonitor {
"",
"5. **Monitor project flow** — Review board/project signals and surface issues",
" by creating or delegating follow-up work as appropriate.",
"",
"When auto-claim relevant tasks is enabled, review Open Task Candidates above and",
"prioritize tasks that align with your role and soul before creating net-new tasks.",
...candidateLines,
...pendingMessagesLines,
"",
"Your soul, instructions, and memory are already loaded in the system prompt.",