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 d051c09a1d
commit cd2a464a41
9 changed files with 433 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add per-agent heartbeat auto-claim controls so identity-bearing agents can opportunistically claim relevant unowned tasks during no-task heartbeat runs.

View File

@@ -217,6 +217,7 @@ The `runtimeConfig` field on agents supports the following options:
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `enabled` | `boolean` | `true` | Whether heartbeat triggers are enabled for this agent | | `enabled` | `boolean` | `true` | Whether heartbeat triggers are enabled for this agent |
| `heartbeatIntervalMs` | `number` | — | How often the agent should wake up for heartbeat checks (ms) | | `heartbeatIntervalMs` | `number` | — | How often the agent should wake up for heartbeat checks (ms) |
| `autoClaimRelevantTasks` | `boolean` | `true` | During no-task heartbeats, opportunistically claim unowned relevant todo tasks that align with the agent's role/soul |
| `heartbeatTimeoutMs` | `number` | — | Time without heartbeat before agent is considered unresponsive (ms) | | `heartbeatTimeoutMs` | `number` | — | Time without heartbeat before agent is considered unresponsive (ms) |
| `maxConcurrentRuns` | `number` | `1` | Max concurrent heartbeat runs for this agent | | `maxConcurrentRuns` | `number` | `1` | Max concurrent heartbeat runs for this agent |
| `messageResponseMode` | `"immediate" \| "on-heartbeat"` | `"immediate"` | Whether agent wakes immediately on message (immediate) or processes during heartbeat (on-heartbeat). See [Heartbeat Run Mailbox Checking](#heartbeat-run-mailbox-checking) | | `messageResponseMode` | `"immediate" \| "on-heartbeat"` | `"immediate"` | Whether agent wakes immediately on message (immediate) or processes during heartbeat (on-heartbeat). See [Heartbeat Run Mailbox Checking](#heartbeat-run-mailbox-checking) |
@@ -227,6 +228,18 @@ The `runtimeConfig` field on agents supports the following options:
Heartbeat values are validated and minimum-clamped to 5 minutes (300,000 ms). Heartbeat values are validated and minimum-clamped to 5 minutes (300,000 ms).
Project setting `heartbeatMultiplier` (default `1`) scales resolved heartbeat intervals globally; per-agent `heartbeatIntervalMs` remains the base interval before multiplier scaling. This setting is configured from the **Agents** screen's **Controls** popup under "Heartbeat Speed". Project setting `heartbeatMultiplier` (default `1`) scales resolved heartbeat intervals globally; per-agent `heartbeatIntervalMs` remains the base interval before multiplier scaling. This setting is configured from the **Agents** screen's **Controls** popup under "Heartbeat Speed".
### No-task auto-claim behavior
When an identity-bearing, non-ephemeral agent wakes with no assigned task and `runtimeConfig.autoClaimRelevantTasks !== false`, the heartbeat monitor scans open todo tasks and may claim one before constructing the prompt run.
Guardrails:
- Only unpaused, unassigned, unchecked-out todo tasks with satisfied dependencies are considered
- Claims are rejected for terminal/paused/owned/conflicting tasks
- Checkout safety is preserved (`checkout_conflict` paths are non-fatal skips)
- On successful claim, the same heartbeat run switches into task-scoped execution (no nested run re-entry)
Operators can disable this per agent in **Agent Detail → Settings → Heartbeat Settings → Auto-Claim Relevant Tasks**.
## Agent Instructions (Dashboard) ## Agent Instructions (Dashboard)
The Agent Detail view includes a dedicated **Instructions** tab for editing agent custom instructions. This replaces the previous embedded instructions editor in the Settings tab, providing a more discoverable and user-friendly experience. The Agent Detail view includes a dedicated **Instructions** tab for editing agent custom instructions. This replaces the previous embedded instructions editor in the Settings tab, providing a more discoverable and user-friendly experience.

View File

@@ -208,6 +208,7 @@ describe("AgentStore", () => {
expect(agent.metadata).toEqual({}); expect(agent.metadata).toEqual({});
expect(agent.runtimeConfig).toMatchObject({ expect(agent.runtimeConfig).toMatchObject({
enabled: true, enabled: true,
autoClaimRelevantTasks: true,
}); });
expect(new Date(agent.createdAt).getTime()).not.toBeNaN(); expect(new Date(agent.createdAt).getTime()).not.toBeNaN();
expect(new Date(agent.updatedAt).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`); 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 () => { it("preserves custom metadata", async () => {
const agent = await store.createAgent({ const agent = await store.createAgent({
name: "With Meta", name: "With Meta",
@@ -932,6 +954,7 @@ describe("AgentStore", () => {
// whatever the caller supplied. // whatever the caller supplied.
expect(result.agent.runtimeConfig).toEqual({ expect(result.agent.runtimeConfig).toEqual({
enabled: true, enabled: true,
autoClaimRelevantTasks: true,
heartbeatTimeoutMs: 60000, heartbeatTimeoutMs: 60000,
heartbeatIntervalMs: 3_600_000, heartbeatIntervalMs: 3_600_000,
}); });
@@ -1775,6 +1798,65 @@ describe("AgentStore", () => {
await store.checkoutTask(holderId, taskId); await store.checkoutTask(holderId, taskId);
expect(await store.getCheckedOutBy(taskId)).toBe(holderId); 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 ──────────────────────────────────────────────────── // ── resetAgent ────────────────────────────────────────────────────

View File

@@ -181,6 +181,9 @@ function resolveCreationRuntimeConfig(
if (typeof rc.enabled !== "boolean") { if (typeof rc.enabled !== "boolean") {
rc.enabled = true; rc.enabled = true;
} }
if (typeof rc.autoClaimRelevantTasks !== "boolean") {
rc.autoClaimRelevantTasks = true;
}
if (typeof rc.heartbeatIntervalMs !== "number" || !Number.isFinite(rc.heartbeatIntervalMs)) { if (typeof rc.heartbeatIntervalMs !== "number" || !Number.isFinite(rc.heartbeatIntervalMs)) {
rc.heartbeatIntervalMs = DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS; 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. * Acquire a checkout lease for a task.
* Throws CheckoutConflictError when another agent already holds the lease. * Throws CheckoutConflictError when another agent already holds the lease.

View File

@@ -3572,6 +3572,8 @@ export type MessageResponseMode = "immediate" | "on-heartbeat";
export interface AgentHeartbeatConfig { export interface AgentHeartbeatConfig {
/** Whether heartbeat triggers are enabled for this agent (default: true) */ /** Whether heartbeat triggers are enabled for this agent (default: true) */
enabled?: boolean; 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 */ /** Polling interval in ms (default: 30000). Min: 1000 */
heartbeatIntervalMs?: number; heartbeatIntervalMs?: number;
/** Heartbeat timeout in ms (default: 60000). Min: 5000 */ /** Heartbeat timeout in ms (default: 60000). Min: 5000 */

View File

@@ -2613,6 +2613,10 @@ function deriveHeartbeatEnabled(runtimeConfig: AgentDetail["runtimeConfig"] | un
return runtimeConfig?.enabled !== false; return runtimeConfig?.enabled !== false;
} }
function deriveAutoClaimRelevantTasksEnabled(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean {
return runtimeConfig?.autoClaimRelevantTasks !== false;
}
function deriveBudgetValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): Record<string, string> { function deriveBudgetValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): Record<string, string> {
const bc = (runtimeConfig ?? {}).budgetConfig as Record<string, unknown> | undefined; const bc = (runtimeConfig ?? {}).budgetConfig as Record<string, unknown> | undefined;
const nextValues: Record<string, string> = {}; const nextValues: Record<string, string> = {};
@@ -2967,6 +2971,9 @@ function ConfigTab({
const [heartbeatEnabled, setHeartbeatEnabled] = useState<boolean>( const [heartbeatEnabled, setHeartbeatEnabled] = useState<boolean>(
() => deriveHeartbeatEnabled(agent.runtimeConfig), () => deriveHeartbeatEnabled(agent.runtimeConfig),
); );
const [autoClaimRelevantTasksEnabled, setAutoClaimRelevantTasksEnabled] = useState<boolean>(
() => deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig),
);
// Budget config state initialised from agent.runtimeConfig.budgetConfig // Budget config state initialised from agent.runtimeConfig.budgetConfig
const [budgetValues, setBudgetValues] = useState<Record<string, string>>( const [budgetValues, setBudgetValues] = useState<Record<string, string>>(
@@ -3167,6 +3174,7 @@ function ConfigTab({
// Check heartbeat values // Check heartbeat values
const rc = agent.runtimeConfig ?? {}; const rc = agent.runtimeConfig ?? {};
if (heartbeatEnabled !== deriveHeartbeatEnabled(agent.runtimeConfig)) return true; if (heartbeatEnabled !== deriveHeartbeatEnabled(agent.runtimeConfig)) return true;
if (autoClaimRelevantTasksEnabled !== deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig)) return true;
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "messageResponseMode"] as const) { for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "messageResponseMode"] as const) {
const current = heartbeatValues[key]?.trim() ?? ""; const current = heartbeatValues[key]?.trim() ?? "";
let persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : ""; let persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
@@ -3240,6 +3248,7 @@ function ConfigTab({
previousAgentRuntimeSyncRef.current = nextSnapshot; previousAgentRuntimeSyncRef.current = nextSnapshot;
setHeartbeatValues(deriveHeartbeatValues(agent.runtimeConfig)); setHeartbeatValues(deriveHeartbeatValues(agent.runtimeConfig));
setHeartbeatEnabled(deriveHeartbeatEnabled(agent.runtimeConfig)); setHeartbeatEnabled(deriveHeartbeatEnabled(agent.runtimeConfig));
setAutoClaimRelevantTasksEnabled(deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig));
setBudgetValues(deriveBudgetValues(agent.runtimeConfig)); setBudgetValues(deriveBudgetValues(agent.runtimeConfig));
setModelValue(initialModelValue); setModelValue(initialModelValue);
setSelectedRuntimeId(initialRuntimeHint); setSelectedRuntimeId(initialRuntimeHint);
@@ -3385,6 +3394,7 @@ function ConfigTab({
// Build the runtimeConfig payload — only include non-empty values // Build the runtimeConfig payload — only include non-empty values
const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig }; const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig };
newRuntimeConfig.enabled = heartbeatEnabled; newRuntimeConfig.enabled = heartbeatEnabled;
newRuntimeConfig.autoClaimRelevantTasks = autoClaimRelevantTasksEnabled;
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns"] as const) { for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns"] as const) {
const raw = heartbeatValues[key]?.trim(); const raw = heartbeatValues[key]?.trim();
if (!raw) { if (!raw) {
@@ -3480,7 +3490,7 @@ function ConfigTab({
runtimeConfig: newRuntimeConfig, runtimeConfig: newRuntimeConfig,
bundleConfig: newBundleConfig, 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 persistSettings = useCallback(async (showValidationToast: boolean, source: "auto" | "manual") => {
const payload = buildSavePayload(); 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> <span className="config-hint">When enabled, this agent receives scheduled heartbeat runs based on its interval.</span>
</div> </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"> <div className="config-field">
<label htmlFor="hb-heartbeatIntervalMs">Heartbeat Interval (s)</label> <label htmlFor="hb-heartbeatIntervalMs">Heartbeat Interval (s)</label>
<input <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 () => { it("shows Save Settings button disabled when no changes", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} })); 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 () => { it("forwards projectId to updateAgent", async () => {
const addToast = vi.fn(); const addToast = vi.fn();
mockUpdateAgent.mockResolvedValue(createMockAgent() as any); mockUpdateAgent.mockResolvedValue(createMockAgent() as any);

View File

@@ -71,6 +71,7 @@ describe("executeHeartbeat", () => {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} as unknown as TaskDetail), } as unknown as TaskDetail),
selectNextTaskForAgent: vi.fn().mockResolvedValue(null), selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn().mockResolvedValue({ createTask: vi.fn().mockResolvedValue({
id: "FN-002", id: "FN-002",
description: "Created task", description: "Created task",
@@ -132,6 +133,10 @@ describe("executeHeartbeat", () => {
mockAgent.taskId = taskId; mockAgent.taskId = taskId;
return mockAgent; return mockAgent;
}), }),
claimTaskForAgent: vi.fn().mockImplementation(async (_agentId: string, _taskId: string) => ({
ok: false,
reason: "task_not_found",
})),
startHeartbeatRun: vi.fn().mockResolvedValue({ startHeartbeatRun: vi.fn().mockResolvedValue({
id: "run-001", id: "run-001",
agentId: "agent-001", agentId: "agent-001",
@@ -423,6 +428,94 @@ describe("executeHeartbeat", () => {
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "no_assignment_identity_run" })); 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 () => { 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 store = createStoreWithAgentForExec({ taskId: undefined, instructionsText: "Monitor task board and create follow-up tasks" });
const mockSession = createMockAgentSession(); const mockSession = createMockAgentSession();

View File

@@ -136,6 +136,35 @@ function formatRelativeTime(iso?: string | null): string {
return `${formatDuration(elapsed)} ago`; 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. */ /** Compare blocked-state snapshots to decide whether blocked messaging is duplicate noise. */
export function isBlockedStateDuplicate(current: BlockedStateSnapshot, previous: BlockedStateSnapshot): boolean { export function isBlockedStateDuplicate(current: BlockedStateSnapshot, previous: BlockedStateSnapshot): boolean {
return current.blockedBy === previous.blockedBy && current.contextHash === previous.contextHash; return current.blockedBy === previous.blockedBy && current.contextHash === previous.contextHash;
@@ -1157,6 +1186,52 @@ export class HeartbeatMonitor {
engineRunContext.taskId = taskId; 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) { if (!taskId) {
// Agents with identity (soul, instructions, memory) should run a full heartbeat // Agents with identity (soul, instructions, memory) should run a full heartbeat
// session even without a task, so they can do ambient work like messaging, // 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 = [ executionPrompt = [
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`, `Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`, `Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
@@ -1556,6 +1639,7 @@ export class HeartbeatMonitor {
`- wake reason: ${wakeReason}`, `- wake reason: ${wakeReason}`,
`- assigned task: none`, `- assigned task: none`,
`- pending messages: ${pendingMessages.length}`, `- pending messages: ${pendingMessages.length}`,
`- auto-claim relevant tasks: ${autoClaimEnabled ? "enabled" : "disabled"}`,
"", "",
"Treat this wake delta as the highest-priority change for this heartbeat.", "Treat this wake delta as the highest-priority change for this heartbeat.",
"This is an autonomous heartbeat run (manual or automatic): re-anchor on", "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", "5. **Monitor project flow** — Review board/project signals and surface issues",
" by creating or delegating follow-up work as appropriate.", " 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, ...pendingMessagesLines,
"", "",
"Your soul, instructions, and memory are already loaded in the system prompt.", "Your soul, instructions, and memory are already loaded in the system prompt.",