fix(FN-2251): default durable agent heartbeats on

This commit is contained in:
gsxdsm
2026-04-23 09:42:54 -07:00
parent e655277bf4
commit 29056b412d
4 changed files with 184 additions and 1 deletions

View File

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

View File

@@ -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.
*