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

@@ -208,6 +208,7 @@ describe("AgentStore", () => {
expect(agent.metadata).toEqual({});
expect(agent.runtimeConfig).toMatchObject({
enabled: true,
autoClaimRelevantTasks: true,
});
expect(new Date(agent.createdAt).getTime()).not.toBeNaN();
expect(new Date(agent.updatedAt).getTime()).not.toBeNaN();
@@ -234,6 +235,27 @@ describe("AgentStore", () => {
expect(agent.heartbeatProcedurePath).toBe(`.fusion/agents/${expectedDir}/HEARTBEAT.md`);
});
it("defaults autoClaimRelevantTasks to true when unset", async () => {
const agent = await store.createAgent({
name: "Auto Claim Default",
role: "executor",
});
const runtimeConfig = agent.runtimeConfig as Record<string, unknown>;
expect(runtimeConfig.autoClaimRelevantTasks).toBe(true);
});
it("preserves explicit autoClaimRelevantTasks=false", async () => {
const agent = await store.createAgent({
name: "Auto Claim Disabled",
role: "executor",
runtimeConfig: { autoClaimRelevantTasks: false },
});
const runtimeConfig = agent.runtimeConfig as Record<string, unknown>;
expect(runtimeConfig.autoClaimRelevantTasks).toBe(false);
});
it("preserves custom metadata", async () => {
const agent = await store.createAgent({
name: "With Meta",
@@ -932,6 +954,7 @@ describe("AgentStore", () => {
// whatever the caller supplied.
expect(result.agent.runtimeConfig).toEqual({
enabled: true,
autoClaimRelevantTasks: true,
heartbeatTimeoutMs: 60000,
heartbeatIntervalMs: 3_600_000,
});
@@ -1775,6 +1798,65 @@ describe("AgentStore", () => {
await store.checkoutTask(holderId, taskId);
expect(await store.getCheckedOutBy(taskId)).toBe(holderId);
});
it("claimTaskForAgent claims unowned task and syncs agent task link", async () => {
const result = await store.claimTaskForAgent(holderId, taskId);
expect(result.ok).toBe(true);
if (!result.ok) return;
const claimedTask = await taskStore.getTask(taskId);
const claimedAgent = await store.getAgent(holderId);
expect(claimedTask?.assignedAgentId).toBe(holderId);
expect(claimedTask?.checkedOutBy).toBe(holderId);
expect(claimedAgent?.taskId).toBe(taskId);
});
it("claimTaskForAgent rejects paused task", async () => {
await taskStore.updateTask(taskId, { paused: true });
const result = await store.claimTaskForAgent(holderId, taskId);
expect(result).toMatchObject({ ok: false, reason: "paused" });
const claimedAgent = await store.getAgent(holderId);
expect(claimedAgent?.taskId).toBeUndefined();
});
it("claimTaskForAgent rejects tasks in terminal columns", async () => {
const doneTask = await taskStore.createTask({ description: "done task", column: "done" });
const result = await store.claimTaskForAgent(holderId, doneTask.id);
expect(result).toMatchObject({ ok: false, reason: "terminal" });
const claimedAgent = await store.getAgent(holderId);
expect(claimedAgent?.taskId).toBeUndefined();
});
it("claimTaskForAgent returns task_not_found when task is missing", async () => {
const result = await store.claimTaskForAgent(holderId, "FN-404");
expect(result).toMatchObject({ ok: false, reason: "task_not_found" });
expect("task" in result).toBe(false);
const claimedAgent = await store.getAgent(holderId);
expect(claimedAgent?.taskId).toBeUndefined();
});
it("claimTaskForAgent rejects task already assigned to another agent", async () => {
await taskStore.updateTask(taskId, { assignedAgentId: otherAgentId });
const result = await store.claimTaskForAgent(holderId, taskId);
expect(result).toMatchObject({ ok: false, reason: "assigned_to_other" });
});
it("claimTaskForAgent rejects checkout conflicts", async () => {
await store.checkoutTask(otherAgentId, taskId);
const result = await store.claimTaskForAgent(holderId, taskId);
expect(result).toMatchObject({ ok: false, reason: "checkout_conflict" });
const claimedAgent = await store.getAgent(holderId);
expect(claimedAgent?.taskId).toBeUndefined();
});
});
// ── resetAgent ────────────────────────────────────────────────────

View File

@@ -181,6 +181,9 @@ function resolveCreationRuntimeConfig(
if (typeof rc.enabled !== "boolean") {
rc.enabled = true;
}
if (typeof rc.autoClaimRelevantTasks !== "boolean") {
rc.autoClaimRelevantTasks = true;
}
if (typeof rc.heartbeatIntervalMs !== "number" || !Number.isFinite(rc.heartbeatIntervalMs)) {
rc.heartbeatIntervalMs = DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS;
}
@@ -1226,6 +1229,70 @@ export class AgentStore extends EventEmitter {
});
}
/**
* Claim task ownership for the calling agent with safety guards.
*
* Guards:
* - task must exist and not be paused
* - task must not be in terminal columns (done/archived)
* - task must not already be assigned to another agent
* - task checkout must be unheld or already held by this agent
*
* On success, updates both durable task assignment (assignedAgentId) and the
* agent's active execution linkage (agent.taskId). Task linkage is only updated
* after ownership + checkout checks pass.
*/
async claimTaskForAgent(agentId: string, taskId: string, runContext?: RunMutationContext): Promise<{ ok: true; task: Task } | { ok: false; reason: string; task?: Task }> {
if (!this.taskStore) {
throw new Error("TaskStore not configured for task-claim operations");
}
const agent = await this.getAgent(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
let task: Task | null = null;
try {
task = await this.taskStore.getTask(taskId);
} catch {
task = null;
}
if (!task) {
return { ok: false, reason: "task_not_found" };
}
if (task.paused) {
return { ok: false, reason: "paused", task };
}
if (task.column === "done" || task.column === "archived") {
return { ok: false, reason: "terminal", task };
}
if (task.assignedAgentId && task.assignedAgentId !== agentId) {
return { ok: false, reason: "assigned_to_other", task };
}
if (task.checkedOutBy && task.checkedOutBy !== agentId) {
return { ok: false, reason: "checkout_conflict", task };
}
try {
await this.checkoutTask(agentId, taskId, runContext);
} catch (error) {
if (error instanceof CheckoutConflictError) {
return { ok: false, reason: "checkout_conflict", task };
}
throw error;
}
const claimedTask = await this.taskStore.updateTask(taskId, { assignedAgentId: agentId }, runContext);
await this.syncExecutionTaskLink(agentId, taskId);
return { ok: true, task: claimedTask };
}
/**
* Acquire a checkout lease for a task.
* Throws CheckoutConflictError when another agent already holds the lease.

View File

@@ -3572,6 +3572,8 @@ export type MessageResponseMode = "immediate" | "on-heartbeat";
export interface AgentHeartbeatConfig {
/** Whether heartbeat triggers are enabled for this agent (default: true) */
enabled?: boolean;
/** Whether this agent should auto-claim relevant unowned tasks during no-task heartbeats (default: true when unset). */
autoClaimRelevantTasks?: boolean;
/** Polling interval in ms (default: 30000). Min: 1000 */
heartbeatIntervalMs?: number;
/** Heartbeat timeout in ms (default: 60000). Min: 5000 */

View File

@@ -2613,6 +2613,10 @@ function deriveHeartbeatEnabled(runtimeConfig: AgentDetail["runtimeConfig"] | un
return runtimeConfig?.enabled !== false;
}
function deriveAutoClaimRelevantTasksEnabled(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean {
return runtimeConfig?.autoClaimRelevantTasks !== false;
}
function deriveBudgetValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): Record<string, string> {
const bc = (runtimeConfig ?? {}).budgetConfig as Record<string, unknown> | undefined;
const nextValues: Record<string, string> = {};
@@ -2967,6 +2971,9 @@ function ConfigTab({
const [heartbeatEnabled, setHeartbeatEnabled] = useState<boolean>(
() => deriveHeartbeatEnabled(agent.runtimeConfig),
);
const [autoClaimRelevantTasksEnabled, setAutoClaimRelevantTasksEnabled] = useState<boolean>(
() => deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig),
);
// Budget config state initialised from agent.runtimeConfig.budgetConfig
const [budgetValues, setBudgetValues] = useState<Record<string, string>>(
@@ -3167,6 +3174,7 @@ function ConfigTab({
// Check heartbeat values
const rc = agent.runtimeConfig ?? {};
if (heartbeatEnabled !== deriveHeartbeatEnabled(agent.runtimeConfig)) return true;
if (autoClaimRelevantTasksEnabled !== deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig)) return true;
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "messageResponseMode"] as const) {
const current = heartbeatValues[key]?.trim() ?? "";
let persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
@@ -3240,6 +3248,7 @@ function ConfigTab({
previousAgentRuntimeSyncRef.current = nextSnapshot;
setHeartbeatValues(deriveHeartbeatValues(agent.runtimeConfig));
setHeartbeatEnabled(deriveHeartbeatEnabled(agent.runtimeConfig));
setAutoClaimRelevantTasksEnabled(deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig));
setBudgetValues(deriveBudgetValues(agent.runtimeConfig));
setModelValue(initialModelValue);
setSelectedRuntimeId(initialRuntimeHint);
@@ -3385,6 +3394,7 @@ function ConfigTab({
// Build the runtimeConfig payload — only include non-empty values
const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig };
newRuntimeConfig.enabled = heartbeatEnabled;
newRuntimeConfig.autoClaimRelevantTasks = autoClaimRelevantTasksEnabled;
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns"] as const) {
const raw = heartbeatValues[key]?.trim();
if (!raw) {
@@ -3480,7 +3490,7 @@ function ConfigTab({
runtimeConfig: newRuntimeConfig,
bundleConfig: newBundleConfig,
};
}, [agent.metadata, agent.runtimeConfig, budgetValues, bundleEntryFile, bundleExternalPath, bundleFiles, bundleMode, formValues, heartbeatEnabled, heartbeatValues, iconValue, modelValue, nameValue, reportsToValue, roleValue, runtimeMode, selectedRuntimeId, selectedSkills, titleValue, validationErrors]);
}, [agent.metadata, agent.runtimeConfig, autoClaimRelevantTasksEnabled, budgetValues, bundleEntryFile, bundleExternalPath, bundleFiles, bundleMode, formValues, heartbeatEnabled, heartbeatValues, iconValue, modelValue, nameValue, reportsToValue, roleValue, runtimeMode, selectedRuntimeId, selectedSkills, titleValue, validationErrors]);
const persistSettings = useCallback(async (showValidationToast: boolean, source: "auto" | "manual") => {
const payload = buildSavePayload();
@@ -3765,6 +3775,19 @@ function ConfigTab({
<span className="config-hint">When enabled, this agent receives scheduled heartbeat runs based on its interval.</span>
</div>
<div className="config-field">
<label className="checkbox-label" htmlFor="hb-autoClaimRelevantTasks">
<input
id="hb-autoClaimRelevantTasks"
type="checkbox"
checked={autoClaimRelevantTasksEnabled}
onChange={(e) => setAutoClaimRelevantTasksEnabled(e.target.checked)}
/>
Auto-Claim Relevant Tasks
</label>
<span className="config-hint">When enabled (default), no-task heartbeats scan open unowned work and auto-claim tasks aligned with this agent&apos;s role and soul.</span>
</div>
<div className="config-field">
<label htmlFor="hb-heartbeatIntervalMs">Heartbeat Interval (s)</label>
<input

View File

@@ -2042,6 +2042,29 @@ describe("AgentDetailView", () => {
});
});
it("defaults auto-claim toggle to enabled when runtimeConfig.autoClaimRelevantTasks is missing", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {
heartbeatIntervalMs: 30000,
},
}));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
await waitFor(() => {
expect((screen.getByLabelText("Auto-Claim Relevant Tasks") as HTMLInputElement).checked).toBe(true);
});
});
it("shows Save Settings button disabled when no changes", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));
@@ -2257,6 +2280,42 @@ describe("AgentDetailView", () => {
});
});
it("persists auto-claim toggle changes on save", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {
enabled: true,
autoClaimRelevantTasks: true,
heartbeatIntervalMs: 30000,
},
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
const autoClaimInput = await screen.findByLabelText("Auto-Claim Relevant Tasks");
await user.click(autoClaimInput);
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
runtimeConfig: expect.objectContaining({ autoClaimRelevantTasks: false }),
}),
undefined,
);
});
});
it("forwards projectId to updateAgent", async () => {
const addToast = vi.fn();
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);

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.",