feat(FN-1061): add heartbeat trigger scheduling for agent wakeup
- Add HeartbeatTriggerScheduler class with timer, assignment, and on-demand trigger types - Wire timer-based periodic wakeups using per-agent heartbeatIntervalMs config - Add agent:assigned event in AgentStore for assignment-triggered wakeups - Enhance POST /api/agents/:id/runs with WakeContext and 409 conflict on concurrent runs - Integrate trigger scheduler into InProcessRuntime lifecycle (start/stop) - Add enabled field to AgentHeartbeatConfig for per-agent trigger control - Update AGENTS.md with heartbeat trigger scheduling documentation
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { HeartbeatMonitor, type AgentSession, type HeartbeatExecutionOptions, HEARTBEAT_SYSTEM_PROMPT } from "./agent-heartbeat.js";
|
||||
import { HeartbeatMonitor, HeartbeatTriggerScheduler, type AgentSession, type HeartbeatExecutionOptions, HEARTBEAT_SYSTEM_PROMPT } from "./agent-heartbeat.js";
|
||||
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent } from "@fusion/core";
|
||||
|
||||
// Mock logger to suppress noise in test output
|
||||
@@ -1592,3 +1592,279 @@ describe("HeartbeatMonitor", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// HeartbeatTriggerScheduler tests
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("HeartbeatTriggerScheduler", () => {
|
||||
let store: AgentStore;
|
||||
let callback: ReturnType<typeof vi.fn>;
|
||||
let scheduler: import("./agent-heartbeat.js").HeartbeatTriggerScheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
callback = vi.fn().mockResolvedValue(undefined);
|
||||
store = {
|
||||
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
} 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("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("skips registration when enabled is false", () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000, enabled: false });
|
||||
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
|
||||
});
|
||||
|
||||
it("skips registration when intervalMs is undefined", () => {
|
||||
scheduler.registerAgent("agent-001", {});
|
||||
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
|
||||
});
|
||||
|
||||
it("skips registration when intervalMs is 0", () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 0 });
|
||||
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
|
||||
});
|
||||
|
||||
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("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("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("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();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assignment watching", () => {
|
||||
let eventStore: AgentStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers(); // Ensure real timers for these tests
|
||||
|
||||
// Create a real AgentStore (which extends EventEmitter) so we can emit events
|
||||
const { AgentStore: AgentStoreClass } = await import("@fusion/core");
|
||||
eventStore = new AgentStoreClass({ rootDir: `.fusion-test-assign-${Date.now()}` }) as AgentStore;
|
||||
// Override getActiveHeartbeatRun to return null (no active run)
|
||||
(eventStore as any).getActiveHeartbeatRun = vi.fn().mockResolvedValue(null);
|
||||
|
||||
scheduler = new HeartbeatTriggerScheduler(eventStore, callback);
|
||||
scheduler.start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
scheduler?.stop();
|
||||
const { rm } = await import("node:fs/promises");
|
||||
await rm((eventStore as any).rootDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("triggers callback on agent:assigned event", async () => {
|
||||
const agent = { id: "agent-test", name: "Test", taskId: "FN-001" } as import("@fusion/core").Agent;
|
||||
|
||||
eventStore.emit("agent:assigned", agent, "FN-001");
|
||||
|
||||
// Wait for async event handler
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
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 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("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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* - onTerminated: Called when an unresponsive agent is terminated
|
||||
*/
|
||||
|
||||
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, TaskStore, TaskDetail } from "@fusion/core";
|
||||
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, TaskStore, TaskDetail } from "@fusion/core";
|
||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import { createTaskCreateTool, createTaskLogTool, taskCreateParams } from "./agent-tools.js";
|
||||
@@ -778,3 +778,227 @@ export class HeartbeatMonitor {
|
||||
this.onTerminated?.(tracked.agentId);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// HeartbeatTriggerScheduler — timer, assignment, and on-demand triggers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Structured context passed when a trigger fires. */
|
||||
export interface WakeContext {
|
||||
/** Optional task ID associated with this trigger */
|
||||
taskId?: string;
|
||||
/** Why the agent was woken */
|
||||
wakeReason: string;
|
||||
/** Detail about the specific trigger */
|
||||
triggerDetail: string;
|
||||
/** Additional context (intervalMs, etc.) */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Callback invoked when a trigger fires. */
|
||||
export type TriggerCallback = (
|
||||
agentId: string,
|
||||
source: HeartbeatInvocationSource,
|
||||
context: WakeContext,
|
||||
) => Promise<void>;
|
||||
|
||||
/** Per-agent timer state */
|
||||
interface AgentTimer {
|
||||
intervalMs: number;
|
||||
handle: ReturnType<typeof setInterval>;
|
||||
}
|
||||
|
||||
/**
|
||||
* HeartbeatTriggerScheduler manages timer-based heartbeat triggers for agents.
|
||||
*
|
||||
* Each agent can be registered with a heartbeat config that specifies
|
||||
* the timer interval. When the timer fires, the scheduler invokes the
|
||||
* provided callback with the appropriate source and context.
|
||||
*
|
||||
* The scheduler respects:
|
||||
* - `enabled`: Skip registration if false
|
||||
* - `heartbeatIntervalMs`: Timer interval (undefined = no timer)
|
||||
* - `maxConcurrentRuns`: Skip tick if agent already has an active run
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const scheduler = new HeartbeatTriggerScheduler(agentStore, async (agentId, source, ctx) => {
|
||||
* await heartbeatMonitor.startRun(agentId, { source, triggerDetail: ctx.triggerDetail, contextSnapshot: { ...ctx } });
|
||||
* });
|
||||
* scheduler.registerAgent("agent-123", { heartbeatIntervalMs: 30000, enabled: true });
|
||||
* scheduler.start();
|
||||
* ```
|
||||
*/
|
||||
export class HeartbeatTriggerScheduler {
|
||||
private store: AgentStore;
|
||||
private callback: TriggerCallback;
|
||||
private timers: Map<string, AgentTimer> = new Map();
|
||||
private running = false;
|
||||
private assignedListener: ((agent: import("@fusion/core").Agent, taskId: string) => void) | null = null;
|
||||
|
||||
constructor(store: AgentStore, callback: TriggerCallback) {
|
||||
this.store = store;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the scheduler. Enables assignment watching.
|
||||
* Individual agents must be registered separately via registerAgent().
|
||||
*/
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.watchAssignments();
|
||||
heartbeatLog.log("HeartbeatTriggerScheduler started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the scheduler and clear all timers.
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.running) return;
|
||||
this.running = false;
|
||||
|
||||
// Unwatch assignments
|
||||
this.unwatchAssignments();
|
||||
|
||||
// Clear all timers
|
||||
for (const [agentId, timer] of this.timers) {
|
||||
clearInterval(timer.handle);
|
||||
heartbeatLog.log(`Cleared timer for ${agentId}`);
|
||||
}
|
||||
this.timers.clear();
|
||||
|
||||
heartbeatLog.log("HeartbeatTriggerScheduler stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the scheduler is running.
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an agent for timer-based heartbeat triggers.
|
||||
* @param agentId - The agent ID
|
||||
* @param config - Per-agent heartbeat config
|
||||
*/
|
||||
registerAgent(agentId: string, config: AgentHeartbeatConfig): void {
|
||||
// Skip if not enabled
|
||||
if (config.enabled === false) {
|
||||
heartbeatLog.log(`Skipping timer registration for ${agentId} (disabled)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if no interval configured
|
||||
const intervalMs = config.heartbeatIntervalMs;
|
||||
if (!intervalMs || typeof intervalMs !== "number" || intervalMs <= 0) {
|
||||
heartbeatLog.log(`Skipping timer registration for ${agentId} (no interval)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timer if re-registering
|
||||
this.unregisterAgent(agentId);
|
||||
|
||||
const maxConcurrent = config.maxConcurrentRuns ?? 1;
|
||||
|
||||
const handle = setInterval(() => {
|
||||
void this.onTimerTick(agentId, intervalMs, maxConcurrent);
|
||||
}, intervalMs);
|
||||
|
||||
this.timers.set(agentId, { intervalMs, handle });
|
||||
heartbeatLog.log(`Registered timer for ${agentId} (every ${intervalMs}ms)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an agent, clearing its timer.
|
||||
* @param agentId - The agent ID
|
||||
*/
|
||||
unregisterAgent(agentId: string): void {
|
||||
const timer = this.timers.get(agentId);
|
||||
if (timer) {
|
||||
clearInterval(timer.handle);
|
||||
this.timers.delete(agentId);
|
||||
heartbeatLog.log(`Unregistered timer for ${agentId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of currently registered agent IDs.
|
||||
* Useful for testing.
|
||||
*/
|
||||
getRegisteredAgents(): string[] {
|
||||
return Array.from(this.timers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to agent:assigned events on the AgentStore.
|
||||
* When a task is assigned to an agent, the trigger callback fires
|
||||
* with source "assignment" and the task ID in the context.
|
||||
*/
|
||||
watchAssignments(): void {
|
||||
if (this.assignedListener) return; // Already watching
|
||||
|
||||
this.assignedListener = async (agent, taskId) => {
|
||||
if (!this.running) return;
|
||||
|
||||
try {
|
||||
// Guard: skip if agent already has an active run
|
||||
const activeRun = await this.store.getActiveHeartbeatRun(agent.id);
|
||||
if (activeRun) {
|
||||
heartbeatLog.log(`Assignment trigger skipped for ${agent.id} (active run)`);
|
||||
return;
|
||||
}
|
||||
|
||||
heartbeatLog.log(`Assignment trigger for ${agent.id} (task: ${taskId})`);
|
||||
await this.callback(agent.id, "assignment", {
|
||||
taskId,
|
||||
wakeReason: "assignment",
|
||||
triggerDetail: "task-assigned",
|
||||
});
|
||||
} catch (err) {
|
||||
heartbeatLog.error(`Assignment trigger error for ${agent.id}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
};
|
||||
|
||||
this.store.on("agent:assigned", this.assignedListener);
|
||||
heartbeatLog.log("Watching agent:assigned events");
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from agent:assigned events.
|
||||
*/
|
||||
unwatchAssignments(): void {
|
||||
if (this.assignedListener) {
|
||||
this.store.off("agent:assigned", this.assignedListener);
|
||||
this.assignedListener = null;
|
||||
heartbeatLog.log("Stopped watching agent:assigned events");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a timer tick for an agent.
|
||||
* Checks for active runs before invoking the callback.
|
||||
*/
|
||||
private async onTimerTick(agentId: string, intervalMs: number, maxConcurrent: number): Promise<void> {
|
||||
if (!this.running) return;
|
||||
|
||||
try {
|
||||
// Check for active runs
|
||||
const activeRun = await this.store.getActiveHeartbeatRun(agentId);
|
||||
if (activeRun) {
|
||||
heartbeatLog.log(`Timer tick skipped for ${agentId} (active run)`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.callback(agentId, "timer", {
|
||||
wakeReason: "timer",
|
||||
triggerDetail: "scheduled",
|
||||
intervalMs,
|
||||
});
|
||||
} catch (err) {
|
||||
heartbeatLog.error(`Timer tick error for ${agentId}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +289,69 @@ describe("InProcessRuntime", () => {
|
||||
|
||||
expect(scheduler).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return HeartbeatMonitor after start", async () => {
|
||||
await runtime.start();
|
||||
const monitor = runtime.getHeartbeatMonitor();
|
||||
expect(monitor).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return TriggerScheduler after start", async () => {
|
||||
await runtime.start();
|
||||
const triggerScheduler = runtime.getTriggerScheduler();
|
||||
expect(triggerScheduler).toBeDefined();
|
||||
expect(triggerScheduler!.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return undefined TriggerScheduler before start", () => {
|
||||
expect(runtime.getTriggerScheduler()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("trigger scheduler wiring", () => {
|
||||
it("creates trigger scheduler on start", async () => {
|
||||
await runtime.start();
|
||||
expect(runtime.getTriggerScheduler()).toBeDefined();
|
||||
expect(runtime.getTriggerScheduler()!.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it("stops trigger scheduler on runtime stop", async () => {
|
||||
await runtime.start();
|
||||
const triggerScheduler = runtime.getTriggerScheduler()!;
|
||||
expect(triggerScheduler.isActive()).toBe(true);
|
||||
|
||||
await runtime.stop();
|
||||
expect(triggerScheduler.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("registers existing agents with heartbeat config", async () => {
|
||||
await runtime.start();
|
||||
|
||||
// Create an agent with heartbeat config
|
||||
const agentStore = runtime.getHeartbeatMonitor() as any;
|
||||
// Access the store from the monitor's private field
|
||||
// Since the AgentStore is created internally, we need to access it
|
||||
// via the runtime's private agentStore field
|
||||
const store = (runtime as any).agentStore;
|
||||
if (store) {
|
||||
await store.createAgent({
|
||||
name: "Configured Agent",
|
||||
role: "executor",
|
||||
runtimeConfig: { heartbeatIntervalMs: 30000, enabled: true },
|
||||
});
|
||||
|
||||
// Re-create runtime to test registration on startup
|
||||
await runtime.stop();
|
||||
runtime = new InProcessRuntime(testConfig, mockCentralCore);
|
||||
await runtime.start();
|
||||
|
||||
const scheduler = runtime.getTriggerScheduler();
|
||||
expect(scheduler).toBeDefined();
|
||||
// The agent was created in the previous runtime's store,
|
||||
// so it should be registered in the new runtime
|
||||
expect(scheduler!.getRegisteredAgents().length).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration", () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Scheduler } from "../scheduler.js";
|
||||
import { TaskExecutor, type TaskExecutorOptions } from "../executor.js";
|
||||
import { WorktreePool } from "../worktree-pool.js";
|
||||
import { AgentSemaphore } from "../concurrency.js";
|
||||
import { HeartbeatMonitor } from "../agent-heartbeat.js";
|
||||
import { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "../agent-heartbeat.js";
|
||||
import type {
|
||||
ProjectRuntime,
|
||||
ProjectRuntimeConfig,
|
||||
@@ -71,6 +71,7 @@ export class InProcessRuntime
|
||||
private selfHealingManager?: SelfHealingManager;
|
||||
private agentStore?: AgentStore;
|
||||
private heartbeatMonitor?: HeartbeatMonitor;
|
||||
private triggerScheduler?: HeartbeatTriggerScheduler;
|
||||
/** Maps task IDs to agent IDs for lifecycle tracking */
|
||||
private taskAgentMap = new Map<string, string>();
|
||||
private lastActivityAt: string = new Date().toISOString();
|
||||
@@ -226,7 +227,46 @@ export class InProcessRuntime
|
||||
},
|
||||
});
|
||||
this.heartbeatMonitor.start();
|
||||
runtimeLog.log(`AgentStore and HeartbeatMonitor initialized`);
|
||||
|
||||
// Initialize HeartbeatTriggerScheduler
|
||||
this.triggerScheduler = new HeartbeatTriggerScheduler(
|
||||
this.agentStore,
|
||||
async (agentId, source, context: WakeContext) => {
|
||||
if (!this.heartbeatMonitor) return;
|
||||
|
||||
// Convert WakeContext to WakeupOptions
|
||||
const options = {
|
||||
source,
|
||||
triggerDetail: context.triggerDetail,
|
||||
contextSnapshot: { ...context },
|
||||
};
|
||||
|
||||
await this.heartbeatMonitor.startRun(agentId, options);
|
||||
},
|
||||
);
|
||||
this.triggerScheduler.start();
|
||||
|
||||
// Register existing agents that have heartbeat config
|
||||
try {
|
||||
const agents = await this.agentStore.listAgents();
|
||||
for (const agent of agents) {
|
||||
const rc = agent.runtimeConfig;
|
||||
if (rc && (rc.heartbeatIntervalMs || rc.enabled !== undefined || rc.maxConcurrentRuns)) {
|
||||
this.triggerScheduler.registerAgent(agent.id, {
|
||||
heartbeatIntervalMs: rc.heartbeatIntervalMs as number | undefined,
|
||||
enabled: rc.enabled as boolean | undefined,
|
||||
maxConcurrentRuns: rc.maxConcurrentRuns as number | undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (agents.length > 0) {
|
||||
runtimeLog.log(`Registered ${this.triggerScheduler.getRegisteredAgents().length} agents for heartbeat triggers`);
|
||||
}
|
||||
} catch (regErr) {
|
||||
runtimeLog.warn(`Failed to register agents for heartbeat triggers:`, regErr);
|
||||
}
|
||||
|
||||
runtimeLog.log(`AgentStore, HeartbeatMonitor, and TriggerScheduler initialized`);
|
||||
} catch (agentErr) {
|
||||
// Non-fatal — agent monitoring is optional
|
||||
runtimeLog.warn(`AgentStore initialization failed (continuing without agent monitoring):`, agentErr);
|
||||
@@ -287,13 +327,19 @@ export class InProcessRuntime
|
||||
runtimeLog.log("SelfHealingManager stopped");
|
||||
}
|
||||
|
||||
// 2. Stop heartbeat monitor
|
||||
// 2. Stop trigger scheduler
|
||||
if (this.triggerScheduler) {
|
||||
this.triggerScheduler.stop();
|
||||
runtimeLog.log("TriggerScheduler stopped");
|
||||
}
|
||||
|
||||
// 3. Stop heartbeat monitor
|
||||
if (this.heartbeatMonitor) {
|
||||
this.heartbeatMonitor.stop();
|
||||
runtimeLog.log("HeartbeatMonitor stopped");
|
||||
}
|
||||
|
||||
// 3. Stop scheduler (prevents new task scheduling)
|
||||
// 4. Stop scheduler (prevents new task scheduling)
|
||||
if (this.scheduler) {
|
||||
this.scheduler.stop();
|
||||
runtimeLog.log("Scheduler stopped");
|
||||
@@ -401,6 +447,14 @@ export class InProcessRuntime
|
||||
return this.heartbeatMonitor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HeartbeatTriggerScheduler instance (if initialized).
|
||||
* Returns undefined when agent monitoring is not available.
|
||||
*/
|
||||
getTriggerScheduler(): HeartbeatTriggerScheduler | undefined {
|
||||
return this.triggerScheduler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a heartbeat run for an agent.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user