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:
5
.changeset/FN-6327-engineer-backlog-auto-claim-ui.md
Normal file
5
.changeset/FN-6327-engineer-backlog-auto-claim-ui.md
Normal 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.
|
||||
@@ -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. |
|
||||
| `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. |
|
||||
| `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). |
|
||||
| `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). |
|
||||
@@ -1173,7 +1173,7 @@ Common heartbeat/runtime keys on `runtimeConfig` include:
|
||||
| `selfImproveIntervalMs` | `number` | Delay between self-improvement cycles (default 4h, minimum 1h) |
|
||||
| `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:
|
||||
|
||||
|
||||
@@ -3310,6 +3310,10 @@ function deriveAutoClaimRelevantTasksEnabled(runtimeConfig: AgentDetail["runtime
|
||||
return runtimeConfig?.autoClaimRelevantTasks !== false;
|
||||
}
|
||||
|
||||
function deriveEngineerBacklogAutoClaim(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean {
|
||||
return runtimeConfig?.engineerBacklogAutoClaim === true;
|
||||
}
|
||||
|
||||
function deriveRunMissedHeartbeatOnStartup(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean {
|
||||
return runtimeConfig?.runMissedHeartbeatOnStartup === true;
|
||||
}
|
||||
@@ -3692,6 +3696,9 @@ function ConfigTab({
|
||||
const [autoClaimRelevantTasksEnabled, setAutoClaimRelevantTasksEnabled] = useState<boolean>(
|
||||
() => deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig),
|
||||
);
|
||||
const [engineerBacklogAutoClaimEnabled, setEngineerBacklogAutoClaimEnabled] = useState<boolean>(
|
||||
() => deriveEngineerBacklogAutoClaim(agent.runtimeConfig),
|
||||
);
|
||||
const [runMissedHeartbeatOnStartup, setRunMissedHeartbeatOnStartup] = useState<boolean>(
|
||||
() => deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig),
|
||||
);
|
||||
@@ -3979,6 +3986,7 @@ function ConfigTab({
|
||||
const rc = agent.runtimeConfig ?? {};
|
||||
if (heartbeatEnabled !== deriveHeartbeatEnabled(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 (allowParallelExecution !== deriveAllowParallelExecution(agent.runtimeConfig)) return true;
|
||||
if (skipHeartbeatWhenIdle !== deriveSkipHeartbeatWhenIdle(agent.runtimeConfig)) return true;
|
||||
@@ -4058,6 +4066,7 @@ function ConfigTab({
|
||||
setHeartbeatValues(deriveHeartbeatValues(agent.runtimeConfig));
|
||||
setHeartbeatEnabled(deriveHeartbeatEnabled(agent.runtimeConfig));
|
||||
setAutoClaimRelevantTasksEnabled(deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig));
|
||||
setEngineerBacklogAutoClaimEnabled(deriveEngineerBacklogAutoClaim(agent.runtimeConfig));
|
||||
setRunMissedHeartbeatOnStartup(deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig));
|
||||
setAllowParallelExecution(deriveAllowParallelExecution(agent.runtimeConfig));
|
||||
setSkipHeartbeatWhenIdle(deriveSkipHeartbeatWhenIdle(agent.runtimeConfig));
|
||||
@@ -4216,6 +4225,7 @@ function ConfigTab({
|
||||
const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig };
|
||||
newRuntimeConfig.enabled = heartbeatEnabled;
|
||||
newRuntimeConfig.autoClaimRelevantTasks = autoClaimRelevantTasksEnabled;
|
||||
newRuntimeConfig.engineerBacklogAutoClaim = engineerBacklogAutoClaimEnabled;
|
||||
newRuntimeConfig.runMissedHeartbeatOnStartup = runMissedHeartbeatOnStartup;
|
||||
newRuntimeConfig.allowParallelExecution = allowParallelExecution;
|
||||
newRuntimeConfig.skipHeartbeatWhenIdle = skipHeartbeatWhenIdle;
|
||||
@@ -4326,7 +4336,7 @@ function ConfigTab({
|
||||
runtimeConfig: newRuntimeConfig,
|
||||
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 payload = buildSavePayload();
|
||||
@@ -4769,6 +4779,19 @@ function ConfigTab({
|
||||
{t("agents.autoClaimRelevantTasks", "Auto-Claim Relevant Tasks")}
|
||||
</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>
|
||||
<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 className="config-field">
|
||||
|
||||
@@ -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 () => {
|
||||
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 () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
runtimeConfig: {
|
||||
|
||||
@@ -2648,6 +2648,72 @@ describe("SettingsModal", () => {
|
||||
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
||||
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", () => {
|
||||
|
||||
@@ -126,6 +126,20 @@ export function SchedulingSection({
|
||||
</select>
|
||||
<small>Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure.</small>
|
||||
</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">
|
||||
<label htmlFor="taskStuckTimeoutMs">Stuck Task Timeout (minutes)</label>
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user