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:
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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's role and soul.</span>
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="hb-heartbeatIntervalMs">Heartbeat Interval (s)</label>
|
||||
<input
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user