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:
47
AGENTS.md
47
AGENTS.md
@@ -640,7 +640,8 @@ Each agent can override the global heartbeat monitoring settings via `runtimeCon
|
||||
|
||||
| Key | Default | Min | Description |
|
||||
|-----|---------|-----|-------------|
|
||||
| `heartbeatIntervalMs` | 30000 | 1000 | How often heartbeats are checked |
|
||||
| `enabled` | true | — | Whether heartbeat triggers are enabled for this agent |
|
||||
| `heartbeatIntervalMs` | 30000 | 1000 | How often heartbeats are checked / timer trigger interval |
|
||||
| `heartbeatTimeoutMs` | 60000 | 5000 | Time without heartbeat before agent is considered unresponsive |
|
||||
| `maxConcurrentRuns` | 1 | 1 | Max concurrent heartbeat runs per agent |
|
||||
|
||||
@@ -661,6 +662,50 @@ The agent detail ConfigTab includes a "Heartbeat Settings" section where users c
|
||||
- `HeartbeatMonitor.getAgentHeartbeatConfig(agentId)` — Returns the resolved config for an agent
|
||||
- `AgentStore.getCachedAgent(agentId)` — Synchronous agent read for hot paths
|
||||
|
||||
## Heartbeat Trigger Scheduling
|
||||
|
||||
The `HeartbeatTriggerScheduler` class (exported from `@fusion/engine`) manages three trigger mechanisms that wake agents via heartbeat runs:
|
||||
|
||||
### Trigger Types
|
||||
|
||||
| Trigger | Source | Description |
|
||||
|---------|--------|-------------|
|
||||
| **Timer** | `"timer"` | Periodic wakeup based on `AgentHeartbeatConfig.heartbeatIntervalMs` |
|
||||
| **Assignment** | `"assignment"` | Automatic wakeup when a task is assigned to the agent |
|
||||
| **On-demand** | `"on_demand"` | Manual trigger via `POST /api/agents/:id/runs` |
|
||||
|
||||
### WakeContext
|
||||
|
||||
Each trigger passes a structured `WakeContext` to the execution path:
|
||||
|
||||
```typescript
|
||||
interface WakeContext {
|
||||
taskId?: string; // Optional task ID (present for assignment triggers)
|
||||
wakeReason: string; // Why the agent was woken
|
||||
triggerDetail: string; // Detail about the specific trigger
|
||||
[key: string]: unknown; // Additional context
|
||||
}
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. `HeartbeatTriggerScheduler` is created and started by `InProcessRuntime` during initialization
|
||||
2. Timer triggers: Agents with `heartbeatIntervalMs` configured get periodic `setInterval`-based wakeups
|
||||
3. Assignment triggers: The scheduler subscribes to `agent:assigned` events from `AgentStore`
|
||||
4. On-demand triggers: The `POST /api/agents/:id/runs` route creates runs with wake context
|
||||
5. All triggers respect `maxConcurrentRuns` — skipped if the agent already has an active run
|
||||
6. On runtime stop, all timers are cleared and event listeners are removed
|
||||
|
||||
### AgentStore Events
|
||||
|
||||
- `"agent:assigned"` — Emitted by `AgentStore.assignTask()` when a non-empty taskId is assigned. Signature: `(agent: Agent, taskId: string) => void`
|
||||
|
||||
### InProcessRuntime Integration
|
||||
|
||||
- `InProcessRuntime.start()` creates the trigger scheduler, starts it, and registers existing agents with heartbeat configs
|
||||
- `InProcessRuntime.stop()` stops the trigger scheduler before stopping the HeartbeatMonitor
|
||||
- `InProcessRuntime.getTriggerScheduler()` — Returns the scheduler instance for testing access
|
||||
|
||||
## Dashboard Task Creation
|
||||
|
||||
The dashboard provides two UI surfaces for creating tasks:
|
||||
|
||||
@@ -511,6 +511,32 @@ describe("AgentStore", () => {
|
||||
expect(updatedAgent.taskId).toBe("KB-002");
|
||||
});
|
||||
|
||||
it("emits 'agent:assigned' event when assigning a task", async () => {
|
||||
const agent = await store.createAgent({ name: "AssignEvent", role: "executor" });
|
||||
const handler = vi.fn();
|
||||
store.on("agent:assigned", handler);
|
||||
|
||||
await store.assignTask(agent.id, "KB-003");
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
const [updatedAgent, taskId] = handler.mock.calls[0];
|
||||
expect(updatedAgent.id).toBe(agent.id);
|
||||
expect(updatedAgent.taskId).toBe("KB-003");
|
||||
expect(taskId).toBe("KB-003");
|
||||
});
|
||||
|
||||
it("does NOT emit 'agent:assigned' when clearing taskId", async () => {
|
||||
const agent = await store.createAgent({ name: "UnassignEvent", role: "executor" });
|
||||
await store.assignTask(agent.id, "KB-004");
|
||||
|
||||
const handler = vi.fn();
|
||||
store.on("agent:assigned", handler);
|
||||
|
||||
await store.assignTask(agent.id, undefined);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws for non-existent agent", async () => {
|
||||
await expect(
|
||||
store.assignTask("agent-missing", "KB-001")
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface AgentStoreEvents {
|
||||
"agent:heartbeat": (agentId: string, event: AgentHeartbeatEvent) => void;
|
||||
/** Emitted when an agent state changes */
|
||||
"agent:stateChanged": (agentId: string, from: AgentState, to: AgentState) => void;
|
||||
/** Emitted when a task is assigned to an agent (taskId is non-empty) */
|
||||
"agent:assigned": (agent: Agent, taskId: string) => void;
|
||||
}
|
||||
|
||||
type TypedEventEmitter<Events extends Record<string, unknown[]>> = {
|
||||
@@ -293,6 +295,11 @@ export class AgentStore extends EventEmitter {
|
||||
await this.writeAgent(updated);
|
||||
this.emit("agent:updated", updated);
|
||||
|
||||
// Emit agent:assigned only when assigning a task (not when clearing)
|
||||
if (taskId !== undefined) {
|
||||
this.emit("agent:assigned", updated, taskId);
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1508,6 +1508,8 @@ export interface Agent {
|
||||
|
||||
/** Per-agent heartbeat configuration, stored in agent.runtimeConfig */
|
||||
export interface AgentHeartbeatConfig {
|
||||
/** Whether heartbeat triggers are enabled for this agent (default: true) */
|
||||
enabled?: boolean;
|
||||
/** Polling interval in ms (default: 30000). Min: 1000 */
|
||||
heartbeatIntervalMs?: number;
|
||||
/** Heartbeat timeout in ms (default: 60000). Min: 5000 */
|
||||
|
||||
@@ -12,6 +12,7 @@ const mockGetRunDetail = vi.fn();
|
||||
const mockRecordHeartbeat = vi.fn();
|
||||
const mockUpdateAgentState = vi.fn();
|
||||
const mockListAgents = vi.fn().mockResolvedValue([]);
|
||||
const mockGetActiveHeartbeatRun = vi.fn().mockResolvedValue(null);
|
||||
|
||||
vi.mock("@fusion/core", () => {
|
||||
return {
|
||||
@@ -24,6 +25,7 @@ vi.mock("@fusion/core", () => {
|
||||
recordHeartbeat = mockRecordHeartbeat;
|
||||
updateAgentState = mockUpdateAgentState;
|
||||
listAgents = mockListAgents;
|
||||
getActiveHeartbeatRun = mockGetActiveHeartbeatRun;
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -74,6 +76,7 @@ describe("Agent runs routes (without HeartbeatMonitor)", () => {
|
||||
vi.clearAllMocks();
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockListAgents.mockResolvedValue([]);
|
||||
mockGetActiveHeartbeatRun.mockResolvedValue(null);
|
||||
|
||||
store = new MockStore();
|
||||
const { createServer } = await import("../server.js");
|
||||
@@ -266,6 +269,7 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
vi.clearAllMocks();
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockListAgents.mockResolvedValue([]);
|
||||
mockGetActiveHeartbeatRun.mockResolvedValue(null);
|
||||
|
||||
mockStartRun = vi.fn();
|
||||
mockExecuteHeartbeat = vi.fn();
|
||||
@@ -302,12 +306,17 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
expect(mockStartRun).toHaveBeenCalledWith("agent-001", {
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
contextSnapshot: {
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
},
|
||||
});
|
||||
// executeHeartbeat should be called fire-and-forget
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
taskId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,6 +336,10 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
expect(mockStartRun).toHaveBeenCalledWith("agent-001", {
|
||||
source: "timer",
|
||||
triggerDetail: "Scheduled run",
|
||||
contextSnapshot: {
|
||||
wakeReason: "timer",
|
||||
triggerDetail: "Scheduled run",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7868,6 +7868,52 @@ describe("POST /api/agents/:id/runs", () => {
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("accepts taskId in body and includes it in contextSnapshot", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
`/api/agents/${agentId}/runs`,
|
||||
JSON.stringify({ source: "on_demand", triggerDetail: "manual", taskId: "FN-001" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.contextSnapshot).toMatchObject({
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
taskId: "FN-001",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes wake context without taskId when not provided", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
`/api/agents/${agentId}/runs`,
|
||||
JSON.stringify({ source: "timer", triggerDetail: "scheduled" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.contextSnapshot).toMatchObject({
|
||||
wakeReason: "timer",
|
||||
triggerDetail: "scheduled",
|
||||
});
|
||||
expect(res.body.contextSnapshot.taskId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns 409 when agent already has an active run", async () => {
|
||||
// Create first run
|
||||
const res1 = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
|
||||
expect(res1.status).toBe(201);
|
||||
|
||||
// Try to create second run — should conflict
|
||||
const res2 = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
|
||||
expect(res2.status).toBe(409);
|
||||
expect(res2.body.error).toContain("active run");
|
||||
expect(res2.body.runId).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/agents/:id/runs/:runId/logs", () => {
|
||||
|
||||
@@ -7263,25 +7263,49 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
/**
|
||||
* POST /api/agents/:id/runs
|
||||
* Manually start a heartbeat run for an agent.
|
||||
* Body: { source?: HeartbeatInvocationSource, triggerDetail?: string }
|
||||
* Body: { source?: HeartbeatInvocationSource, triggerDetail?: string, taskId?: string }
|
||||
*
|
||||
* When HeartbeatMonitor is available, delegates to startRun() which enriches
|
||||
* the run with execution context, transitions the agent to "running", and
|
||||
* fires the onRunStarted event. The route returns the run immediately with
|
||||
* "active" status while execution continues in the background via
|
||||
* executeHeartbeat() fire-and-forget.
|
||||
*
|
||||
* Returns 409 Conflict if the agent already has an active run.
|
||||
*/
|
||||
router.post("/agents/:id/runs", async (req, res) => {
|
||||
try {
|
||||
const { source, triggerDetail } = req.body || {};
|
||||
const { source, triggerDetail, taskId } = req.body || {};
|
||||
const invocationSource = source ?? "on_demand";
|
||||
const trigger = triggerDetail ?? "Triggered from dashboard";
|
||||
|
||||
// Build structured wake context
|
||||
const contextSnapshot: Record<string, unknown> = {
|
||||
wakeReason: invocationSource,
|
||||
triggerDetail: trigger,
|
||||
};
|
||||
if (taskId) {
|
||||
contextSnapshot.taskId = taskId;
|
||||
}
|
||||
|
||||
if (hasHeartbeatExecutor && heartbeatMonitor) {
|
||||
// Check for existing active run
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore: AgentStoreClass } = await import("@fusion/core");
|
||||
const agentStore = new AgentStoreClass({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const activeRun = await agentStore.getActiveHeartbeatRun(req.params.id);
|
||||
if (activeRun) {
|
||||
res.status(409).json({ error: "Agent already has an active run", runId: activeRun.id });
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to HeartbeatMonitor for enriched run creation
|
||||
const run = await heartbeatMonitor.startRun(req.params.id, {
|
||||
source: invocationSource,
|
||||
triggerDetail: trigger,
|
||||
contextSnapshot,
|
||||
});
|
||||
|
||||
// Fire-and-forget execution in the background
|
||||
@@ -7289,6 +7313,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
agentId: req.params.id,
|
||||
source: invocationSource,
|
||||
triggerDetail: trigger,
|
||||
taskId,
|
||||
}).catch((err: any) => {
|
||||
console.error(`[heartbeat] Background execution failed for ${req.params.id}:`, err.message);
|
||||
});
|
||||
@@ -7301,13 +7326,19 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
// Check for existing active run
|
||||
const activeRun = await agentStore.getActiveHeartbeatRun(req.params.id);
|
||||
if (activeRun) {
|
||||
res.status(409).json({ error: "Agent already has an active run", runId: activeRun.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const run = await agentStore.startHeartbeatRun(req.params.id);
|
||||
|
||||
// Enrich with invocation source and trigger detail
|
||||
// Enrich with invocation source, trigger detail, and context snapshot
|
||||
(run as any).invocationSource = invocationSource;
|
||||
if (triggerDetail) {
|
||||
(run as any).triggerDetail = triggerDetail;
|
||||
}
|
||||
(run as any).triggerDetail = triggerDetail;
|
||||
(run as any).contextSnapshot = contextSnapshot;
|
||||
|
||||
await agentStore.saveRun(run);
|
||||
res.status(201).json(run);
|
||||
|
||||
@@ -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