Files
fusion/packages/engine/src/__tests__/heartbeat-scheduler.test.ts
Fusion 20de80303e test(FN-4386): complete Step 3 — cover idle heartbeat timer gate
Fusion-Task-Id: FN-4386
Fusion-Task-Lineage: 82c1c281-8d69-42c1-8705-90e811b6df93
2026-05-13 16:38:00 -07:00

1732 lines
65 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import {
HeartbeatMonitor,
HeartbeatTriggerScheduler,
} from "../agent-heartbeat.js";
import type { AgentStore, TaskStore, Agent } from "@fusion/core";
import { createBudgetStatus } from "./heartbeat-test-helpers.js";
vi.mock("../logger.js", async () => {
const { createMockLogger, formatMockError } = await import("./heartbeat-test-helpers.js");
return {
createLogger: vi.fn(() => createMockLogger()),
heartbeatLog: createMockLogger(),
formatError: formatMockError,
};
});
import { heartbeatLog } from "../logger.js";
describe("HeartbeatTriggerScheduler", () => {
let store: AgentStore;
let callback: ReturnType<typeof vi.fn>;
let scheduler: import("../agent-heartbeat.js").HeartbeatTriggerScheduler;
beforeEach(() => {
vi.clearAllMocks();
callback = vi.fn().mockResolvedValue(undefined);
store = {
getAgent: vi.fn().mockResolvedValue({
id: "agent-001",
name: "Agent 001",
role: "executor",
state: "active",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}),
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
listAgents: vi.fn().mockResolvedValue([]),
getRunDetail: vi.fn().mockResolvedValue(null),
saveRun: vi.fn().mockResolvedValue(undefined),
endHeartbeatRun: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
updateAgent: vi.fn().mockImplementation(async (_id: string, updates: { metadata: Record<string, unknown> }) => ({
id: "agent-001",
metadata: updates.metadata,
})),
} as unknown as AgentStore;
});
afterEach(() => {
scheduler?.stop();
vi.useRealTimers();
});
describe("constructor and lifecycle", () => {
it("starts and stops cleanly", () => {
scheduler = new HeartbeatTriggerScheduler(store, callback);
expect(scheduler.isActive()).toBe(false);
scheduler.start();
expect(scheduler.isActive()).toBe(true);
scheduler.stop();
expect(scheduler.isActive()).toBe(false);
});
it("start is idempotent", () => {
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
scheduler.start(); // second call should be no-op
expect(scheduler.isActive()).toBe(true);
});
it("stop is idempotent", () => {
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
scheduler.stop();
scheduler.stop(); // second call should be no-op
expect(scheduler.isActive()).toBe(false);
});
});
describe("scheduler timer audit", () => {
it("re-arms a tickable durable agent when timer entry is missing and no lifecycle event fires", async () => {
vi.useFakeTimers();
const agent = {
id: "agent-001",
name: "Agent 001",
role: "executor",
state: "active",
lastHeartbeatAt: "2026-01-01T00:00:00.000Z",
runtimeConfig: { enabled: true, heartbeatIntervalMs: 30_000 },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
} as Agent;
vi.mocked(store.listAgents).mockResolvedValue([agent]);
vi.mocked(store.getActiveHeartbeatRun).mockResolvedValue(null);
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
await vi.advanceTimersByTimeAsync(0);
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
scheduler.unregisterAgent("agent-001");
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
await vi.advanceTimersByTimeAsync(60_000);
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
});
it("marks repaired agent metadata as stale when last heartbeat exceeds the default 2x threshold", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T02:00:00.000Z"));
const agent = {
id: "agent-001",
name: "Agent 001",
role: "executor",
state: "active",
lastHeartbeatAt: "2026-01-01T00:00:00.000Z",
runtimeConfig: { enabled: true, heartbeatIntervalMs: 30_000 },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
} as Agent;
vi.mocked(store.listAgents).mockResolvedValue([agent]);
vi.mocked(store.getActiveHeartbeatRun).mockResolvedValue(null);
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
await vi.advanceTimersByTimeAsync(0);
expect(store.updateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
metadata: expect.objectContaining({
heartbeatTimerRepair: expect.objectContaining({ staleAtRepair: true }),
}),
}),
);
expect(heartbeatLog.warn).toHaveBeenCalledWith(expect.stringContaining("Timer re-armed stale agent agent-001"));
});
it("marks repaired agent metadata as healthy when heartbeat is within stale threshold", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:10.000Z"));
const agent = {
id: "agent-001",
name: "Agent 001",
role: "executor",
state: "active",
lastHeartbeatAt: "2026-01-01T00:00:00.000Z",
runtimeConfig: { enabled: true, heartbeatIntervalMs: 30_000 },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
} as Agent;
vi.mocked(store.listAgents).mockResolvedValue([agent]);
vi.mocked(store.getActiveHeartbeatRun).mockResolvedValue(null);
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
await vi.advanceTimersByTimeAsync(0);
expect(store.updateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
metadata: expect.objectContaining({
heartbeatTimerRepair: expect.objectContaining({ staleAtRepair: false }),
}),
}),
);
expect(heartbeatLog.warn).not.toHaveBeenCalledWith(expect.stringContaining("Timer re-armed stale agent"));
});
it("uses project heartbeatRepairStaleMultiplier when configured", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:50.000Z"));
const agent = {
id: "agent-001",
name: "Agent 001",
role: "executor",
state: "active",
lastHeartbeatAt: "2026-01-01T00:00:00.000Z",
runtimeConfig: { enabled: true, heartbeatIntervalMs: 30_000 },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
} as Agent;
vi.mocked(store.listAgents).mockResolvedValue([agent]);
vi.mocked(store.getActiveHeartbeatRun).mockResolvedValue(null);
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ heartbeatRepairStaleMultiplier: 1 }),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
await vi.advanceTimersByTimeAsync(0);
expect(taskStore.getSettings).toHaveBeenCalled();
expect(store.updateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
metadata: expect.objectContaining({
heartbeatTimerRepair: expect.objectContaining({ staleAtRepair: true }),
}),
}),
);
});
it("skips audit re-arm when the agent already has an active heartbeat run", async () => {
vi.useFakeTimers();
const agent = {
id: "agent-001",
name: "Agent 001",
role: "executor",
state: "active",
runtimeConfig: { enabled: true, heartbeatIntervalMs: 30_000 },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
} as Agent;
vi.mocked(store.listAgents).mockResolvedValue([agent]);
vi.mocked(store.getActiveHeartbeatRun).mockResolvedValue({ id: "run-1" } as any);
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
await vi.advanceTimersByTimeAsync(0);
scheduler.unregisterAgent("agent-001");
await vi.advanceTimersByTimeAsync(60_000);
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
});
it("FN-4119 reaps a stale active run and re-arms the timer when audit finds a lost registration", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T02:00:00.000Z"));
const agent = {
id: "agent-001",
name: "Agent 001",
role: "executor",
state: "active",
lastHeartbeatAt: "2026-01-01T00:00:00.000Z",
runtimeConfig: { enabled: true, heartbeatIntervalMs: 3_600_000, heartbeatTimeoutMs: 10_000 },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
} as Agent;
const activeRun = {
id: "run-stale",
agentId: "agent-001",
startedAt: "2026-01-01T00:00:00.000Z",
status: "active",
} as any;
vi.mocked(store.listAgents).mockResolvedValue([agent]);
vi.mocked(store.getActiveHeartbeatRun).mockResolvedValue(activeRun);
vi.mocked(store.getRunDetail).mockResolvedValue(activeRun);
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
await vi.advanceTimersByTimeAsync(0);
expect(store.endHeartbeatRun).toHaveBeenCalledOnce();
expect(store.endHeartbeatRun).toHaveBeenCalledWith("run-stale", "terminated");
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
expect(heartbeatLog.warn).toHaveBeenCalledWith(expect.stringContaining("reason=orphaned-run-reaped agentId=agent-001 runId=run-stale"));
expect(heartbeatLog.log).toHaveBeenCalledWith(expect.stringContaining("reason=timer-audit-rearmed agentId=agent-001 runId=run-stale"));
vi.mocked(store.getActiveHeartbeatRun).mockResolvedValue(null);
await vi.advanceTimersByTimeAsync(60_000);
expect(store.endHeartbeatRun).toHaveBeenCalledTimes(1);
});
it("FN-4119 leaves healthy active runs alone during audit", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:15.000Z"));
const agent = {
id: "agent-001",
name: "Agent 001",
role: "executor",
state: "active",
lastHeartbeatAt: "2026-01-01T00:00:10.000Z",
runtimeConfig: { enabled: true, heartbeatIntervalMs: 3_600_000, heartbeatTimeoutMs: 10_000 },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:10.000Z",
metadata: {},
} as Agent;
vi.mocked(store.listAgents).mockResolvedValue([agent]);
vi.mocked(store.getActiveHeartbeatRun).mockResolvedValue({ id: "run-healthy", status: "active" } as any);
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
await vi.advanceTimersByTimeAsync(0);
expect(store.endHeartbeatRun).not.toHaveBeenCalled();
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
expect(heartbeatLog.log).toHaveBeenCalledWith("Timer audit skipped re-arm for agent-001 (active run)");
});
it("FN-4119 does not reap task-worker runs during audit", async () => {
vi.useFakeTimers();
const agent = {
id: "executor-FN-999",
name: "executor-FN-999",
role: "executor",
state: "active",
runtimeConfig: { enabled: true, heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 10_000 },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: { agentKind: "task-worker" },
} as Agent;
vi.mocked(store.listAgents).mockResolvedValue([agent]);
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
await vi.advanceTimersByTimeAsync(0);
expect(store.getActiveHeartbeatRun).not.toHaveBeenCalled();
expect(store.endHeartbeatRun).not.toHaveBeenCalled();
expect(scheduler.getRegisteredAgents()).not.toContain("executor-FN-999");
});
});
describe("registerAgent", () => {
beforeEach(() => {
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
});
it("registers an agent with timer", () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
});
it("does not register when heartbeat is explicitly disabled", () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000, enabled: false });
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
});
it("applies default 3600-second interval when intervalMs is undefined", async () => {
vi.useFakeTimers();
scheduler.registerAgent("agent-001", {});
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Verify the default 3600-second interval fires
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(3_600_000);
expect(callback).toHaveBeenCalledOnce();
vi.useRealTimers();
});
it("applies default 3600-second interval when intervalMs is 0", async () => {
vi.useFakeTimers();
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 0 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Verify the default 3600-second interval fires
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(3_600_000);
expect(callback).toHaveBeenCalledOnce();
vi.useRealTimers();
});
it("applies default 3600-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 3600 seconds (default interval)
await vi.advanceTimersByTimeAsync(3_599_999);
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1); // Now at exactly 3600 seconds
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 3_600_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 3600
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("applies heartbeatMultiplier to timer interval", async () => {
scheduler.stop();
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 0.5 }),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
vi.useFakeTimers();
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 60_000, enabled: true });
await Promise.resolve();
await Promise.resolve();
expect(taskStore.getSettings).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(29_999);
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(callback).toHaveBeenCalledWith("agent-001", "timer", expect.objectContaining({ intervalMs: 30_000 }));
vi.useRealTimers();
});
it("defaults multiplier to 1 when setting is missing", async () => {
scheduler.stop();
const taskStore = {
getSettings: vi.fn().mockResolvedValue({}),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
vi.useFakeTimers();
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 20_000, enabled: true });
await Promise.resolve();
await Promise.resolve();
expect(taskStore.getSettings).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(19_999);
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(callback).toHaveBeenCalledWith("agent-001", "timer", expect.objectContaining({ intervalMs: 20_000 }));
vi.useRealTimers();
});
it("clamps multiplied interval to 1000ms minimum", async () => {
scheduler.stop();
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 0.1 }),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
vi.useFakeTimers();
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 2_000, enabled: true });
await Promise.resolve();
await Promise.resolve();
expect(taskStore.getSettings).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(999);
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(callback).toHaveBeenCalledWith("agent-001", "timer", expect.objectContaining({ intervalMs: 1_000 }));
vi.useRealTimers();
});
it("clears previous timer when re-registering", () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 20000 });
expect(scheduler.getRegisteredAgents()).toHaveLength(1);
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
});
});
describe("unregisterAgent", () => {
beforeEach(() => {
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
});
it("removes a registered agent", () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
scheduler.unregisterAgent("agent-001");
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
});
it("is no-op for unregistered agent", () => {
scheduler.unregisterAgent("agent-999");
expect(scheduler.getRegisteredAgents()).toHaveLength(0);
});
});
describe("timer triggers", () => {
beforeEach(() => {
vi.useFakeTimers();
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
});
it("fires callback at the configured interval", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
// Advance by one interval and let async callbacks settle
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 5000,
});
});
it("clamps configured interval to a minimum of 1000ms", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10 });
await vi.advanceTimersByTimeAsync(999);
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 1000,
});
});
it("fires multiple times for multiple intervals", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(15000);
expect(callback).toHaveBeenCalledTimes(3);
});
it("does not fire after stop", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
scheduler.stop();
await vi.advanceTimersByTimeAsync(10000);
expect(callback).not.toHaveBeenCalled();
});
it("does not fire after unregister", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
scheduler.unregisterAgent("agent-001");
await vi.advanceTimersByTimeAsync(10000);
expect(callback).not.toHaveBeenCalled();
});
it("phase-aligns the first tick to lastHeartbeatAt when supplied", async () => {
// Simulate: last tick was 4s ago, interval is 5s.
// The next tick is due in 1s, not in a fresh full 5s window.
vi.setSystemTime(new Date("2026-04-30T05:00:00.000Z"));
const lastHeartbeatAt = new Date("2026-04-30T04:59:56.000Z").toISOString();
scheduler.registerAgent(
"agent-001",
{ heartbeatIntervalMs: 5000 },
{ lastHeartbeatAt },
);
await vi.advanceTimersByTimeAsync(999);
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(callback).toHaveBeenCalledOnce();
// Subsequent ticks resume the steady cadence.
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledTimes(2);
});
it("fires promptly with jitter when lastHeartbeatAt is already overdue", async () => {
// Interval is 60s but the last tick was 10 minutes ago — fire immediately
// (within the OVERDUE_FIRE_JITTER_MS window) instead of waiting another
// full 60s. This is the core fix for "agents look unresponsive after a
// dashboard restart" — the previous setInterval-only scheduler would
// have made the user wait a full interval before the catch-up tick.
vi.setSystemTime(new Date("2026-04-30T05:00:00.000Z"));
const lastHeartbeatAt = new Date("2026-04-30T04:50:00.000Z").toISOString();
scheduler.registerAgent(
"agent-001",
{ heartbeatIntervalMs: 60_000 },
{ lastHeartbeatAt },
);
// Jitter window is 5s; advance past it to guarantee the fire happens.
await vi.advanceTimersByTimeAsync(5_000);
expect(callback).toHaveBeenCalledOnce();
});
it("falls back to full-interval delay when lastHeartbeatAt is missing", async () => {
// No options provided — preserves the original "wait one full interval"
// behavior for agents that have never ticked.
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(4999);
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(callback).toHaveBeenCalledOnce();
});
it("skips tick when agent has active run", async () => {
(store.getActiveHeartbeatRun as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-active",
status: "active",
});
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(5000);
expect(callback).not.toHaveBeenCalled();
});
it("FN-4119 reaps a stale active run and proceeds with the timer tick", async () => {
const staleAgent = {
id: "agent-001",
name: "Agent 001",
role: "executor",
state: "active",
lastHeartbeatAt: "2026-01-01T00:00:00.000Z",
runtimeConfig: { enabled: true, heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 10_000 },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
} as Agent;
const activeRun = {
id: "run-stale",
agentId: "agent-001",
startedAt: "2026-01-01T00:00:00.000Z",
status: "active",
} as any;
vi.setSystemTime(new Date("2026-01-01T02:00:00.000Z"));
vi.mocked(store.getAgent).mockResolvedValue(staleAgent);
vi.mocked(store.getActiveHeartbeatRun).mockResolvedValue(activeRun);
vi.mocked(store.getRunDetail).mockResolvedValue(activeRun);
await (scheduler as any).onTimerTick("agent-001", 30_000);
expect(store.endHeartbeatRun).toHaveBeenCalledOnce();
expect(store.endHeartbeatRun).toHaveBeenCalledWith("run-stale", "terminated");
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 30_000,
});
expect(heartbeatLog.log).toHaveBeenCalledWith(expect.stringContaining("reason=tick-proceeded-after-reap agentId=agent-001 runId=run-stale"));
});
it("FN-4119 preserves the active-run skip when the run is still healthy", async () => {
const healthyAgent = {
id: "agent-001",
name: "Agent 001",
role: "executor",
state: "active",
lastHeartbeatAt: "2026-01-01T00:00:12.000Z",
runtimeConfig: { enabled: true, heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 10_000 },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:12.000Z",
metadata: {},
} as Agent;
vi.setSystemTime(new Date("2026-01-01T00:00:15.000Z"));
vi.mocked(store.getAgent).mockResolvedValue(healthyAgent);
vi.mocked(store.getActiveHeartbeatRun).mockResolvedValue({ id: "run-healthy", status: "active" } as any);
await (scheduler as any).onTimerTick("agent-001", 30_000);
expect(store.endHeartbeatRun).not.toHaveBeenCalled();
expect(callback).not.toHaveBeenCalled();
expect(heartbeatLog.log).toHaveBeenCalledWith("Timer tick skipped for agent-001 (active run)");
});
it.each([
{ name: "paused agent state", agentState: "paused" as const, settings: null, expectedLog: "Timer tick skipped for agent-001 (state=paused)" },
{ name: "global pause", agentState: "active" as const, settings: { globalPause: true, enginePaused: false }, expectedLog: "Timer tick skipped for agent-001 (global pause active)" },
{ name: "engine pause", agentState: "active" as const, settings: { globalPause: false, enginePaused: true }, expectedLog: "Timer tick skipped for agent-001 (engine paused)" },
])("FN-4119 does not reap stale runs during $name", async ({ agentState, settings, expectedLog }) => {
scheduler.stop();
const taskStore = settings
? ({ getSettings: vi.fn().mockResolvedValue(settings) } as unknown as TaskStore)
: undefined;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
const agent = {
id: "agent-001",
name: "Agent 001",
role: "executor",
state: agentState,
lastHeartbeatAt: "2026-01-01T00:00:00.000Z",
runtimeConfig: { enabled: true, heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 10_000 },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
} as Agent;
vi.setSystemTime(new Date("2026-01-01T02:00:00.000Z"));
vi.mocked(store.getAgent).mockResolvedValue(agent);
vi.mocked(store.getActiveHeartbeatRun).mockResolvedValue({ id: "run-stale", status: "active" } as any);
await (scheduler as any).onTimerTick("agent-001", 30_000);
expect(store.getActiveHeartbeatRun).not.toHaveBeenCalled();
expect(store.endHeartbeatRun).not.toHaveBeenCalled();
expect(callback).not.toHaveBeenCalled();
expect(heartbeatLog.log).toHaveBeenCalledWith(expectedLog);
});
it("skips timer dispatch when global pause is active", async () => {
scheduler.stop();
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ globalPause: true, enginePaused: false }),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(5000);
expect(callback).not.toHaveBeenCalled();
expect(heartbeatLog.log).toHaveBeenCalledWith("Timer tick skipped for agent-001 (global pause active)");
});
it("skips timer dispatch when engine pause is active", async () => {
scheduler.stop();
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: true }),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(5000);
expect(callback).not.toHaveBeenCalled();
});
it("dispatches timer callback when pause flags are false", async () => {
scheduler.stop();
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 5000,
});
});
it("respects maxConcurrentRuns from config", async () => {
// Agent with active run should be skipped
(store.getActiveHeartbeatRun as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-active",
status: "active",
});
scheduler.registerAgent("agent-001", {
heartbeatIntervalMs: 5000,
maxConcurrentRuns: 1,
});
await vi.advanceTimersByTimeAsync(5000);
expect(callback).not.toHaveBeenCalled();
});
it("dispatches timer callback even when agent is over budget (budget enforcement in executeHeartbeat)", async () => {
// Budget checks have been moved from the scheduler to executeHeartbeat().
// The scheduler dispatches the callback so that executeHeartbeat() can create
// explicit run records with budget_exhausted/budget_threshold_exceeded reasons.
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
createBudgetStatus({ isOverBudget: true, isOverThreshold: true, usagePercent: 100 })
);
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(5000);
// Callback IS called so executeHeartbeat() can create a run record
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 5000,
});
});
it("dispatches timer callback even when agent is over threshold (budget enforcement in executeHeartbeat)", async () => {
// Budget checks have been moved from the scheduler to executeHeartbeat().
// The scheduler dispatches the callback so that executeHeartbeat() can create
// explicit run records with budget_exhausted/budget_threshold_exceeded reasons.
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
createBudgetStatus({
budgetLimit: 1000,
usagePercent: 85,
thresholdPercent: 80,
isOverBudget: false,
isOverThreshold: true,
})
);
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(5000);
// Callback IS called so executeHeartbeat() can create a run record
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 5000,
});
});
it("fires timer tick normally when below threshold", async () => {
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
createBudgetStatus({
budgetLimit: 1000,
usagePercent: 30,
thresholdPercent: 80,
isOverBudget: false,
isOverThreshold: false,
})
);
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
it("fires timer tick when getBudgetStatus throws", async () => {
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("budget unavailable"));
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
});
describe("stop clears all timers", () => {
it("clears all registered timers on stop", () => {
vi.useFakeTimers();
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
scheduler.registerAgent("agent-002", { heartbeatIntervalMs: 10000 });
expect(scheduler.getRegisteredAgents()).toHaveLength(2);
scheduler.stop();
expect(scheduler.getRegisteredAgents()).toHaveLength(0);
vi.advanceTimersByTime(20000);
expect(callback).not.toHaveBeenCalled();
vi.useRealTimers();
});
});
// ── FN-2289 Regression: Idle-state timer persistence ─────────────────────────────────────
// These tests verify the fix for the defect where agent timers were unintentionally cleared
// when agents transitioned to "idle" state. The isTickableState() function must include "idle"
// as a valid state so that timers remain armed for agents between tasks.
describe("FN-2289: idle-state timer persistence", () => {
let eventStore: EventEmitter & {
getAgent: ReturnType<typeof vi.fn>;
getActiveHeartbeatRun: ReturnType<typeof vi.fn>;
getBudgetStatus: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.useFakeTimers();
eventStore = Object.assign(new EventEmitter(), {
getAgent: vi.fn().mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
})),
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
});
scheduler = new HeartbeatTriggerScheduler(eventStore as unknown as AgentStore, callback);
scheduler.start();
});
afterEach(() => {
scheduler?.stop();
vi.useRealTimers();
});
it("timer remains armed when agent transitions to idle state (regression test for FN-2289)", async () => {
// Register agent with active state
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Simulate agent transitioning to idle state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const, // Agent is now idle
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
// Timer should still be registered
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Timer should fire for idle agent
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 5000,
});
});
it("timer fires correctly for idle agent at scheduled interval", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Update agent to idle state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
// Timer should still be armed
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Advance time and verify multiple fires
await vi.advanceTimersByTimeAsync(30000);
expect(callback).toHaveBeenCalledTimes(3);
});
it("timer fires for agent transitioning from idle back to active", async () => {
// Start with idle state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
// Timer should be armed even for idle agent
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Advance time - timer should fire
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
it("idle agent receives timer trigger with correct context", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 15000 });
// Update to idle state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
await vi.advanceTimersByTimeAsync(15000);
expect(callback).toHaveBeenCalledWith("agent-001", "timer", expect.objectContaining({
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 15000,
}));
});
it("timer is still armed after multiple idle state transitions", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
// First transition to idle
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
// Second transition (still idle, but emit update)
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
// Third transition back to active
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "active", metadata: {} } as import("@fusion/core").Agent);
// Timer should still be registered through all transitions
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Timer should fire
await vi.advanceTimersByTimeAsync(10000);
expect(callback).toHaveBeenCalledOnce();
});
it("timer fires for running agent (pre-existing behavior)", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
// Update to running state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "running" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "running", metadata: {} } as import("@fusion/core").Agent);
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
it("timer is unregistered when agent becomes paused (should clear timer)", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Update to paused state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "paused" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "paused", metadata: {} } as import("@fusion/core").Agent);
// Timer should be cleared for paused agents
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
await vi.advanceTimersByTimeAsync(10000);
expect(callback).not.toHaveBeenCalled();
});
it("timer is unregistered when agent becomes error state (should clear timer)", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Update to error state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "error" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "error", metadata: {} } as import("@fusion/core").Agent);
// Timer should be cleared for error agents
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
await vi.advanceTimersByTimeAsync(10000);
expect(callback).not.toHaveBeenCalled();
});
it("timer is unregistered when agent becomes paused state (should clear timer)", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Update to paused state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "paused" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "paused", metadata: {} } as import("@fusion/core").Agent);
// Timer should be cleared for paused agents
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
await vi.advanceTimersByTimeAsync(10000);
expect(callback).not.toHaveBeenCalled();
});
});
// ── FN-2289 Regression: Multiplier stability across re-registration ─────────────────────
describe("FN-2289: multiplier stability across re-registration", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
scheduler?.stop();
vi.useRealTimers();
});
it("multiplier-adjusted interval remains stable when re-registering", async () => {
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 0.5 }),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
// First registration with multiplier 0.5 -> effective interval 5000ms
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
await vi.advanceTimersByTimeAsync(100); // Allow pending async operations to complete
// Re-register (simulating settings change or config update)
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
await vi.advanceTimersByTimeAsync(100);
// Timer should still fire at the multiplied interval (5000ms)
// The timer was set up immediately, so we need to ensure we advance past 5000ms total
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
it("async multiplier registration does not stale-overwrite newer registration", async () => {
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 2.0 }),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
// Register with multiplier 2.0 -> effective interval 20000ms
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
await Promise.resolve();
// Immediately re-register before async multiplier completes
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
// Timer should still be registered
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Advance time past the original interval (10s) but before multiplied (20s)
// If stale-overwrite happens, callback would be called at 10s instead of 20s
await vi.advanceTimersByTimeAsync(15000);
expect(callback).not.toHaveBeenCalled();
// Timer should fire at 20s (correct multiplied interval)
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
});
describe("assignment watching", () => {
let eventStore: EventEmitter & {
getActiveHeartbeatRun: ReturnType<typeof vi.fn>;
getBudgetStatus: ReturnType<typeof vi.fn>;
getRecentRuns: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.useRealTimers(); // Ensure real timers for these tests
eventStore = Object.assign(new EventEmitter(), {
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockRejectedValue(new Error("budget status unavailable")),
getRecentRuns: vi.fn().mockResolvedValue([]),
});
scheduler = new HeartbeatTriggerScheduler(eventStore as unknown as AgentStore, callback);
scheduler.start();
}, 30000);
afterEach(() => {
scheduler?.stop();
});
it("triggers callback on agent:assigned event", async () => {
const agent = { id: "agent-test", name: "Test", state: "active", metadata: {}, taskId: "FN-001" } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-001");
// Allow asynchronous assignment listeners to run in heavily loaded test environments.
await vi.waitFor(() => {
expect(callback).toHaveBeenCalledOnce();
}, { timeout: 1000 });
expect(callback).toHaveBeenCalledWith("agent-test", "assignment", {
taskId: "FN-001",
wakeReason: "assignment",
triggerDetail: "task-assigned",
});
});
it("does NOT trigger when stopped", async () => {
scheduler.stop();
const agent = { id: "agent-test", name: "Test" } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-002");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(callback).not.toHaveBeenCalled();
});
it("skips trigger when agent heartbeat is disabled", async () => {
const agent: import("@fusion/core").Agent = {
id: "agent-test",
name: "executor-FN-1661",
role: "executor",
state: "active",
taskId: "FN-1661",
metadata: {},
runtimeConfig: { enabled: false },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
eventStore.emit("agent:assigned", agent, "FN-1661");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(callback).not.toHaveBeenCalled();
expect(eventStore.getActiveHeartbeatRun).not.toHaveBeenCalled();
});
it("skips trigger when agent has active run", async () => {
(eventStore.getActiveHeartbeatRun as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-active",
status: "active",
});
const agent = { id: "agent-test", name: "Test" } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-003");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(callback).not.toHaveBeenCalled();
});
it("blocks assignment trigger when agent is over budget", async () => {
(eventStore as any).getBudgetStatus = vi.fn().mockResolvedValue(
createBudgetStatus({
agentId: "agent-test",
isOverBudget: true,
isOverThreshold: true,
usagePercent: 100,
budgetLimit: 1000,
thresholdPercent: 80,
})
);
const agent = { id: "agent-test", name: "Test" } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-003");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(callback).not.toHaveBeenCalled();
});
it("allows assignment trigger when agent is over threshold", async () => {
const budgetStatus = createBudgetStatus({
agentId: "agent-test",
budgetLimit: 1000,
usagePercent: 85,
thresholdPercent: 80,
isOverBudget: false,
isOverThreshold: true,
});
(eventStore as any).getBudgetStatus = vi.fn().mockResolvedValue(budgetStatus);
const agent = { id: "agent-test", name: "Test", state: "active", metadata: {} } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-003");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-test", "assignment", {
taskId: "FN-003",
wakeReason: "assignment",
triggerDetail: "task-assigned",
budgetStatus,
});
});
it("passes budgetStatus in WakeContext for assignment triggers", async () => {
const budgetStatus = createBudgetStatus({
agentId: "agent-test",
budgetLimit: 1000,
usagePercent: 45,
thresholdPercent: 80,
});
(eventStore as any).getBudgetStatus = vi.fn().mockResolvedValue(budgetStatus);
const agent = { id: "agent-test", name: "Test", state: "active", metadata: {} } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-005");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(callback).toHaveBeenCalledWith(
"agent-test",
"assignment",
expect.objectContaining({
taskId: "FN-005",
budgetStatus,
}),
);
});
it("includes new steering comment IDs for assignment wakes when taskStore is available", async () => {
scheduler.stop();
(eventStore as any).getRecentRuns = vi.fn().mockResolvedValue([
{ startedAt: "2026-01-01T00:00:00.000Z" },
]);
const assignmentTaskStore = {
getTask: vi.fn().mockResolvedValue({
id: "FN-006",
steeringComments: [
{ id: "steer-old", text: "older", author: "user", createdAt: "2025-12-31T23:00:00.000Z" },
{ id: "steer-new", text: "new guidance", author: "user", createdAt: "2026-01-01T01:00:00.000Z" },
],
}),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(eventStore as unknown as AgentStore, callback, assignmentTaskStore);
scheduler.start();
const agent = { id: "agent-test", name: "Test", state: "active", metadata: {} } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-006");
await vi.waitFor(() => {
expect(callback).toHaveBeenCalledOnce();
}, { timeout: 1000 });
expect(callback).toHaveBeenCalledWith("agent-test", "assignment", expect.objectContaining({
taskId: "FN-006",
triggeringCommentIds: ["steer-new"],
triggeringCommentType: "steering",
}));
});
it("cleans up listener on unwatch", async () => {
scheduler.unwatchAssignments();
const agent = { id: "agent-test", name: "Test" } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-004");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(callback).not.toHaveBeenCalled();
});
});
describe("Run context propagation", () => {
it("createHeartbeatTools passes runContext to taskStore.logEntry", async () => {
// Create a minimal mock TaskStore
const mockTaskStore = {
createTask: vi.fn().mockResolvedValue({ id: "FN-NEW", description: "New task" }),
logEntry: vi.fn().mockResolvedValue({}),
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
description: "Test task",
column: "todo",
log: [],
}),
} as unknown as import("@fusion/core").TaskStore;
const monitor = new HeartbeatMonitor({
store,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const runContext = { runId: "run-123", agentId: "agent-456", source: "timer" };
// Create tools with run context
const tools = monitor.createHeartbeatTools("agent-456", mockTaskStore, "FN-001", runContext);
// Find the fn_task_log tool and execute it
const taskLogTool = tools.find(t => t.name === "fn_task_log");
expect(taskLogTool).toBeDefined();
const result = await taskLogTool!.execute("call-1", { message: "Test log entry", outcome: undefined }, undefined as any, undefined as any, undefined as any);
// Verify logEntry was called with runContext
expect(mockTaskStore.logEntry).toHaveBeenCalledWith(
"FN-001",
"Test log entry",
undefined,
runContext,
);
});
it("createHeartbeatTools tracks task creations with runContext", async () => {
// Create a minimal mock TaskStore
const mockTaskStore = {
createTask: vi.fn().mockResolvedValue({ id: "FN-200", description: "New task created", dependencies: [] }),
logEntry: vi.fn().mockResolvedValue({}),
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
description: "Test task",
column: "todo",
log: [],
}),
} as unknown as import("@fusion/core").TaskStore;
const monitor = new HeartbeatMonitor({
store,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const runContext = { runId: "run-789", agentId: "agent-abc", source: "on_demand" };
// Create tools with run context
const tools = monitor.createHeartbeatTools("agent-abc", mockTaskStore, "FN-001", runContext);
// Find the fn_task_create tool and execute it
const taskCreateTool = tools.find(t => t.name === "fn_task_create");
expect(taskCreateTool).toBeDefined();
const result = await taskCreateTool!.execute("call-1", { description: "New task created" }, undefined as any, undefined as any, undefined as any);
// Verify logEntry was called with runContext for the created task
expect(mockTaskStore.logEntry).toHaveBeenCalledWith(
"FN-200",
"Created by agent agent-abc during heartbeat run",
undefined,
runContext,
);
});
it("createHeartbeatTools works without runContext (backward compat)", async () => {
// Create a minimal mock TaskStore
const mockTaskStore = {
createTask: vi.fn().mockResolvedValue({ id: "FN-NEW", description: "New task" }),
logEntry: vi.fn().mockResolvedValue({}),
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
description: "Test task",
column: "todo",
log: [],
}),
} as unknown as import("@fusion/core").TaskStore;
const monitor = new HeartbeatMonitor({
store,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
// Create tools without run context
const tools = monitor.createHeartbeatTools("agent-456", mockTaskStore, "FN-001");
// Find the fn_task_log tool and execute it
const taskLogTool = tools.find(t => t.name === "fn_task_log");
expect(taskLogTool).toBeDefined();
const result = await taskLogTool!.execute("call-1", { message: "Test log entry", outcome: undefined }, undefined as any, undefined as any, undefined as any);
// Verify logEntry was called without runContext
expect(mockTaskStore.logEntry).toHaveBeenCalledWith(
"FN-001",
"Test log entry",
undefined,
undefined,
);
});
});
describe("allowParallelExecution gate", () => {
function makeAgentWithConfig(overrides: Record<string, unknown> = {}) {
return {
id: "agent-par",
name: "Parallel Agent",
role: "executor",
state: "active",
taskId: "FN-TASK-1",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
runtimeConfig: overrides,
};
}
it("timer tick skips when allowParallelExecution=false and task is executing", async () => {
vi.useFakeTimers();
const isTaskExecuting = vi.fn().mockReturnValue(true);
const parallelStore = {
getAgent: vi.fn().mockResolvedValue(makeAgentWithConfig({ allowParallelExecution: false })),
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
on: vi.fn(),
off: vi.fn(),
} as unknown as AgentStore;
scheduler = new HeartbeatTriggerScheduler(parallelStore, callback, undefined, { isTaskExecuting });
scheduler.start();
scheduler.registerAgent("agent-par", { heartbeatIntervalMs: 1000 });
await vi.advanceTimersByTimeAsync(1100);
expect(callback).not.toHaveBeenCalled();
expect(isTaskExecuting).toHaveBeenCalledWith("FN-TASK-1");
});
it("timer tick fires when allowParallelExecution=false and task is NOT executing", async () => {
vi.useFakeTimers();
const isTaskExecuting = vi.fn().mockReturnValue(false);
const parallelStore = {
getAgent: vi.fn().mockResolvedValue(makeAgentWithConfig({ allowParallelExecution: false })),
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
on: vi.fn(),
off: vi.fn(),
} as unknown as AgentStore;
scheduler = new HeartbeatTriggerScheduler(parallelStore, callback, undefined, { isTaskExecuting });
scheduler.start();
scheduler.registerAgent("agent-par", { heartbeatIntervalMs: 1000 });
await vi.advanceTimersByTimeAsync(1100);
expect(callback).toHaveBeenCalledOnce();
});
it("timer tick fires when allowParallelExecution=true even while task is executing", async () => {
vi.useFakeTimers();
const isTaskExecuting = vi.fn().mockReturnValue(true);
const parallelStore = {
getAgent: vi.fn().mockResolvedValue(makeAgentWithConfig({ allowParallelExecution: true })),
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
on: vi.fn(),
off: vi.fn(),
} as unknown as AgentStore;
scheduler = new HeartbeatTriggerScheduler(parallelStore, callback, undefined, { isTaskExecuting });
scheduler.start();
scheduler.registerAgent("agent-par", { heartbeatIntervalMs: 1000 });
await vi.advanceTimersByTimeAsync(1100);
expect(callback).toHaveBeenCalledOnce();
});
it("timer tick fires when allowParallelExecution is unset (default) even while task is executing", async () => {
vi.useFakeTimers();
const isTaskExecuting = vi.fn().mockReturnValue(true);
const parallelStore = {
getAgent: vi.fn().mockResolvedValue(makeAgentWithConfig({})),
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
on: vi.fn(),
off: vi.fn(),
} as unknown as AgentStore;
scheduler = new HeartbeatTriggerScheduler(parallelStore, callback, undefined, { isTaskExecuting });
scheduler.start();
scheduler.registerAgent("agent-par", { heartbeatIntervalMs: 1000 });
await vi.advanceTimersByTimeAsync(1100);
expect(callback).toHaveBeenCalledOnce();
});
});
describe("skipHeartbeatWhenIdle gate", () => {
function makeAgentWithConfig(overrides: Record<string, unknown> = {}, taskId?: string) {
return {
id: "agent-idle",
name: "Idle Agent",
role: "executor",
state: "active",
taskId,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
runtimeConfig: overrides,
};
}
it("timer tick is skipped when skipHeartbeatWhenIdle=true and no task is assigned", async () => {
vi.useFakeTimers();
const idleStore = {
getAgent: vi.fn().mockResolvedValue(makeAgentWithConfig({ skipHeartbeatWhenIdle: true })),
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
on: vi.fn(),
off: vi.fn(),
} as unknown as AgentStore;
scheduler = new HeartbeatTriggerScheduler(idleStore, callback);
scheduler.start();
scheduler.registerAgent("agent-idle", { heartbeatIntervalMs: 1000 });
await vi.advanceTimersByTimeAsync(1100);
expect(callback).not.toHaveBeenCalled();
expect(scheduler.getRegisteredAgents()).toContain("agent-idle");
});
it("timer tick fires when skipHeartbeatWhenIdle=true and task is assigned", async () => {
vi.useFakeTimers();
const idleStore = {
getAgent: vi.fn().mockResolvedValue(makeAgentWithConfig({ skipHeartbeatWhenIdle: true }, "FN-TASK-7")),
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
on: vi.fn(),
off: vi.fn(),
} as unknown as AgentStore;
scheduler = new HeartbeatTriggerScheduler(idleStore, callback);
scheduler.start();
scheduler.registerAgent("agent-idle", { heartbeatIntervalMs: 1000 });
await vi.advanceTimersByTimeAsync(1100);
expect(callback).toHaveBeenCalledOnce();
});
it.each([
[undefined, undefined],
[{}, undefined],
[{ skipHeartbeatWhenIdle: false }, undefined],
])("timer tick fires by default when config=%p taskId=%p", async (runtimeConfig, taskId) => {
vi.useFakeTimers();
const idleStore = {
getAgent: vi.fn().mockResolvedValue(makeAgentWithConfig((runtimeConfig ?? {}) as Record<string, unknown>, taskId as string | undefined)),
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
on: vi.fn(),
off: vi.fn(),
} as unknown as AgentStore;
scheduler = new HeartbeatTriggerScheduler(idleStore, callback);
scheduler.start();
scheduler.registerAgent("agent-idle", { heartbeatIntervalMs: 1000 });
await vi.advanceTimersByTimeAsync(1100);
expect(callback).toHaveBeenCalledOnce();
});
it("assignment trigger still fires when skipHeartbeatWhenIdle=true", async () => {
vi.useRealTimers();
const eventStore = Object.assign(new EventEmitter(), {
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockRejectedValue(new Error("budget status unavailable")),
getRecentRuns: vi.fn().mockResolvedValue([]),
});
scheduler = new HeartbeatTriggerScheduler(eventStore as unknown as AgentStore, callback);
scheduler.start();
const agent = makeAgentWithConfig({ skipHeartbeatWhenIdle: true }) as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-123");
await vi.waitFor(() => {
expect(callback).toHaveBeenCalledOnce();
}, { timeout: 1000 });
expect(callback).toHaveBeenCalledWith("agent-idle", "assignment", {
taskId: "FN-123",
wakeReason: "assignment",
triggerDetail: "task-assigned",
});
});
});
});