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:
@@ -228,29 +228,32 @@ describe("getAgentHealthStatus", () => {
|
||||
// ── Healthy vs Unresponsive ───────────────────────────────────────────────
|
||||
|
||||
describe("heartbeat freshness", () => {
|
||||
it('returns "Healthy" when heartbeat is fresh (within timeout)', () => {
|
||||
it('returns "Healthy" when heartbeat is fresh (within timeout) with periodic heartbeat', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString(), // 30s ago, well within 60s timeout
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000 }, // periodic heartbeat configured
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
it('returns "Healthy" when heartbeat is exactly at the timeout boundary', () => {
|
||||
it('returns "Healthy" when heartbeat is exactly at the timeout boundary with periodic heartbeat', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 60_000).toISOString(), // exactly 60s ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000 }, // periodic heartbeat configured
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it('returns "Unresponsive" when heartbeat exceeds the timeout', () => {
|
||||
it('returns "Unresponsive" when heartbeat exceeds the timeout with periodic heartbeat', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 60_001).toISOString(), // just over 60s ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000 }, // periodic heartbeat configured
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
@@ -262,7 +265,7 @@ describe("getAgentHealthStatus", () => {
|
||||
state: "active",
|
||||
// 90s ago - would be unresponsive with default 60s, but within 120s timeout
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 90_000).toISOString(),
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120_000 },
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 120_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
@@ -273,7 +276,73 @@ describe("getAgentHealthStatus", () => {
|
||||
state: "active",
|
||||
// 60s ago - would be healthy with default 60s, but exceeds 30s custom timeout
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 60_000).toISOString(),
|
||||
runtimeConfig: { heartbeatTimeoutMs: 30_000 },
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 30_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Non-periodic agents (no heartbeatIntervalMs) ────────────────────────────
|
||||
|
||||
describe("non-periodic agents (no heartbeatIntervalMs)", () => {
|
||||
it('returns "Healthy" for agent without heartbeatIntervalMs regardless of elapsed time', () => {
|
||||
// This is an event-driven agent - no timer-based heartbeats expected
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1_000_000).toISOString(), // very stale heartbeat
|
||||
runtimeConfig: { enabled: true }, // no heartbeatIntervalMs - event-driven
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
it('returns "Healthy" for agent with stale heartbeat when no heartbeatIntervalMs is set', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 60_001).toISOString(), // just over 60s ago
|
||||
runtimeConfig: {}, // empty runtimeConfig - no heartbeatIntervalMs
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it('returns "Healthy" for agent with heartbeatIntervalMs: 0 (invalid, treated as non-periodic)', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1_000_000).toISOString(), // very stale
|
||||
runtimeConfig: { heartbeatIntervalMs: 0 }, // 0 is invalid, treated as non-periodic
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it('returns "Healthy" for agent with heartbeatIntervalMs: -5000 (negative, treated as non-periodic)', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(), // stale
|
||||
runtimeConfig: { heartbeatIntervalMs: -5000 }, // negative is invalid
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it('returns "Healthy" for agent with heartbeatIntervalMs: undefined (non-periodic)', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 500_000).toISOString(), // very stale
|
||||
runtimeConfig: { heartbeatTimeoutMs: 60_000, heartbeatIntervalMs: undefined as unknown as number },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it('returns "Healthy" for periodic agent with heartbeatIntervalMs: 60000 and stale heartbeat shows "Unresponsive"', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 120_000).toISOString(), // 120s ago, exceeds 60s timeout
|
||||
runtimeConfig: { heartbeatIntervalMs: 60_000 }, // periodic with 60s interval
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
@@ -283,7 +352,7 @@ describe("getAgentHealthStatus", () => {
|
||||
// ── Per-agent timeout overrides ────────────────────────────────────────────
|
||||
|
||||
describe("per-agent timeout overrides", () => {
|
||||
it("uses default 60s timeout when no runtimeConfig", () => {
|
||||
it("returns 'Healthy' for non-periodic agent regardless of elapsed time (no heartbeatIntervalMs)", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 59_000).toISOString(), // 59s ago
|
||||
@@ -292,41 +361,41 @@ describe("getAgentHealthStatus", () => {
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("uses default 60s timeout when runtimeConfig exists but no heartbeatTimeoutMs", () => {
|
||||
it("returns 'Healthy' for agent with runtimeConfig but no heartbeatIntervalMs", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 59_000).toISOString(),
|
||||
runtimeConfig: { maxConcurrentRuns: 2 }, // has other config, but no timeout
|
||||
runtimeConfig: { maxConcurrentRuns: 2 }, // has other config, but no heartbeatIntervalMs
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("handles custom timeout of 30 seconds", () => {
|
||||
it("handles custom timeout of 30 seconds with periodic heartbeat", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 45_000).toISOString(), // 45s ago
|
||||
runtimeConfig: { heartbeatTimeoutMs: 30_000 },
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 30_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
});
|
||||
|
||||
it("handles custom timeout of 120 seconds", () => {
|
||||
it("handles custom timeout of 120 seconds with periodic heartbeat", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 90_000).toISOString(), // 90s ago
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120_000 },
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 120_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("handles very short timeout of 5 seconds", () => {
|
||||
it("handles very short timeout of 5 seconds with periodic heartbeat", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 6_000).toISOString(), // 6s ago
|
||||
runtimeConfig: { heartbeatTimeoutMs: 5_000 },
|
||||
runtimeConfig: { heartbeatIntervalMs: 10_000, heartbeatTimeoutMs: 5_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
@@ -356,24 +425,24 @@ describe("getAgentHealthStatus", () => {
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("treats runtimeConfig.enabled as true when undefined", () => {
|
||||
it("treats runtimeConfig.enabled as true when undefined (non-periodic)", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(), // stale
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120_000 }, // no enabled field
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120_000 }, // no heartbeatIntervalMs, so non-periodic
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy"); // monitoring is enabled by default
|
||||
expect(status.label).toBe("Healthy"); // non-periodic agents are always Healthy when they have heartbeat
|
||||
});
|
||||
|
||||
it("treats runtimeConfig.enabled === true as enabled", () => {
|
||||
it("treats runtimeConfig.enabled === true with periodic heartbeat", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(), // stale
|
||||
runtimeConfig: { enabled: true, heartbeatTimeoutMs: 120_000 },
|
||||
runtimeConfig: { enabled: true, heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 120_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.label).toBe("Healthy"); // within 120s timeout
|
||||
});
|
||||
|
||||
it("returns consistent icons for all states", () => {
|
||||
|
||||
@@ -46,6 +46,19 @@ function isHeartbeatEnabled(runtimeConfig?: Record<string, unknown>): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the agent has periodic heartbeat configuration.
|
||||
* An agent has periodic heartbeats if heartbeatIntervalMs is a positive number.
|
||||
* Agents with periodic heartbeat timers should show "Unresponsive" if no heartbeat
|
||||
* is received within the timeout window. Agents without periodic heartbeat (event-driven)
|
||||
* should not be marked "Unresponsive" based on elapsed time.
|
||||
*/
|
||||
function hasPeriodicHeartbeat(runtimeConfig?: Record<string, unknown>): boolean {
|
||||
if (!runtimeConfig) return false;
|
||||
const intervalMs = runtimeConfig.heartbeatIntervalMs;
|
||||
return typeof intervalMs === "number" && Number.isFinite(intervalMs) && intervalMs > 0;
|
||||
}
|
||||
|
||||
function isTaskWorkerAgent(agent: AgentHealthInput): boolean {
|
||||
const metadata = agent.metadata as Record<string, unknown> | null | undefined;
|
||||
if (metadata) {
|
||||
@@ -135,7 +148,19 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
};
|
||||
}
|
||||
|
||||
// Compute elapsed time since last heartbeat
|
||||
// For agents without periodic heartbeat configuration (event-driven agents),
|
||||
// return "Healthy" if they have a lastHeartbeatAt. These agents don't have
|
||||
// timer-based triggers, so absence of recent heartbeats is not a signal of
|
||||
// unresponsiveness.
|
||||
if (!hasPeriodicHeartbeat(runtimeConfig)) {
|
||||
return {
|
||||
label: "Healthy",
|
||||
icon: <Heart size={14} />,
|
||||
color: "var(--state-active-text)",
|
||||
};
|
||||
}
|
||||
|
||||
// Agent has periodic heartbeat — check if within timeout window
|
||||
const lastHeartbeat = new Date(lastHeartbeatAt).getTime();
|
||||
const elapsed = Date.now() - lastHeartbeat;
|
||||
const timeoutMs = getHeartbeatTimeoutMs(runtimeConfig) ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
||||
|
||||
@@ -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