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 */
|
||||
|
||||
Reference in New Issue
Block a user