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:
Fusion
2026-04-17 20:40:06 -07:00
committed by gsxdsm
parent 0a89068ef0
commit e4f43a8b43
7 changed files with 396 additions and 37 deletions

View File

@@ -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", () => {

View File

@@ -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;

View File

@@ -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", () => {

View File

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

View File

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

View File

@@ -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");