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:
gsxdsm
2026-07-23 13:04:17 -07:00
parent 2021d5679b
commit 47d2d176ef
4 changed files with 162 additions and 3 deletions

View 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.

View File

@@ -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" />

View File

@@ -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}

View File

@@ -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} />);