From 1c4ec5f002ff6474392fadf82fd4be4bf01874b8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 12 Jun 2026 22:55:01 -0700 Subject: [PATCH] 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 --- .../FN-6327-engineer-backlog-auto-claim-ui.md | 5 + docs/settings-reference.md | 4 +- .../app/components/AgentDetailView.tsx | 25 ++++- ...AgentDetailView.advanced-settings.test.tsx | 104 ++++++++++++++++++ .../__tests__/SettingsModal.test.tsx | 66 +++++++++++ .../settings/sections/SchedulingSection.tsx | 14 +++ 6 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 .changeset/FN-6327-engineer-backlog-auto-claim-ui.md diff --git a/.changeset/FN-6327-engineer-backlog-auto-claim-ui.md b/.changeset/FN-6327-engineer-backlog-auto-claim-ui.md new file mode 100644 index 0000000000..ad8ef955f6 --- /dev/null +++ b/.changeset/FN-6327-engineer-backlog-auto-claim-ui.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 2e4422dc8e..4ae74e22d5 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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: diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index a2cb6c84f4..38a70ea5f9 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -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( () => deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig), ); + const [engineerBacklogAutoClaimEnabled, setEngineerBacklogAutoClaimEnabled] = useState( + () => deriveEngineerBacklogAutoClaim(agent.runtimeConfig), + ); const [runMissedHeartbeatOnStartup, setRunMissedHeartbeatOnStartup] = useState( () => 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 = { ...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")} {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.")} + + {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.")}
diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx index 915257c549..fbc3d04904 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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: { diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 4ce5e654d4..8a01d84a5a 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -2648,6 +2648,72 @@ describe("SettingsModal", () => { const payload = mockUpdateSettings.mock.calls[0][0] as Record; 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; + 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; + expect(payload.engineerBacklogAutoClaim).toBe(false); + }); }); describe("Number input clearing", () => { diff --git a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx index 43ad43a4e4..85852b2ee0 100644 --- a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx +++ b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx @@ -126,6 +126,20 @@ export function SchedulingSection({ Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure.
+
+ + 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. +