FN-6327: add engineer backlog auto-claim controls

Expose engineer backlog auto-claim configuration in dashboard settings and agent heartbeat settings.

- Add a project-level Scheduling & Capacity checkbox for engineer backlog auto-claim.
- Add a per-agent Heartbeat Settings checkbox that persists runtimeConfig.engineerBacklogAutoClaim.
- Cover the new settings UI with dashboard component tests.
- Document the project default and per-agent override locations and add a release changeset.

Files changed:
 .../FN-6327-engineer-backlog-auto-claim-ui.md      |   5 +
 docs/settings-reference.md                         |   4 +-
 .../dashboard/app/components/AgentDetailView.tsx   |  25 ++++-
 .../AgentDetailView.advanced-settings.test.tsx     | 104 +++++++++++++++++++++
 .../components/__tests__/SettingsModal.test.tsx    |  66 +++++++++++++
 .../settings/sections/SchedulingSection.tsx        |  14 +++
 6 files changed, 215 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-6327

Fusion-Task-Lineage: fddc39bc-f50e-42fa-ae67-7c3eca59cbeb
This commit is contained in:
gsxdsm
2026-06-12 22:55:01 -07:00
parent f2054d0fa9
commit 1c4ec5f002
6 changed files with 215 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add dashboard controls for the engineer backlog auto-claim opt-in at project scope and per-agent heartbeat settings.

View File

