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:
5
.changeset/fn-3250-auto-claim.md
Normal file
5
.changeset/fn-3250-auto-claim.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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'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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user