fix(FN-2251): default durable agent heartbeats on
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { AgentStore } from "./agent-store.js";
|
||||
import { Database } from "./db.js";
|
||||
import { TaskStore } from "./store.js";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -99,6 +100,35 @@ describe("AgentStore", () => {
|
||||
await rm(legacyRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes legacy durable agents to heartbeat enabled once", async () => {
|
||||
const agent = await store.createAgent({
|
||||
name: "Legacy Durable Agent",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
await store.updateAgent(agent.id, {
|
||||
runtimeConfig: {
|
||||
...(agent.runtimeConfig ?? {}),
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const db = new Database(rootDir);
|
||||
db.init();
|
||||
db.prepare(`
|
||||
INSERT INTO __meta (key, value)
|
||||
VALUES ('agentHeartbeatDefaultVersion', '0')
|
||||
ON CONFLICT(key) DO UPDATE SET value = '0'
|
||||
`).run();
|
||||
|
||||
store.close();
|
||||
store = new AgentStore({ rootDir });
|
||||
await store.init();
|
||||
|
||||
const migrated = await store.getAgent(agent.id);
|
||||
expect((migrated?.runtimeConfig as Record<string, unknown> | undefined)?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createAgent ───────────────────────────────────────────────────
|
||||
@@ -115,6 +145,9 @@ describe("AgentStore", () => {
|
||||
expect(agent.role).toBe("executor");
|
||||
expect(agent.state).toBe("idle");
|
||||
expect(agent.metadata).toEqual({});
|
||||
expect(agent.runtimeConfig).toMatchObject({
|
||||
enabled: true,
|
||||
});
|
||||
expect(new Date(agent.createdAt).getTime()).not.toBeNaN();
|
||||
expect(new Date(agent.updatedAt).getTime()).not.toBeNaN();
|
||||
});
|
||||
@@ -816,6 +849,7 @@ describe("AgentStore", () => {
|
||||
// agents, so the rollback target config includes that field alongside
|
||||
// whatever the caller supplied.
|
||||
expect(result.agent.runtimeConfig).toEqual({
|
||||
enabled: true,
|
||||
heartbeatTimeoutMs: 60000,
|
||||
heartbeatIntervalMs: 3_600_000,
|
||||
});
|
||||
|
||||
@@ -150,6 +150,9 @@ function resolveCreationRuntimeConfig(
|
||||
return incoming;
|
||||
}
|
||||
const rc: Record<string, unknown> = { ...(incoming ?? {}) };
|
||||
if (typeof rc.enabled !== "boolean") {
|
||||
rc.enabled = true;
|
||||
}
|
||||
if (typeof rc.heartbeatIntervalMs !== "number" || !Number.isFinite(rc.heartbeatIntervalMs)) {
|
||||
rc.heartbeatIntervalMs = DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS;
|
||||
}
|
||||
@@ -197,6 +200,7 @@ export class AgentStore extends EventEmitter {
|
||||
const _ = this.db;
|
||||
await mkdir(this.agentsDir, { recursive: true });
|
||||
await this.importLegacyFileDataOnce();
|
||||
await this.normalizeHeartbeatDefaultsOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,6 +332,60 @@ export class AgentStore extends EventEmitter {
|
||||
this.db.bumpLastModified();
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time normalization for durable agents created before the heartbeat
|
||||
* toggle was exposed in the UI. Those agents could persist
|
||||
* `runtimeConfig.enabled = false` even though users had no supported way to
|
||||
* manage that flag, which caused timers to stay disabled after restart.
|
||||
*
|
||||
* We normalize only once per project. After this migration lands, explicit
|
||||
* user choices are preserved because the version gate prevents reruns.
|
||||
*/
|
||||
private async normalizeHeartbeatDefaultsOnce(): Promise<void> {
|
||||
const migrationKey = "agentHeartbeatDefaultVersion";
|
||||
const migrationVersion = "1";
|
||||
const row = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(migrationKey) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
if (row?.value === migrationVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agents = await this.listAgents({ includeEphemeral: true });
|
||||
let changed = 0;
|
||||
|
||||
for (const agent of agents) {
|
||||
if (isEphemeralAgent(agent)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextRuntimeConfig = {
|
||||
...(resolveCreationRuntimeConfig(agent.runtimeConfig, agent.metadata) ?? {}),
|
||||
enabled: true,
|
||||
};
|
||||
const currentRuntimeConfig = agent.runtimeConfig ?? undefined;
|
||||
if (JSON.stringify(nextRuntimeConfig) === JSON.stringify(currentRuntimeConfig)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.writeAgent({
|
||||
...agent,
|
||||
runtimeConfig: nextRuntimeConfig,
|
||||
});
|
||||
changed++;
|
||||
}
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO __meta (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`).run(migrationKey, migrationVersion);
|
||||
|
||||
if (changed > 0) {
|
||||
this.db.bumpLastModified();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new agent with "idle" state.
|
||||
*
|
||||
|
||||
@@ -2657,6 +2657,10 @@ function deriveHeartbeatValues(runtimeConfig: AgentDetail["runtimeConfig"] | und
|
||||
return nextValues;
|
||||
}
|
||||
|
||||
function deriveHeartbeatEnabled(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean {
|
||||
return runtimeConfig?.enabled !== false;
|
||||
}
|
||||
|
||||
function deriveBudgetValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): Record<string, string> {
|
||||
const bc = (runtimeConfig ?? {}).budgetConfig as Record<string, unknown> | undefined;
|
||||
const nextValues: Record<string, string> = {};
|
||||
@@ -2718,6 +2722,9 @@ function ConfigTab({
|
||||
const [heartbeatValues, setHeartbeatValues] = useState<Record<string, string>>(
|
||||
() => deriveHeartbeatValues(agent.runtimeConfig),
|
||||
);
|
||||
const [heartbeatEnabled, setHeartbeatEnabled] = useState<boolean>(
|
||||
() => deriveHeartbeatEnabled(agent.runtimeConfig),
|
||||
);
|
||||
|
||||
// Budget config state initialised from agent.runtimeConfig.budgetConfig
|
||||
const [budgetValues, setBudgetValues] = useState<Record<string, string>>(
|
||||
@@ -2826,6 +2833,7 @@ function ConfigTab({
|
||||
}
|
||||
// Check heartbeat values
|
||||
const rc = agent.runtimeConfig ?? {};
|
||||
if (heartbeatEnabled !== deriveHeartbeatEnabled(agent.runtimeConfig)) return true;
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "messageResponseMode"] as const) {
|
||||
const current = heartbeatValues[key]?.trim() ?? "";
|
||||
let persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
|
||||
@@ -2896,6 +2904,7 @@ function ConfigTab({
|
||||
|
||||
previousAgentRuntimeSyncRef.current = nextSnapshot;
|
||||
setHeartbeatValues(deriveHeartbeatValues(agent.runtimeConfig));
|
||||
setHeartbeatEnabled(deriveHeartbeatEnabled(agent.runtimeConfig));
|
||||
setBudgetValues(deriveBudgetValues(agent.runtimeConfig));
|
||||
}, [agent, hasChanges]);
|
||||
|
||||
@@ -2924,6 +2933,11 @@ function ConfigTab({
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeartbeatEnabledChange = (enabled: boolean) => {
|
||||
setHeartbeatEnabled(enabled);
|
||||
setJustSaved(false);
|
||||
};
|
||||
|
||||
const handleBudgetFieldChange = (key: string, value: string) => {
|
||||
setBudgetValues((prev) => ({ ...prev, [key]: value }));
|
||||
setJustSaved(false);
|
||||
@@ -3033,6 +3047,7 @@ function ConfigTab({
|
||||
|
||||
// Build the runtimeConfig payload — only include non-empty values
|
||||
const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig };
|
||||
newRuntimeConfig.enabled = heartbeatEnabled;
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns"] as const) {
|
||||
const raw = heartbeatValues[key]?.trim();
|
||||
if (!raw) {
|
||||
@@ -3253,6 +3268,19 @@ function ConfigTab({
|
||||
</p>
|
||||
|
||||
<div className="config-fields">
|
||||
<div className="config-field">
|
||||
<label className="checkbox-label" htmlFor="hb-enabled">
|
||||
<input
|
||||
id="hb-enabled"
|
||||
type="checkbox"
|
||||
checked={heartbeatEnabled}
|
||||
onChange={(e) => handleHeartbeatEnabledChange(e.target.checked)}
|
||||
/>
|
||||
Heartbeat Enabled
|
||||
</label>
|
||||
<span className="config-hint">When enabled, this agent receives scheduled heartbeat runs based on its interval.</span>
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="hb-heartbeatIntervalMs">Heartbeat Interval (s)</label>
|
||||
<input
|
||||
|
||||
@@ -1147,6 +1147,7 @@ describe("AgentDetailView", () => {
|
||||
it("pre-fills heartbeat fields from agent runtimeConfig", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
runtimeConfig: {
|
||||
enabled: false,
|
||||
heartbeatIntervalMs: 15000,
|
||||
heartbeatTimeoutMs: 120000,
|
||||
},
|
||||
@@ -1168,6 +1169,9 @@ describe("AgentDetailView", () => {
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
const heartbeatEnabledInput = screen.getByLabelText("Heartbeat Enabled") as HTMLInputElement;
|
||||
expect(heartbeatEnabledInput.checked).toBe(false);
|
||||
|
||||
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (s)") as HTMLInputElement;
|
||||
expect(heartbeatInput.value).toBe("15");
|
||||
|
||||
@@ -1182,6 +1186,29 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults heartbeat toggle to enabled when runtimeConfig.enabled is missing", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
runtimeConfig: {
|
||||
heartbeatIntervalMs: 30000,
|
||||
},
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText("Heartbeat Enabled") as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Save Settings button disabled when no changes", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));
|
||||
|
||||
@@ -1362,6 +1389,41 @@ describe("AgentDetailView", () => {
|
||||
expect(addToast).toHaveBeenCalledWith("Settings saved", "success");
|
||||
});
|
||||
|
||||
it("persists heartbeat enabled toggle changes on save", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
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 heartbeatEnabledInput = await screen.findByLabelText("Heartbeat Enabled");
|
||||
await user.click(heartbeatEnabledInput);
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenCalledWith(
|
||||
"agent-001",
|
||||
expect.objectContaining({
|
||||
runtimeConfig: expect.objectContaining({ enabled: false, heartbeatIntervalMs: 30000 }),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards projectId to updateAgent", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
||||
@@ -1535,7 +1597,7 @@ describe("AgentDetailView", () => {
|
||||
it("persists existing non-advanced metadata keys and runtimeConfig during save", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
metadata: { customKey: "preserved" },
|
||||
runtimeConfig: { heartbeatIntervalMs: 30000, otherConfig: "also-preserved" },
|
||||
runtimeConfig: { enabled: true, heartbeatIntervalMs: 30000, otherConfig: "also-preserved" },
|
||||
}));
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
||||
|
||||
@@ -1561,6 +1623,7 @@ describe("AgentDetailView", () => {
|
||||
const call = mockUpdateAgent.mock.calls[0];
|
||||
const payload = (call as any)[1];
|
||||
expect(payload.metadata.customKey).toBe("preserved");
|
||||
expect(payload.runtimeConfig.enabled).toBe(true);
|
||||
expect(payload.runtimeConfig.heartbeatIntervalMs).toBe(45000);
|
||||
expect(payload.runtimeConfig.otherConfig).toBe("also-preserved");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user