@@ -295,7 +295,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `heartbeatScopeDiscipline` | `"strict" \| "lite" \| "off"` | `"strict"` | Heartbeat prompt procedure mode. `strict` keeps coordination-heavy scope discipline, `lite` restores pre-2026-05-11 wording, and `off` uses a minimal procedure. Per-agent `runtimeConfig.heartbeatScopeDiscipline` can override this default. | | `heartbeatScopeDiscipline` | `"strict" \| "lite" \| "off"` | `"strict"` | Heartbeat prompt procedure mode. `strict` keeps coordination-heavy scope discipline, `lite` restores pre-2026-05-11 wording, and `off` uses a minimal procedure. Per-agent `runtimeConfig.heartbeatScopeDiscipline` can override this default. |
| `heartbeatPromptTemplate` | `"default" \| "compact"` | `"default"` | Heartbeat execution-prompt trim template default. Per-agent `runtimeConfig.heartbeatPromptTemplate` overrides this value. Role fallback when unset everywhere is `executor`→`default`, non-executor coordination roles→`compact`. | | `heartbeatPromptTemplate` | `"default" \| "compact"` | `"default"` | Heartbeat execution-prompt trim template default. Per-agent `runtimeConfig.heartbeatPromptTemplate` overrides this value. Role fallback when unset everywhere is `executor`→`default`, non-executor coordination roles→`compact`. |
| `autoClaimCandidatesInPrompt` | `number` | `5` | Default no-task heartbeat candidate list length. Integer range `0-10`; `0` suppresses candidate prompt injection. | | `autoClaimCandidatesInPrompt` | `number` | `5` | Default no-task heartbeat candidate list length. Integer range `0-10`; `0` suppresses candidate prompt injection. |
| `engineerBacklogAutoClaim` | `boolean` | `false` | Opt engineer-role agents into no-task backlog auto-claim for implementation tasks. The default remains executor-only; per-agent `runtimeConfig.engineerBacklogAutoClaim` overrides this project default, and explicit routing/delegation is unchanged. | | `engineerBacklogAutoClaim` | `boolean` | `false` | Opt engineer-role agents into no-task backlog auto-claim for implementation tasks. The default remains executor-only; per-agent `runtimeConfig.engineerBacklogAutoClaim` overrides this project default, and explicit routing/delegation is unchanged. Configure the project default in **Settings → Scheduling & Capacity → Let engineer agents auto-claim backlog tasks**; configure the per-agent override in **Agents → Agent Detail → Settings → Heartbeat Settings → Engineer Backlog Auto-Claim**. |
| `defaultNodeId` | `string` | `undefined` | Optional project default execution node for task dispatch. When set, tasks without a per-task `nodeId` override resolve to this node (`routing source: project-default`). See [Task Management → Node Routing](./task-management.md#node-routing). | | `defaultNodeId` | `string` | `undefined` | Optional project default execution node for task dispatch. When set, tasks without a per-task `nodeId` override resolve to this node (`routing source: project-default`). See [Task Management → Node Routing](./task-management.md#node-routing). |
| `unavailableNodePolicy` | `"block" \| "fallback-local"` | `"block"` | Project routing policy used during scheduler dispatch when a task resolves to a remote node and node health is known. `"block"` keeps the task in `todo` if the node is unhealthy; `"fallback-local"` reroutes dispatch to local execution. See [Architecture → Task Routing Architecture](./architecture.md#task-routing-architecture). | | `unavailableNodePolicy` | `"block" \| "fallback-local"` | `"block"` | Project routing policy used during scheduler dispatch when a task resolves to a remote node and node health is known. `"block"` keeps the task in `todo` if the node is unhealthy; `"fallback-local"` reroutes dispatch to local execution. See [Architecture → Task Routing Architecture](./architecture.md#task-routing-architecture). |
| `secretsAccessPolicy` | `"auto" \| "prompt" \| "deny"` | `undefined` | Project-level default secret access policy (overrides global default when present). | | `secretsAccessPolicy` | `"auto" \| "prompt" \| "deny"` | `undefined` | Project-level default secret access policy (overrides global default when present). |
@@ -1173,7 +1173,7 @@ Common heartbeat/runtime keys on `runtimeConfig` include:
| `selfImproveIntervalMs` | `number` | Delay between self-improvement cycles (default 4h, minimum 1h) | | `selfImproveIntervalMs` | `number` | Delay between self-improvement cycles (default 4h, minimum 1h) |
| `lastSelfImproveAt` | `string` | Last self-improvement checkpoint timestamp (managed by heartbeat monitor) | | `lastSelfImproveAt` | `string` | Last self-improvement checkpoint timestamp (managed by heartbeat monitor) |
Configure these per agent in **Agents → Agent Detail → Settings → Heartbeat Settings** (dashboard), or by updating agent `runtimeConfig` via the Agents API/CLI config flows. Configure these per agent in **Agents → Agent Detail → Settings → Heartbeat Settings** (dashboard), or by updating agent `runtimeConfig` via the Agents API/CLI config flows. The **Engineer Backlog Auto-Claim** checkbox in this card controls `runtimeConfig.engineerBacklogAutoClaim` for that agent and only affects no-task backlog pickup; explicit assignment and delegation behavior are unchanged.
These examples show agents configured to use Paperclip, Hermes, and OpenClaw runtime hints: These examples show agents configured to use Paperclip, Hermes, and OpenClaw runtime hints:

View File

@@ -3310,6 +3310,10 @@ function deriveAutoClaimRelevantTasksEnabled(runtimeConfig: AgentDetail["runtime
return runtimeConfig?.autoClaimRelevantTasks !== false; return runtimeConfig?.autoClaimRelevantTasks !== false;
} }
function deriveEngineerBacklogAutoClaim(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean {
return runtimeConfig?.engineerBacklogAutoClaim === true;
}
function deriveRunMissedHeartbeatOnStartup(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean { function deriveRunMissedHeartbeatOnStartup(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean {
return runtimeConfig?.runMissedHeartbeatOnStartup === true; return runtimeConfig?.runMissedHeartbeatOnStartup === true;
} }
@@ -3692,6 +3696,9 @@ function ConfigTab({
const [autoClaimRelevantTasksEnabled, setAutoClaimRelevantTasksEnabled] = useState<boolean>( const [autoClaimRelevantTasksEnabled, setAutoClaimRelevantTasksEnabled] = useState<boolean>(
() => deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig), () => deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig),
); );
const [engineerBacklogAutoClaimEnabled, setEngineerBacklogAutoClaimEnabled] = useState<boolean>(
() => deriveEngineerBacklogAutoClaim(agent.runtimeConfig),
);
const [runMissedHeartbeatOnStartup, setRunMissedHeartbeatOnStartup] = useState<boolean>( const [runMissedHeartbeatOnStartup, setRunMissedHeartbeatOnStartup] = useState<boolean>(
() => deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig), () => deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig),
); );
@@ -3979,6 +3986,7 @@ function ConfigTab({
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; if (autoClaimRelevantTasksEnabled !== deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig)) return true;
if (engineerBacklogAutoClaimEnabled !== deriveEngineerBacklogAutoClaim(agent.runtimeConfig)) return true;
if (runMissedHeartbeatOnStartup !== deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig)) return true; if (runMissedHeartbeatOnStartup !== deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig)) return true;
if (allowParallelExecution !== deriveAllowParallelExecution(agent.runtimeConfig)) return true; if (allowParallelExecution !== deriveAllowParallelExecution(agent.runtimeConfig)) return true;
if (skipHeartbeatWhenIdle !== deriveSkipHeartbeatWhenIdle(agent.runtimeConfig)) return true; if (skipHeartbeatWhenIdle !== deriveSkipHeartbeatWhenIdle(agent.runtimeConfig)) return true;
@@ -4058,6 +4066,7 @@ function ConfigTab({
setHeartbeatValues(deriveHeartbeatValues(agent.runtimeConfig)); setHeartbeatValues(deriveHeartbeatValues(agent.runtimeConfig));
setHeartbeatEnabled(deriveHeartbeatEnabled(agent.runtimeConfig)); setHeartbeatEnabled(deriveHeartbeatEnabled(agent.runtimeConfig));
setAutoClaimRelevantTasksEnabled(deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig)); setAutoClaimRelevantTasksEnabled(deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig));
setEngineerBacklogAutoClaimEnabled(deriveEngineerBacklogAutoClaim(agent.runtimeConfig));
setRunMissedHeartbeatOnStartup(deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig)); setRunMissedHeartbeatOnStartup(deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig));
setAllowParallelExecution(deriveAllowParallelExecution(agent.runtimeConfig)); setAllowParallelExecution(deriveAllowParallelExecution(agent.runtimeConfig));
setSkipHeartbeatWhenIdle(deriveSkipHeartbeatWhenIdle(agent.runtimeConfig)); setSkipHeartbeatWhenIdle(deriveSkipHeartbeatWhenIdle(agent.runtimeConfig));
@@ -4216,6 +4225,7 @@ function ConfigTab({
const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig }; const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig };
newRuntimeConfig.enabled = heartbeatEnabled; newRuntimeConfig.enabled = heartbeatEnabled;
newRuntimeConfig.autoClaimRelevantTasks = autoClaimRelevantTasksEnabled; newRuntimeConfig.autoClaimRelevantTasks = autoClaimRelevantTasksEnabled;
newRuntimeConfig.engineerBacklogAutoClaim = engineerBacklogAutoClaimEnabled;
newRuntimeConfig.runMissedHeartbeatOnStartup = runMissedHeartbeatOnStartup; newRuntimeConfig.runMissedHeartbeatOnStartup = runMissedHeartbeatOnStartup;
newRuntimeConfig.allowParallelExecution = allowParallelExecution; newRuntimeConfig.allowParallelExecution = allowParallelExecution;
newRuntimeConfig.skipHeartbeatWhenIdle = skipHeartbeatWhenIdle; newRuntimeConfig.skipHeartbeatWhenIdle = skipHeartbeatWhenIdle;
@@ -4326,7 +4336,7 @@ function ConfigTab({
runtimeConfig: newRuntimeConfig, runtimeConfig: newRuntimeConfig,
bundleConfig: newBundleConfig, bundleConfig: newBundleConfig,
}; };
}, [agent.metadata, agent.runtimeConfig, allowParallelExecution, autoClaimRelevantTasksEnabled, budgetValues, bundleEntryFile, bundleExternalPath, bundleFiles, bundleMode, formValues, heartbeatEnabled, heartbeatPromptTemplate, heartbeatScopeDiscipline, heartbeatValues, iconValue, modelValue, nameValue, reportsToValue, roleValue, runMissedHeartbeatOnStartup, runtimeMode, selectedRuntimeId, selectedSkills, skipHeartbeatWhenIdle, titleValue, validationErrors]); }, [agent.metadata, agent.runtimeConfig, allowParallelExecution, autoClaimRelevantTasksEnabled, budgetValues, bundleEntryFile, bundleExternalPath, bundleFiles, bundleMode, engineerBacklogAutoClaimEnabled, formValues, heartbeatEnabled, heartbeatPromptTemplate, heartbeatScopeDiscipline, heartbeatValues, iconValue, modelValue, nameValue, reportsToValue, roleValue, runMissedHeartbeatOnStartup, runtimeMode, selectedRuntimeId, selectedSkills, skipHeartbeatWhenIdle, 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();
@@ -4769,6 +4779,19 @@ function ConfigTab({
{t("agents.autoClaimRelevantTasks", "Auto-Claim Relevant Tasks")} {t("agents.autoClaimRelevantTasks", "Auto-Claim Relevant Tasks")}
</label> </label>
<span className="config-hint">{t("agents.autoClaimHint", "When enabled (default), no-task heartbeats scan open unowned work and auto-claim tasks aligned with this agent's role and soul.")}</span> <span className="config-hint">{t("agents.autoClaimHint", "When enabled (default), no-task heartbeats scan open unowned work and auto-claim tasks aligned with this agent's role and soul.")}</span>
<label className="checkbox-label" htmlFor="hb-engineerBacklogAutoClaim">
<input
id="hb-engineerBacklogAutoClaim"
type="checkbox"
checked={engineerBacklogAutoClaimEnabled}
onChange={(e) => {
setEngineerBacklogAutoClaimEnabled(e.target.checked);
void scheduleAutoSave();
}}
/>
{t("agents.engineerBacklogAutoClaim", "Engineer Backlog Auto-Claim")}
</label>
<span className="config-hint">{t("agents.engineerBacklogAutoClaimHint", "Per-agent override of the project default. Allows this engineer-role agent to auto-claim unowned backlog tasks; explicit assignment and delegation are unchanged.")}</span>
</div> </div>
<div className="config-field"> <div className="config-field">

View File

@@ -709,6 +709,35 @@ describe("Advanced Settings", () => {
}); });
}); });
it.each([
["undefined", undefined, false],
["false", false, false],
["true", true, true],
] as const)("renders engineer backlog auto-claim unchecked by default and reflects %s runtimeConfig", async (_label, engineerBacklogAutoClaim, expectedChecked) => {
mockFetchAgent.mockResolvedValue(createMockAgent({
role: "engineer",
runtimeConfig: {
heartbeatIntervalMs: 30000,
...(engineerBacklogAutoClaim === undefined ? {} : { engineerBacklogAutoClaim }),
},
}));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
await waitFor(() => {
expect((screen.getByLabelText("Engineer Backlog Auto-Claim") as HTMLInputElement).checked).toBe(expectedChecked);
});
});
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: {} }));
@@ -1018,6 +1047,81 @@ describe("Advanced Settings", () => {
}); });
}); });
it("persists engineer backlog auto-claim enabled override on save", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
role: "engineer",
runtimeConfig: {
enabled: 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 toggle = await screen.findByLabelText("Engineer Backlog Auto-Claim");
expect((toggle as HTMLInputElement).checked).toBe(false);
await user.click(toggle);
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
runtimeConfig: expect.objectContaining({ engineerBacklogAutoClaim: true }),
}),
undefined,
);
});
});
it("persists engineer backlog auto-claim disabled override on save", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
role: "engineer",
runtimeConfig: {
enabled: true,
engineerBacklogAutoClaim: 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 toggle = await screen.findByLabelText("Engineer Backlog Auto-Claim");
expect((toggle as HTMLInputElement).checked).toBe(true);
await user.click(toggle);
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
runtimeConfig: expect.objectContaining({ engineerBacklogAutoClaim: false }),
}),
undefined,
);
});
});
it("applies coordination-only preset and persists disabled auto-claim", async () => { it("applies coordination-only preset and persists disabled auto-claim", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: { runtimeConfig: {

View File

@@ -2648,6 +2648,72 @@ describe("SettingsModal", () => {
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>; const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.heartbeatScopeDiscipline).toBe("off"); expect(payload.heartbeatScopeDiscipline).toBe("off");
}); });
it.each([
["undefined", undefined, false],
["false", false, false],
["true", true, true],
] as const)("renders engineer backlog auto-claim from %s project setting", async (_label, engineerBacklogAutoClaim, expectedChecked) => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
...(engineerBacklogAutoClaim === undefined ? {} : { engineerBacklogAutoClaim }),
});
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling & Capacity"));
expect((screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement).checked).toBe(expectedChecked);
});
it("routes enabled engineer backlog auto-claim through the project settings save payload", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
engineerBacklogAutoClaim: false,
});
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling & Capacity"));
const toggle = screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement;
expect(toggle.checked).toBe(false);
await userEvent.click(toggle);
await userEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.engineerBacklogAutoClaim).toBe(true);
});
it("routes disabled engineer backlog auto-claim through the project settings save payload", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
engineerBacklogAutoClaim: true,
});
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling & Capacity"));
const toggle = screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement;
expect(toggle.checked).toBe(true);
await userEvent.click(toggle);
await userEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.engineerBacklogAutoClaim).toBe(false);
});
}); });
describe("Number input clearing", () => { describe("Number input clearing", () => {

View File

@@ -126,6 +126,20 @@ export function SchedulingSection({
</select> </select>
<small>Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure.</small> <small>Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure.</small>
</div> </div>
<div className="form-group">
<label htmlFor="engineerBacklogAutoClaim" className="checkbox-label">
<input
id="engineerBacklogAutoClaim"
type="checkbox"
checked={form.engineerBacklogAutoClaim === true}
onChange={(e) =>
setForm((f) => ({ ...f, engineerBacklogAutoClaim: e.target.checked }))
}
/>
Let engineer agents auto-claim backlog tasks
</label>
<small>Backlog/no-task auto-claim is executor-only by default. Enable to let engineer-role agents auto-claim unowned backlog tasks; explicit routing and delegation are unchanged. Default: off.</small>
</div>
<div className="form-group"> <div className="form-group">
<label htmlFor="taskStuckTimeoutMs">Stuck Task Timeout (minutes)</label> <label htmlFor="taskStuckTimeoutMs">Stuck Task Timeout (minutes)</label>
<input <input