FN-8552: add disabled heartbeat scheduling option
Allow agent heartbeat scheduling to be paused and resumed without losing its configured cadence. - Add a Disabled heartbeat interval dropdown option that persists runtime enablement. - Re-enable scheduling when preset or custom intervals are selected. - Cover disabled, legacy, desktop/mobile, and configuration-preservation flows. - Document the heartbeat control and add a patch changeset. Files changed: .changeset/fn-8552-heartbeat-disabled-option.md | 7 ++ README.md | 2 +- packages/dashboard/app/components/AgentsView.tsx | 41 +++++++- .../app/components/__tests__/AgentsView.test.tsx | 115 +++++++++++++++++++++ 4 files changed, 162 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-8552 Fusion-Task-Lineage: 07f78005-4ac0-4210-9dd3-6c5b06ab7bc5 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8552-heartbeat-disabled-option.md
Normal file
7
.changeset/fn-8552-heartbeat-disabled-option.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Let agent-card heartbeat controls disable and re-enable scheduling.
|
||||
category: feature
|
||||
dev: The interval dropdown now preserves runtime configuration while updating runtimeConfig.enabled.
|
||||
@@ -192,7 +192,7 @@ Here is the same fleet re-skinned into the **Ember** theme (dark graphite with a
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Import a team and every agent shows up here — role, reports-to chain, heartbeat, and token share. The **Agents** roster in Ember:
|
||||
Import a team and every agent shows up here — role, reports-to chain, heartbeat, and token share. Each agent card's heartbeat dropdown shows **Disabled** when scheduling is persisted off; choose Disabled to pause heartbeats while retaining its cadence, or select an interval to re-enable them. The **Agents** roster in Ember:
|
||||
|
||||
<div align="center">
|
||||
<img src="./demo/assets/agents-ember.png" alt="Fusion Agents view in the Ember theme" width="900" />
|
||||
|
||||
@@ -60,6 +60,7 @@ function getAgentRoles(t: TFunction<"app">): { value: AgentCapability; label: st
|
||||
}
|
||||
|
||||
const HEARTBEAT_MULTIPLIER_PRESETS = [0.1, 0.25, 0.5, 1, 2, 3, 5, 10] as const;
|
||||
const HEARTBEAT_DISABLED_OPTION_VALUE = "__disabled__";
|
||||
|
||||
const ORG_CHART_SCALE_MIN = 0.25;
|
||||
const ORG_CHART_SCALE_MAX = 3;
|
||||
@@ -935,6 +936,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
{
|
||||
runtimeConfig: {
|
||||
...(agent.runtimeConfig ?? {}),
|
||||
enabled: true,
|
||||
heartbeatIntervalMs: newIntervalMs,
|
||||
},
|
||||
},
|
||||
@@ -949,6 +951,28 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeartbeatDisabled = async (agent: Agent) => {
|
||||
setUpdatingHeartbeatAgentId(agent.id);
|
||||
try {
|
||||
await updateAgent(
|
||||
agent.id,
|
||||
{
|
||||
runtimeConfig: {
|
||||
...(agent.runtimeConfig ?? {}),
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
addToast(t("agents.heartbeatDisabled", "Heartbeat disabled for {{name}}", { name: agent.name }), "success");
|
||||
void loadAgents();
|
||||
} catch (err) {
|
||||
addToast(t("agents.heartbeatIntervalUpdateFailed", "Failed to update heartbeat interval: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
} finally {
|
||||
setUpdatingHeartbeatAgentId(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle saving custom heartbeat interval from typed minutes input.
|
||||
* Validation behavior:
|
||||
@@ -989,6 +1013,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
{
|
||||
runtimeConfig: {
|
||||
...(agent.runtimeConfig ?? {}),
|
||||
enabled: true,
|
||||
heartbeatIntervalMs: MIN_HEARTBEAT_INTERVAL_MS,
|
||||
},
|
||||
},
|
||||
@@ -1019,6 +1044,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
{
|
||||
runtimeConfig: {
|
||||
...(agent.runtimeConfig ?? {}),
|
||||
enabled: true,
|
||||
heartbeatIntervalMs: intervalMs,
|
||||
},
|
||||
},
|
||||
@@ -1855,6 +1881,14 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const stateCardClass = getStateCardClass("agent-card", agent.state);
|
||||
const configuredIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs);
|
||||
const heartbeatOptions = getHeartbeatIntervalOptions(configuredIntervalMs);
|
||||
/*
|
||||
* FNXC:AgentHeartbeatControls 2026-07-23-12:43:
|
||||
* The list-card select is the scheduling control: an explicit false wins over its saved cadence,
|
||||
* while absent enabled remains backwards-compatible as enabled. Interval changes always persist
|
||||
* enabled: true; disabling retains the complete runtime configuration and saved cadence.
|
||||
*/
|
||||
const isHeartbeatDisabled = agent.runtimeConfig?.enabled === false;
|
||||
const heartbeatSelectValue = isHeartbeatDisabled ? HEARTBEAT_DISABLED_OPTION_VALUE : String(configuredIntervalMs);
|
||||
const isUpdatingHeartbeat = updatingHeartbeatAgentId === agent.id;
|
||||
const modelLabel = getAgentModelLabel(agent);
|
||||
return (
|
||||
@@ -2056,10 +2090,12 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<>
|
||||
<select
|
||||
className="select agent-heartbeat-select"
|
||||
value={configuredIntervalMs}
|
||||
value={heartbeatSelectValue}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "__custom__") {
|
||||
if (value === HEARTBEAT_DISABLED_OPTION_VALUE) {
|
||||
void handleHeartbeatDisabled(agent);
|
||||
} else if (value === "__custom__") {
|
||||
handleSelectCustomHeartbeat(agent);
|
||||
} else {
|
||||
void handleHeartbeatIntervalChange(agent, Number(value));
|
||||
@@ -2068,6 +2104,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
disabled={isUpdatingHeartbeat}
|
||||
aria-label={t("agents.setHeartbeatAria", "Set heartbeat interval for {{name}}", { name: agent.name })}
|
||||
>
|
||||
<option value={HEARTBEAT_DISABLED_OPTION_VALUE}>{t("agents.heartbeatDisabledOption", "Disabled")}</option>
|
||||
{heartbeatOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
||||
@@ -1128,6 +1128,121 @@ describe("AgentsView", () => {
|
||||
expect(intervalSelect.options[intervalSelect.selectedIndex]?.text).toBe("1h");
|
||||
});
|
||||
|
||||
it("maps persisted heartbeat enablement to the dropdown across desktop and mobile", async () => {
|
||||
const heartbeatAgents: Agent[] = [
|
||||
{ ...mockAgents[1], id: "agent-disabled", name: "Disabled Agent", runtimeConfig: { enabled: false, heartbeatIntervalMs: 900_000 } },
|
||||
{ ...mockAgents[1], id: "agent-enabled", name: "Enabled Agent", runtimeConfig: { enabled: true, heartbeatIntervalMs: 900_000 } },
|
||||
{ ...mockAgents[1], id: "agent-legacy", name: "Legacy Agent", runtimeConfig: { heartbeatIntervalMs: 900_000 } },
|
||||
{ ...mockAgents[1], id: "agent-default", name: "Default Agent", runtimeConfig: undefined },
|
||||
];
|
||||
mockFetchAgents.mockResolvedValue(heartbeatAgents);
|
||||
mockFetchAgentStats.mockResolvedValue({ total: heartbeatAgents.length, byState: {}, byRole: {} });
|
||||
mockViewportMode.mockReturnValue("mobile");
|
||||
|
||||
renderView(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText("Set heartbeat interval for Disabled Agent") as HTMLSelectElement).value).toBe("__disabled__");
|
||||
});
|
||||
for (const name of ["Enabled Agent", "Legacy Agent"]) {
|
||||
const select = screen.getByLabelText(`Set heartbeat interval for ${name}`) as HTMLSelectElement;
|
||||
expect(select.value).toBe("900000");
|
||||
expect(select.options[select.selectedIndex]?.text).toBe("15m");
|
||||
}
|
||||
expect((screen.getByLabelText("Set heartbeat interval for Default Agent") as HTMLSelectElement).value).toBe("3600000");
|
||||
});
|
||||
|
||||
it("preserves runtime config while disabling and re-enabling heartbeat intervals", async () => {
|
||||
const disabledAgent: Agent = {
|
||||
...mockAgents[1],
|
||||
id: "agent-disabled",
|
||||
name: "Disabled Agent",
|
||||
runtimeConfig: {
|
||||
enabled: false,
|
||||
heartbeatIntervalMs: 900_000,
|
||||
heartbeatTimeoutMs: 120_000,
|
||||
maxConcurrentRuns: 3,
|
||||
messageResponseMode: "on-heartbeat",
|
||||
},
|
||||
};
|
||||
mockFetchAgents
|
||||
.mockResolvedValueOnce([disabledAgent])
|
||||
.mockResolvedValueOnce([disabledAgent])
|
||||
.mockResolvedValueOnce([{ ...disabledAgent, runtimeConfig: { ...disabledAgent.runtimeConfig, enabled: true, heartbeatIntervalMs: 1_800_000 } }])
|
||||
.mockResolvedValueOnce([{ ...disabledAgent, runtimeConfig: { ...disabledAgent.runtimeConfig, enabled: false, heartbeatIntervalMs: 1_800_000 } }]);
|
||||
mockFetchAgentStats.mockResolvedValue({ total: 1, byState: {}, byRole: {} });
|
||||
|
||||
renderView(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
const select = await screen.findByLabelText("Set heartbeat interval for Disabled Agent") as HTMLSelectElement;
|
||||
expect(select.value).toBe("__disabled__");
|
||||
expect(Array.from(select.options).map((option) => option.text)).toContain("Disabled");
|
||||
|
||||
fireEvent.change(select, { target: { value: "__disabled__" } });
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenLastCalledWith(
|
||||
"agent-disabled",
|
||||
{
|
||||
runtimeConfig: {
|
||||
enabled: false,
|
||||
heartbeatIntervalMs: 900_000,
|
||||
heartbeatTimeoutMs: 120_000,
|
||||
maxConcurrentRuns: 3,
|
||||
messageResponseMode: "on-heartbeat",
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.change(select, { target: { value: "1800000" } });
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenLastCalledWith(
|
||||
"agent-disabled",
|
||||
{
|
||||
runtimeConfig: {
|
||||
enabled: true,
|
||||
heartbeatIntervalMs: 1_800_000,
|
||||
heartbeatTimeoutMs: 120_000,
|
||||
maxConcurrentRuns: 3,
|
||||
messageResponseMode: "on-heartbeat",
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText("Set heartbeat interval for Disabled Agent") as HTMLSelectElement).value).toBe("1800000");
|
||||
});
|
||||
|
||||
const refreshedSelect = screen.getByLabelText("Set heartbeat interval for Disabled Agent");
|
||||
fireEvent.change(refreshedSelect, { target: { value: "__disabled__" } });
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText("Set heartbeat interval for Disabled Agent") as HTMLSelectElement).value).toBe("__disabled__");
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Set heartbeat interval for Disabled Agent"), { target: { value: "__custom__" } });
|
||||
const customInput = await screen.findByLabelText("Custom heartbeat interval in minutes for Disabled Agent");
|
||||
fireEvent.change(customInput, { target: { value: "7" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenLastCalledWith(
|
||||
"agent-disabled",
|
||||
{
|
||||
runtimeConfig: {
|
||||
enabled: true,
|
||||
heartbeatIntervalMs: 420_000,
|
||||
heartbeatTimeoutMs: 120_000,
|
||||
maxConcurrentRuns: 3,
|
||||
messageResponseMode: "on-heartbeat",
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("updates agent heartbeat interval from preset dropdown", async () => {
|
||||
renderView(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user