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:
gsxdsm
2026-04-07 15:55:09 -07:00
parent 0f0ddc02fb
commit 06fe36a64c
11 changed files with 800 additions and 13 deletions

View File

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

View File

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

View File

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

View File

@@ -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.
*