feat(FN-1049): add per-agent heartbeat configuration via runtimeConfig

- Define AgentHeartbeatConfig interface in core types (heartbeatIntervalMs, heartbeatTimeoutMs, maxConcurrentRuns)
- Update HeartbeatMonitor to resolve per-agent config from AgentStore with validated min/max clamping
- Wire AgentStore into HeartbeatMonitor via InProcessRuntime initialization
- Add heartbeat settings section to dashboard AgentDetailView ConfigTab
- Add PATCH /api/agents/:id endpoint accepting runtimeConfig updates
- Add comprehensive tests for per-agent heartbeat config resolution and validation
- Document per-agent heartbeat configuration in AGENTS.md
This commit is contained in:
gsxdsm
2026-04-07 11:50:17 -07:00
parent 39f4f4929f
commit 825ae1061b
10 changed files with 579 additions and 40 deletions

View File

@@ -202,7 +202,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
}
const lastHeartbeat = new Date(agent.lastHeartbeatAt).getTime();
const elapsed = Date.now() - lastHeartbeat;
const timeoutMs = 60000;
const timeoutMs = (agent as any).runtimeConfig?.heartbeatTimeoutMs ?? 60000;
if (elapsed > timeoutMs) {
return { label: "Unresponsive", color: "var(--state-error-text, #f85149)" };
}
@@ -767,15 +767,6 @@ interface AdvancedSettingField {
/** Well-known advanced setting definitions backed by agent.metadata */
const ADVANCED_SETTINGS: AdvancedSettingField[] = [
{
key: "heartbeatIntervalMs",
label: "Heartbeat Interval (ms)",
type: "number",
placeholder: "30000",
hint: "How often the agent sends heartbeats (minimum 1000ms, default 30000ms)",
min: 1000,
max: 600000,
},
{
key: "maxRetries",
label: "Max Retries",
@@ -870,6 +861,19 @@ function ConfigTab({
return initial;
});
// Heartbeat config state initialised from agent.runtimeConfig
const [heartbeatValues, setHeartbeatValues] = useState<Record<string, string>>(() => {
const rc = agent.runtimeConfig ?? {};
const initial: Record<string, string> = {};
if (rc.heartbeatIntervalMs !== undefined && rc.heartbeatIntervalMs !== null) {
initial.heartbeatIntervalMs = String(rc.heartbeatIntervalMs);
}
if (rc.heartbeatTimeoutMs !== undefined && rc.heartbeatTimeoutMs !== null) {
initial.heartbeatTimeoutMs = String(rc.heartbeatTimeoutMs);
}
return initial;
});
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<ValidationErrors>({});
const [justSaved, setJustSaved] = useState(false);
@@ -883,6 +887,13 @@ function ConfigTab({
: "";
if (current !== persisted) return true;
}
// Check heartbeat values
const rc = agent.runtimeConfig ?? {};
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs"] as const) {
const current = heartbeatValues[key]?.trim() ?? "";
const persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
if (current !== persisted) return true;
}
return false;
})();
@@ -899,9 +910,37 @@ function ConfigTab({
}
};
const handleHeartbeatFieldChange = (key: string, value: string) => {
setHeartbeatValues((prev) => ({ ...prev, [key]: value }));
setJustSaved(false);
if (errors[key]) {
setErrors((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
};
const handleSave = async () => {
// Validate before save
// Validate advanced settings
const validationErrors = validateAdvancedSettings(formValues);
// Validate heartbeat settings
for (const [key, config] of Object.entries({
heartbeatIntervalMs: { label: "Heartbeat Interval", min: 1000 },
heartbeatTimeoutMs: { label: "Heartbeat Timeout", min: 5000 },
})) {
const raw = heartbeatValues[key]?.trim();
if (!raw) continue;
const num = Number(raw);
if (Number.isNaN(num) || !Number.isFinite(num)) {
validationErrors[key] = `"${config.label}" must be a valid number`;
} else if (num < config.min) {
validationErrors[key] = `"${config.label}" must be at least ${config.min.toLocaleString()}`;
}
}
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
addToast("Please fix validation errors before saving", "error");
@@ -922,10 +961,21 @@ function ConfigTab({
}
}
// Build the runtimeConfig payload — only include non-empty values
const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig };
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs"] as const) {
const raw = heartbeatValues[key]?.trim();
if (!raw) {
delete newRuntimeConfig[key];
} else {
newRuntimeConfig[key] = Number(raw);
}
}
setIsSaving(true);
try {
await updateAgent(agent.id, { metadata: newMetadata }, projectId);
addToast("Advanced settings saved", "success");
await updateAgent(agent.id, { metadata: newMetadata, runtimeConfig: newRuntimeConfig }, projectId);
addToast("Settings saved", "success");
setJustSaved(true);
// Auto-hide the saved indicator after 3 seconds
setTimeout(() => setJustSaved(false), 3000);
@@ -972,6 +1022,51 @@ function ConfigTab({
</div>
</div>
<div className="config-section">
<h3>Heartbeat Settings</h3>
<p className="config-description">
Configure how this agent's heartbeat is monitored. Leave a field empty to use system defaults.
</p>
<div className="config-fields">
<div className="config-field">
<label htmlFor="hb-heartbeatIntervalMs">Heartbeat Interval (ms)</label>
<input
id="hb-heartbeatIntervalMs"
type="text"
inputMode="numeric"
className={cn("input", !!errors.heartbeatIntervalMs && "input--error")}
placeholder="30000"
value={heartbeatValues.heartbeatIntervalMs ?? ""}
onChange={(e) => handleHeartbeatFieldChange("heartbeatIntervalMs", e.target.value)}
/>
{errors.heartbeatIntervalMs ? (
<span className="config-error">{errors.heartbeatIntervalMs}</span>
) : (
<span className="config-hint">How often heartbeats are checked. Leave empty for system default (30000ms)</span>
)}
</div>
<div className="config-field">
<label htmlFor="hb-heartbeatTimeoutMs">Heartbeat Timeout (ms)</label>
<input
id="hb-heartbeatTimeoutMs"
type="text"
inputMode="numeric"
className={cn("input", !!errors.heartbeatTimeoutMs && "input--error")}
placeholder="60000"
value={heartbeatValues.heartbeatTimeoutMs ?? ""}
onChange={(e) => handleHeartbeatFieldChange("heartbeatTimeoutMs", e.target.value)}
/>
{errors.heartbeatTimeoutMs ? (
<span className="config-error">{errors.heartbeatTimeoutMs}</span>
) : (
<span className="config-hint">Time without heartbeat before agent is considered unresponsive. Leave empty for system default (60000ms)</span>
)}
</div>
</div>
</div>
<div className="config-section">
<h3>Advanced Settings</h3>
<p className="config-description">

View File

@@ -27,6 +27,7 @@ describe("AgentDetailView", () => {
role: AgentCapability;
state: "idle" | "active" | "paused" | "terminated";
taskId?: string;
runtimeConfig?: Record<string, unknown>;
}> = {}): AgentDetail => ({
id: "agent-001",
name: "Test Agent",
@@ -37,6 +38,7 @@ describe("AgentDetailView", () => {
updatedAt: "2024-01-01T00:00:00.000Z",
lastHeartbeatAt: "2024-01-01T00:05:00.000Z",
metadata: {},
runtimeConfig: overrides.runtimeConfig,
heartbeatHistory: [],
activeRun: {
id: "run-001",
@@ -455,14 +457,17 @@ describe("AgentDetailView", () => {
await navigateToSettings(user);
await waitFor(() => {
// Heartbeat Settings section
expect(screen.getByLabelText("Heartbeat Interval (ms)")).toBeInTheDocument();
expect(screen.getByLabelText("Heartbeat Timeout (ms)")).toBeInTheDocument();
// Advanced Settings section
expect(screen.getByLabelText("Max Retries")).toBeInTheDocument();
expect(screen.getByLabelText("Task Timeout (ms)")).toBeInTheDocument();
expect(screen.getByLabelText("Log Level")).toBeInTheDocument();
});
});
it("shows empty fields when metadata has no advanced settings", async () => {
it("shows empty fields when metadata and runtimeConfig are empty", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));
const user = userEvent.setup();
@@ -482,10 +487,13 @@ describe("AgentDetailView", () => {
});
});
it("pre-fills fields from agent metadata", async () => {
it("pre-fills heartbeat fields from agent runtimeConfig", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
metadata: {
runtimeConfig: {
heartbeatIntervalMs: 15000,
heartbeatTimeoutMs: 120000,
},
metadata: {
maxRetries: 5,
logLevel: "debug",
},
@@ -506,6 +514,9 @@ describe("AgentDetailView", () => {
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (ms)") as HTMLInputElement;
expect(heartbeatInput.value).toBe("15000");
const heartbeatTimeoutInput = screen.getByLabelText("Heartbeat Timeout (ms)") as HTMLInputElement;
expect(heartbeatTimeoutInput.value).toBe("120000");
const retriesInput = screen.getByLabelText("Max Retries") as HTMLInputElement;
expect(retriesInput.value).toBe("5");
@@ -595,15 +606,15 @@ describe("AgentDetailView", () => {
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
const heartbeatTimeoutInput = await screen.findByLabelText("Heartbeat Timeout (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "500");
await user.clear(heartbeatTimeoutInput);
await user.type(heartbeatTimeoutInput, "500");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/must be at least 1,000/)).toBeInTheDocument();
expect(screen.getByText(/must be at least 5,000/)).toBeInTheDocument();
});
});
@@ -631,7 +642,7 @@ describe("AgentDetailView", () => {
});
});
it("calls updateAgent with correct metadata on save", async () => {
it("calls updateAgent with correct metadata and runtimeConfig on save", async () => {
const addToast = vi.fn();
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
@@ -656,12 +667,15 @@ describe("AgentDetailView", () => {
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
{ metadata: expect.objectContaining({ heartbeatIntervalMs: 15000 }) },
expect.objectContaining({
metadata: expect.any(Object),
runtimeConfig: expect.objectContaining({ heartbeatIntervalMs: 15000 }),
}),
undefined,
);
});
expect(addToast).toHaveBeenCalledWith("Advanced settings saved", "success");
expect(addToast).toHaveBeenCalledWith("Settings saved", "success");
});
it("forwards projectId to updateAgent", async () => {
@@ -799,9 +813,9 @@ describe("AgentDetailView", () => {
expect((logLevelSelect as HTMLSelectElement).value).toBe("debug");
});
it("clears metadata key when field is cleared to empty", async () => {
it("clears runtimeConfig key when heartbeat field is cleared to empty", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
metadata: { heartbeatIntervalMs: 30000 },
runtimeConfig: { heartbeatIntervalMs: 30000 },
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
@@ -827,16 +841,17 @@ describe("AgentDetailView", () => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
metadata: expect.not.objectContaining({ heartbeatIntervalMs: expect.anything() }),
runtimeConfig: expect.not.objectContaining({ heartbeatIntervalMs: expect.anything() }),
}),
undefined,
);
});
});
it("persists existing non-advanced metadata keys during save", async () => {
it("persists existing non-advanced metadata keys and runtimeConfig during save", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
metadata: { customKey: "preserved", heartbeatIntervalMs: 30000 },
metadata: { customKey: "preserved" },
runtimeConfig: { heartbeatIntervalMs: 30000, otherConfig: "also-preserved" },
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
@@ -860,9 +875,10 @@ describe("AgentDetailView", () => {
await waitFor(() => {
const call = mockUpdateAgent.mock.calls[0];
const metadata = (call as any)[1].metadata;
expect(metadata.customKey).toBe("preserved");
expect(metadata.heartbeatIntervalMs).toBe(45000);
const payload = (call as any)[1];
expect(payload.metadata.customKey).toBe("preserved");
expect(payload.runtimeConfig.heartbeatIntervalMs).toBe(45000);
expect(payload.runtimeConfig.otherConfig).toBe("also-preserved");
});
});
});

View File

@@ -7004,14 +7004,14 @@ Output ONLY the prompt text (no markdown, no explanations).`;
*/
router.patch("/agents/:id", async (req, res) => {
try {
const { name, role, metadata } = req.body;
const { name, role, metadata, runtimeConfig } = req.body;
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agent = await agentStore.updateAgent(req.params.id, { name, role, metadata });
const agent = await agentStore.updateAgent(req.params.id, { name, role, metadata, runtimeConfig });
res.json(agent);
} catch (err: any) {
if (err.message?.includes("not found")) {