1732 lines
65 KiB
TypeScript
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",
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|