fix(FN-2040): prevent false unresponsive agent states
- Apply a default 30s heartbeat interval when scheduler config is missing or invalid - Dynamically register and update heartbeat triggers from AgentStore create/update events - Register all non-disabled agents at startup and guard listener cleanup when stopping runtime - Treat non-periodic agents as healthy in dashboard health checks to avoid stale-time false alarms - Expand engine and dashboard tests and add a patch changeset for @gsxdsm/fusion
This commit is contained in:
@@ -3340,14 +3340,66 @@ describe("HeartbeatTriggerScheduler", () => {
|
||||
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
|
||||
});
|
||||
|
||||
it("skips registration when intervalMs is undefined", () => {
|
||||
it("applies default 30-second interval when intervalMs is undefined", async () => {
|
||||
vi.useFakeTimers();
|
||||
scheduler.registerAgent("agent-001", {});
|
||||
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Verify the default 30-second interval fires
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("skips registration when intervalMs is 0", () => {
|
||||
it("applies default 30-second interval when intervalMs is 0", async () => {
|
||||
vi.useFakeTimers();
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 0 });
|
||||
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Verify the default 30-second interval fires
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("applies default 30-second interval when heartbeatIntervalMs is not set", async () => {
|
||||
vi.useFakeTimers();
|
||||
scheduler.registerAgent("agent-001", { enabled: true });
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Should fire at exactly 30 seconds (default interval)
|
||||
await vi.advanceTimersByTimeAsync(29_999);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1); // Now at exactly 30 seconds
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
|
||||
wakeReason: "timer",
|
||||
triggerDetail: "scheduled",
|
||||
intervalMs: 30_000,
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses explicit interval over default when both are provided", async () => {
|
||||
vi.useFakeTimers();
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 15_000, enabled: true });
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Should fire at 15 seconds (explicit), not 30
|
||||
await vi.advanceTimersByTimeAsync(14_999);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1); // Now at exactly 15 seconds
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
|
||||
wakeReason: "timer",
|
||||
triggerDetail: "scheduled",
|
||||
intervalMs: 15_000,
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("clears previous timer when re-registering", () => {
|
||||
|
||||
@@ -1459,6 +1459,9 @@ export class HeartbeatTriggerScheduler {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
/** Default heartbeat interval when not explicitly configured (30 seconds) */
|
||||
private static readonly DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Register an agent for timer-based heartbeat triggers.
|
||||
* @param agentId - The agent ID
|
||||
@@ -1471,11 +1474,14 @@ export class HeartbeatTriggerScheduler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if no interval configured
|
||||
const rawIntervalMs = config.heartbeatIntervalMs;
|
||||
// Apply default interval if not explicitly configured
|
||||
// This ensures agents with heartbeat monitoring enabled but no explicit interval
|
||||
// still get periodic timer triggers (matching HeartbeatMonitor constructor default)
|
||||
let rawIntervalMs = config.heartbeatIntervalMs;
|
||||
let usingDefaultInterval = false;
|
||||
if (!rawIntervalMs || typeof rawIntervalMs !== "number" || !Number.isFinite(rawIntervalMs) || rawIntervalMs <= 0) {
|
||||
heartbeatLog.log(`Skipping timer registration for ${agentId} (no interval)`);
|
||||
return;
|
||||
rawIntervalMs = HeartbeatTriggerScheduler.DEFAULT_HEARTBEAT_INTERVAL_MS;
|
||||
usingDefaultInterval = true;
|
||||
}
|
||||
|
||||
const intervalMs = Math.max(1000, Math.round(rawIntervalMs));
|
||||
@@ -1488,7 +1494,11 @@ export class HeartbeatTriggerScheduler {
|
||||
}, intervalMs);
|
||||
|
||||
this.timers.set(agentId, { intervalMs, handle });
|
||||
heartbeatLog.log(`Registered timer for ${agentId} (every ${intervalMs}ms)`);
|
||||
heartbeatLog.log(
|
||||
usingDefaultInterval
|
||||
? `Registered timer for ${agentId} (every ${intervalMs}ms, default interval)`
|
||||
: `Registered timer for ${agentId} (every ${intervalMs}ms)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -684,4 +684,153 @@ describe("InProcessRuntime", () => {
|
||||
expect(MessageStore).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dynamic agent registration with HeartbeatTriggerScheduler", () => {
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
await runtime.start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await runtime.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("registers a new agent when agent:created event is emitted", async () => {
|
||||
// Create a new agent via the AgentStore
|
||||
const store = getAgentStore(runtime);
|
||||
const agent = await store.createAgent({
|
||||
name: "test-agent-dynamic",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
// Verify the agent was registered with the trigger scheduler
|
||||
const scheduler = runtime.getTriggerScheduler();
|
||||
expect(scheduler).toBeDefined();
|
||||
expect(scheduler!.getRegisteredAgents()).toContain(agent.id);
|
||||
});
|
||||
|
||||
it("registers agent without explicit heartbeatIntervalMs using default 30s interval", async () => {
|
||||
// Create a new agent with only enabled: true (no heartbeatIntervalMs)
|
||||
// This tests that the default 30-second interval is applied
|
||||
const store = getAgentStore(runtime);
|
||||
const agent = await store.createAgent({
|
||||
name: "test-agent-default-interval",
|
||||
role: "executor",
|
||||
runtimeConfig: { enabled: true }, // No heartbeatIntervalMs - should use default 30s
|
||||
});
|
||||
|
||||
// Verify the agent was registered with the trigger scheduler
|
||||
const scheduler = runtime.getTriggerScheduler();
|
||||
expect(scheduler).toBeDefined();
|
||||
expect(scheduler!.getRegisteredAgents()).toContain(agent.id);
|
||||
});
|
||||
|
||||
it("registers a new agent with explicit heartbeatIntervalMs", async () => {
|
||||
// Create a new agent with explicit heartbeat config
|
||||
const store = getAgentStore(runtime);
|
||||
const agent = await store.createAgent({
|
||||
name: "test-agent-explicit",
|
||||
role: "executor",
|
||||
runtimeConfig: {
|
||||
heartbeatIntervalMs: 15000,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify the agent was registered with the trigger scheduler
|
||||
const scheduler = runtime.getTriggerScheduler();
|
||||
expect(scheduler).toBeDefined();
|
||||
expect(scheduler!.getRegisteredAgents()).toContain(agent.id);
|
||||
});
|
||||
|
||||
it("does not register a new agent when enabled is false", async () => {
|
||||
// Create a new agent with heartbeat disabled
|
||||
const store = getAgentStore(runtime);
|
||||
const agent = await store.createAgent({
|
||||
name: "test-agent-disabled",
|
||||
role: "executor",
|
||||
runtimeConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify the agent was NOT registered with the trigger scheduler
|
||||
const scheduler = runtime.getTriggerScheduler();
|
||||
expect(scheduler).toBeDefined();
|
||||
expect(scheduler!.getRegisteredAgents()).not.toContain(agent.id);
|
||||
});
|
||||
|
||||
it("re-registers an existing agent when agent:updated event is emitted", async () => {
|
||||
// Create a new agent
|
||||
const store = getAgentStore(runtime);
|
||||
const agent = await store.createAgent({
|
||||
name: "test-agent-update",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
const scheduler = runtime.getTriggerScheduler();
|
||||
expect(scheduler!.getRegisteredAgents()).toContain(agent.id);
|
||||
|
||||
// Update the agent
|
||||
await store.updateAgent(agent.id, {
|
||||
name: "test-agent-update-renamed",
|
||||
});
|
||||
|
||||
// Verify the agent is still registered (re-registration succeeded)
|
||||
expect(scheduler!.getRegisteredAgents()).toContain(agent.id);
|
||||
});
|
||||
|
||||
it("unregisters an agent when enabled is set to false in update", async () => {
|
||||
// Create a new agent with heartbeat enabled
|
||||
const store = getAgentStore(runtime);
|
||||
const agent = await store.createAgent({
|
||||
name: "test-agent-toggle",
|
||||
role: "executor",
|
||||
runtimeConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const scheduler = runtime.getTriggerScheduler();
|
||||
expect(scheduler!.getRegisteredAgents()).toContain(agent.id);
|
||||
|
||||
// Update the agent to disable heartbeat
|
||||
await store.updateAgent(agent.id, {
|
||||
runtimeConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify the agent was unregistered
|
||||
expect(scheduler!.getRegisteredAgents()).not.toContain(agent.id);
|
||||
});
|
||||
|
||||
it("removes event listeners when runtime is stopped", async () => {
|
||||
// Create a new agent before stopping
|
||||
const store = getAgentStore(runtime);
|
||||
const agent = await store.createAgent({
|
||||
name: "test-agent-cleanup",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
const scheduler = runtime.getTriggerScheduler();
|
||||
expect(scheduler!.getRegisteredAgents()).toContain(agent.id);
|
||||
|
||||
// Stop the runtime
|
||||
await runtime.stop();
|
||||
|
||||
// The agent should still be registered (unregister is internal to scheduler)
|
||||
// But the listeners should be removed - verify by checking they don't fire
|
||||
// Create another agent - it won't be registered since runtime is stopped
|
||||
const agent2 = await store.createAgent({
|
||||
name: "test-agent-after-stop",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
// Since runtime is stopped, trigger scheduler is stopped
|
||||
// The agent won't be in registered list
|
||||
expect(scheduler!.getRegisteredAgents()).not.toContain(agent2.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,8 @@ export class InProcessRuntime
|
||||
private triageProcessor?: TriageProcessor;
|
||||
private messageStore?: MessageStore;
|
||||
private concurrencyChangedListener?: (state: { globalMaxConcurrent: number }) => void;
|
||||
private agentCreatedListener?: (agent: import("@fusion/core").Agent) => void;
|
||||
private agentUpdatedListener?: (agent: import("@fusion/core").Agent, previousState?: import("@fusion/core").AgentState) => void;
|
||||
|
||||
/**
|
||||
* @param config - Runtime configuration
|
||||
@@ -409,21 +411,55 @@ export class InProcessRuntime
|
||||
);
|
||||
this.triggerScheduler.start();
|
||||
|
||||
// Register existing agents that have heartbeat config
|
||||
// Set up dynamic registration for agents created or updated after startup
|
||||
this.agentCreatedListener = (agent) => {
|
||||
if (!this.triggerScheduler) return;
|
||||
const rc = agent.runtimeConfig;
|
||||
if (rc?.enabled === false) return;
|
||||
this.triggerScheduler.registerAgent(agent.id, {
|
||||
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
|
||||
enabled: rc?.enabled as boolean | undefined,
|
||||
maxConcurrentRuns: rc?.maxConcurrentRuns as number | undefined,
|
||||
});
|
||||
runtimeLog.log(`Registered new agent ${agent.id} for heartbeat triggers`);
|
||||
};
|
||||
this.agentStore.on("agent:created", this.agentCreatedListener);
|
||||
|
||||
this.agentUpdatedListener = (agent) => {
|
||||
if (!this.triggerScheduler) return;
|
||||
const rc = agent.runtimeConfig;
|
||||
if (rc?.enabled === false) {
|
||||
this.triggerScheduler.unregisterAgent(agent.id);
|
||||
runtimeLog.log(`Unregistered agent ${agent.id} from heartbeat triggers (disabled)`);
|
||||
} else {
|
||||
this.triggerScheduler.registerAgent(agent.id, {
|
||||
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
|
||||
enabled: rc?.enabled as boolean | undefined,
|
||||
maxConcurrentRuns: rc?.maxConcurrentRuns as number | undefined,
|
||||
});
|
||||
runtimeLog.log(`Re-registered agent ${agent.id} for heartbeat triggers`);
|
||||
}
|
||||
};
|
||||
this.agentStore.on("agent:updated", this.agentUpdatedListener);
|
||||
|
||||
// Register existing agents with heartbeat monitoring not explicitly disabled
|
||||
// Agents without explicit heartbeat config will use the default 30-second interval
|
||||
try {
|
||||
const agents = await this.agentStore.listAgents();
|
||||
let registeredCount = 0;
|
||||
for (const agent of agents) {
|
||||
const rc = agent.runtimeConfig;
|
||||
if (rc && (rc.heartbeatIntervalMs || rc.enabled !== undefined || rc.maxConcurrentRuns)) {
|
||||
if (rc?.enabled !== false) {
|
||||
this.triggerScheduler.registerAgent(agent.id, {
|
||||
heartbeatIntervalMs: rc.heartbeatIntervalMs as number | undefined,
|
||||
enabled: rc.enabled as boolean | undefined,
|
||||
maxConcurrentRuns: rc.maxConcurrentRuns as number | undefined,
|
||||
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
|
||||
enabled: rc?.enabled as boolean | undefined,
|
||||
maxConcurrentRuns: rc?.maxConcurrentRuns as number | undefined,
|
||||
});
|
||||
registeredCount++;
|
||||
}
|
||||
}
|
||||
if (agents.length > 0) {
|
||||
runtimeLog.log(`Registered ${this.triggerScheduler.getRegisteredAgents().length} agents for heartbeat triggers`);
|
||||
runtimeLog.log(`Registered ${registeredCount} of ${agents.length} agents for heartbeat triggers`);
|
||||
}
|
||||
} catch (regErr) {
|
||||
runtimeLog.warn(`Failed to register agents for heartbeat triggers:`, regErr);
|
||||
@@ -612,7 +648,20 @@ export class InProcessRuntime
|
||||
runtimeLog.log("RoutineScheduler stopped");
|
||||
}
|
||||
|
||||
// 3. Stop trigger scheduler
|
||||
// 3. Remove agent event listeners (before stopping trigger scheduler)
|
||||
// Guard on this.agentStore being defined - it may not exist if AgentStore init failed
|
||||
if (this.agentCreatedListener && this.agentStore) {
|
||||
this.agentStore.off("agent:created", this.agentCreatedListener);
|
||||
this.agentCreatedListener = undefined;
|
||||
runtimeLog.log("AgentStore agent:created listener removed");
|
||||
}
|
||||
if (this.agentUpdatedListener && this.agentStore) {
|
||||
this.agentStore.off("agent:updated", this.agentUpdatedListener);
|
||||
this.agentUpdatedListener = undefined;
|
||||
runtimeLog.log("AgentStore agent:updated listener removed");
|
||||
}
|
||||
|
||||
// 4. Stop trigger scheduler
|
||||
if (this.triggerScheduler) {
|
||||
this.triggerScheduler.stop();
|
||||
runtimeLog.log("TriggerScheduler stopped");
|
||||
|
||||
Reference in New Issue
Block a user