feat(FN-1065): add immediate message response mode for heartbeats
- Add MessageResponseMode and messageResponseMode runtime config to core agent heartbeat types and exports - Extend MessageStore with an onMessageToAgent hook plus runtime hook updates for agent-directed messages - Wire HeartbeatMonitor to wake agents on incoming messages when messageResponseMode is immediate and state is eligible - Update AgentDetailView with a Message Response Mode setting, validation, and runtimeConfig persistence - Expand core and engine tests to cover hook behavior, wake-on-message triggering, and start/stop hook lifecycle
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
|
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
|
||||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentHeartbeatConfig, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
|
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentHeartbeatConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
|
||||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||||
export {
|
export {
|
||||||
BUILTIN_AGENT_PROMPTS,
|
BUILTIN_AGENT_PROMPTS,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -95,6 +95,86 @@ describe("MessageStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("message-to-agent hook", () => {
|
||||||
|
it("does not call the hook for non-agent recipients", async () => {
|
||||||
|
const hook = vi.fn();
|
||||||
|
const hookedStore = new MessageStore({ rootDir: tempDir, onMessageToAgent: hook });
|
||||||
|
await hookedStore.init();
|
||||||
|
|
||||||
|
await hookedStore.sendMessage({
|
||||||
|
fromId: "agent-1",
|
||||||
|
fromType: "agent",
|
||||||
|
toId: "user-1",
|
||||||
|
toType: "user",
|
||||||
|
content: "Hello user",
|
||||||
|
type: "agent-to-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hook).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the hook when a message is sent to an agent", async () => {
|
||||||
|
const hook = vi.fn();
|
||||||
|
const hookedStore = new MessageStore({ rootDir: tempDir, onMessageToAgent: hook });
|
||||||
|
await hookedStore.init();
|
||||||
|
|
||||||
|
const message = await hookedStore.sendMessage({
|
||||||
|
fromId: "user-1",
|
||||||
|
fromType: "user",
|
||||||
|
toId: "agent-1",
|
||||||
|
toType: "agent",
|
||||||
|
content: "Hello agent",
|
||||||
|
type: "user-to-agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hook).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hook).toHaveBeenCalledWith(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when no hook is configured", async () => {
|
||||||
|
await expect(
|
||||||
|
store.sendMessage({
|
||||||
|
fromId: "user-1",
|
||||||
|
fromType: "user",
|
||||||
|
toId: "agent-1",
|
||||||
|
toType: "agent",
|
||||||
|
content: "No hook configured",
|
||||||
|
type: "user-to-agent",
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ toId: "agent-1", toType: "agent" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setMessageToAgentHook updates the hook used for subsequent messages", async () => {
|
||||||
|
const firstHook = vi.fn();
|
||||||
|
const secondHook = vi.fn();
|
||||||
|
const hookedStore = new MessageStore({ rootDir: tempDir, onMessageToAgent: firstHook });
|
||||||
|
await hookedStore.init();
|
||||||
|
|
||||||
|
await hookedStore.sendMessage({
|
||||||
|
fromId: "user-1",
|
||||||
|
fromType: "user",
|
||||||
|
toId: "agent-1",
|
||||||
|
toType: "agent",
|
||||||
|
content: "First",
|
||||||
|
type: "user-to-agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
hookedStore.setMessageToAgentHook(secondHook);
|
||||||
|
|
||||||
|
await hookedStore.sendMessage({
|
||||||
|
fromId: "user-1",
|
||||||
|
fromType: "user",
|
||||||
|
toId: "agent-1",
|
||||||
|
toType: "agent",
|
||||||
|
content: "Second",
|
||||||
|
type: "user-to-agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstHook).toHaveBeenCalledTimes(1);
|
||||||
|
expect(secondHook).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getInbox()", () => {
|
describe("getInbox()", () => {
|
||||||
it("returns inbox messages for a participant", async () => {
|
it("returns inbox messages for a participant", async () => {
|
||||||
await store.sendMessage({
|
await store.sendMessage({
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface MessageStoreEvents {
|
|||||||
export interface MessageStoreOptions {
|
export interface MessageStoreOptions {
|
||||||
/** Root directory for kb data (default: .fusion) */
|
/** Root directory for kb data (default: .fusion) */
|
||||||
rootDir?: string;
|
rootDir?: string;
|
||||||
|
/** Optional hook invoked when a message is addressed to an agent */
|
||||||
|
onMessageToAgent?: (message: Message) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Index structure for mailbox lookups */
|
/** Index structure for mailbox lookups */
|
||||||
@@ -55,12 +57,14 @@ export class MessageStore extends EventEmitter {
|
|||||||
private rootDir: string;
|
private rootDir: string;
|
||||||
private messagesDir: string;
|
private messagesDir: string;
|
||||||
private indexPath: string;
|
private indexPath: string;
|
||||||
|
private onMessageToAgent?: (message: Message) => void;
|
||||||
|
|
||||||
constructor(options: MessageStoreOptions = {}) {
|
constructor(options: MessageStoreOptions = {}) {
|
||||||
super();
|
super();
|
||||||
this.rootDir = options.rootDir ?? ".fusion";
|
this.rootDir = options.rootDir ?? ".fusion";
|
||||||
this.messagesDir = join(this.rootDir, "messages");
|
this.messagesDir = join(this.rootDir, "messages");
|
||||||
this.indexPath = join(this.messagesDir, "index.json");
|
this.indexPath = join(this.messagesDir, "index.json");
|
||||||
|
this.onMessageToAgent = options.onMessageToAgent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,6 +113,10 @@ export class MessageStore extends EventEmitter {
|
|||||||
this.emit("message:sent", message);
|
this.emit("message:sent", message);
|
||||||
this.emit("message:received", message);
|
this.emit("message:received", message);
|
||||||
|
|
||||||
|
if (message.toType === "agent" && this.onMessageToAgent) {
|
||||||
|
this.onMessageToAgent(message);
|
||||||
|
}
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +299,13 @@ export class MessageStore extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or update the hook used when messages are sent to agents.
|
||||||
|
*/
|
||||||
|
setMessageToAgentHook(hook: (message: Message) => void): void {
|
||||||
|
this.onMessageToAgent = hook;
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Private helpers
|
// Private helpers
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1506,6 +1506,8 @@ export interface Agent {
|
|||||||
instructionsText?: string;
|
instructionsText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MessageResponseMode = "immediate" | "on-heartbeat";
|
||||||
|
|
||||||
/** Per-agent heartbeat configuration, stored in agent.runtimeConfig */
|
/** Per-agent heartbeat configuration, stored in agent.runtimeConfig */
|
||||||
export interface AgentHeartbeatConfig {
|
export interface AgentHeartbeatConfig {
|
||||||
/** Whether heartbeat triggers are enabled for this agent (default: true) */
|
/** Whether heartbeat triggers are enabled for this agent (default: true) */
|
||||||
@@ -1516,6 +1518,12 @@ export interface AgentHeartbeatConfig {
|
|||||||
heartbeatTimeoutMs?: number;
|
heartbeatTimeoutMs?: number;
|
||||||
/** Max concurrent heartbeat runs per agent (default: 1). Min: 1 */
|
/** Max concurrent heartbeat runs per agent (default: 1). Min: 1 */
|
||||||
maxConcurrentRuns?: number;
|
maxConcurrentRuns?: number;
|
||||||
|
/**
|
||||||
|
* How this agent responds to incoming messages.
|
||||||
|
* "immediate" triggers a heartbeat run when a message arrives.
|
||||||
|
* "on-heartbeat" defers message handling to the next scheduled heartbeat (default).
|
||||||
|
*/
|
||||||
|
messageResponseMode?: MessageResponseMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extended agent information including heartbeat history */
|
/** Extended agent information including heartbeat history */
|
||||||
|
|||||||
@@ -1175,6 +1175,9 @@ function ConfigTab({
|
|||||||
if (rc.heartbeatTimeoutMs !== undefined && rc.heartbeatTimeoutMs !== null) {
|
if (rc.heartbeatTimeoutMs !== undefined && rc.heartbeatTimeoutMs !== null) {
|
||||||
initial.heartbeatTimeoutMs = String(rc.heartbeatTimeoutMs);
|
initial.heartbeatTimeoutMs = String(rc.heartbeatTimeoutMs);
|
||||||
}
|
}
|
||||||
|
if (rc.messageResponseMode === "immediate" || rc.messageResponseMode === "on-heartbeat") {
|
||||||
|
initial.messageResponseMode = rc.messageResponseMode;
|
||||||
|
}
|
||||||
return initial;
|
return initial;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1199,7 +1202,7 @@ function ConfigTab({
|
|||||||
}
|
}
|
||||||
// Check heartbeat values
|
// Check heartbeat values
|
||||||
const rc = agent.runtimeConfig ?? {};
|
const rc = agent.runtimeConfig ?? {};
|
||||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs"] as const) {
|
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "messageResponseMode"] as const) {
|
||||||
const current = heartbeatValues[key]?.trim() ?? "";
|
const current = heartbeatValues[key]?.trim() ?? "";
|
||||||
const persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
|
const persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
|
||||||
if (current !== persisted) return true;
|
if (current !== persisted) return true;
|
||||||
@@ -1259,6 +1262,11 @@ function ConfigTab({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageResponseModeForValidation = heartbeatValues.messageResponseMode?.trim();
|
||||||
|
if (messageResponseModeForValidation && !["immediate", "on-heartbeat"].includes(messageResponseModeForValidation)) {
|
||||||
|
validationErrors.messageResponseMode = "\"Message Response Mode\" must be either immediate or on-heartbeat";
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(validationErrors).length > 0) {
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
setErrors(validationErrors);
|
setErrors(validationErrors);
|
||||||
addToast("Please fix validation errors before saving", "error");
|
addToast("Please fix validation errors before saving", "error");
|
||||||
@@ -1290,6 +1298,13 @@ function ConfigTab({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageResponseMode = heartbeatValues.messageResponseMode?.trim();
|
||||||
|
if (!messageResponseMode) {
|
||||||
|
delete newRuntimeConfig.messageResponseMode;
|
||||||
|
} else {
|
||||||
|
newRuntimeConfig.messageResponseMode = messageResponseMode;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await updateAgent(agent.id, { metadata: newMetadata, runtimeConfig: newRuntimeConfig }, projectId);
|
await updateAgent(agent.id, { metadata: newMetadata, runtimeConfig: newRuntimeConfig }, projectId);
|
||||||
@@ -1404,6 +1419,25 @@ function ConfigTab({
|
|||||||
<span className="config-hint">Time without heartbeat before agent is considered unresponsive. Leave empty for system default (60000ms)</span>
|
<span className="config-hint">Time without heartbeat before agent is considered unresponsive. Leave empty for system default (60000ms)</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="config-field">
|
||||||
|
<label htmlFor="hb-messageResponseMode">Message Response Mode</label>
|
||||||
|
<select
|
||||||
|
id="hb-messageResponseMode"
|
||||||
|
className={cn("select", !!errors.messageResponseMode && "input--error")}
|
||||||
|
value={heartbeatValues.messageResponseMode ?? ""}
|
||||||
|
onChange={(e) => handleHeartbeatFieldChange("messageResponseMode", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">System Default (On Heartbeat)</option>
|
||||||
|
<option value="on-heartbeat">On Heartbeat</option>
|
||||||
|
<option value="immediate">Immediate</option>
|
||||||
|
</select>
|
||||||
|
{errors.messageResponseMode ? (
|
||||||
|
<span className="config-error">{errors.messageResponseMode}</span>
|
||||||
|
) : (
|
||||||
|
<span className="config-hint">How this agent responds to incoming messages. 'Immediate' wakes the agent as soon as a message arrives. 'On Heartbeat' defers processing to the next scheduled heartbeat.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { HeartbeatMonitor, HeartbeatTriggerScheduler, 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";
|
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message } from "@fusion/core";
|
||||||
|
|
||||||
// Mock logger to suppress noise in test output
|
// Mock logger to suppress noise in test output
|
||||||
vi.mock("./logger.js", () => {
|
vi.mock("./logger.js", () => {
|
||||||
@@ -43,6 +43,31 @@ function createMockSession(): AgentSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMockMessageStore(onSetHook?: (hook: (message: Message) => void) => void): MessageStore {
|
||||||
|
return {
|
||||||
|
setMessageToAgentHook: vi.fn((hook: (message: Message) => void) => {
|
||||||
|
onSetHook?.(hook);
|
||||||
|
}),
|
||||||
|
} as unknown as MessageStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMessage(overrides: Partial<Message> = {}): Message {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
id: "msg-001",
|
||||||
|
fromId: "user-1",
|
||||||
|
fromType: "user",
|
||||||
|
toId: "agent-1",
|
||||||
|
toType: "agent",
|
||||||
|
content: "hello",
|
||||||
|
type: "user-to-agent",
|
||||||
|
read: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("HeartbeatMonitor", () => {
|
describe("HeartbeatMonitor", () => {
|
||||||
let store: AgentStore;
|
let store: AgentStore;
|
||||||
let monitor: HeartbeatMonitor;
|
let monitor: HeartbeatMonitor;
|
||||||
@@ -139,6 +164,164 @@ describe("HeartbeatMonitor", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("wake-on-message", () => {
|
||||||
|
it("executes heartbeat when messageResponseMode is immediate", () => {
|
||||||
|
let messageHook: ((message: Message) => void) | undefined;
|
||||||
|
const messageStore = createMockMessageStore((hook) => {
|
||||||
|
messageHook = hook;
|
||||||
|
});
|
||||||
|
const configStore = createMockStore({
|
||||||
|
getCachedAgent: vi.fn().mockReturnValue({
|
||||||
|
id: "agent-1",
|
||||||
|
state: "active",
|
||||||
|
runtimeConfig: { messageResponseMode: "immediate" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customMonitor = new HeartbeatMonitor({
|
||||||
|
store,
|
||||||
|
agentStore: configStore,
|
||||||
|
messageStore,
|
||||||
|
});
|
||||||
|
const executeHeartbeatSpy = vi
|
||||||
|
.spyOn(customMonitor, "executeHeartbeat")
|
||||||
|
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
|
||||||
|
|
||||||
|
customMonitor.start();
|
||||||
|
messageHook?.(createMessage({ toId: "agent-1", toType: "agent" }));
|
||||||
|
|
||||||
|
expect(executeHeartbeatSpy).toHaveBeenCalledWith({
|
||||||
|
agentId: "agent-1",
|
||||||
|
source: "on_demand",
|
||||||
|
triggerDetail: "wake-on-message",
|
||||||
|
});
|
||||||
|
|
||||||
|
customMonitor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not execute heartbeat when messageResponseMode is on-heartbeat or unset", () => {
|
||||||
|
let messageHook: ((message: Message) => void) | undefined;
|
||||||
|
const messageStore = createMockMessageStore((hook) => {
|
||||||
|
messageHook = hook;
|
||||||
|
});
|
||||||
|
const getCachedAgent = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
id: "agent-1",
|
||||||
|
state: "active",
|
||||||
|
runtimeConfig: { messageResponseMode: "on-heartbeat" },
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
id: "agent-1",
|
||||||
|
state: "active",
|
||||||
|
runtimeConfig: {},
|
||||||
|
});
|
||||||
|
const configStore = createMockStore({ getCachedAgent });
|
||||||
|
|
||||||
|
const customMonitor = new HeartbeatMonitor({
|
||||||
|
store,
|
||||||
|
agentStore: configStore,
|
||||||
|
messageStore,
|
||||||
|
});
|
||||||
|
const executeHeartbeatSpy = vi
|
||||||
|
.spyOn(customMonitor, "executeHeartbeat")
|
||||||
|
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
|
||||||
|
|
||||||
|
customMonitor.start();
|
||||||
|
messageHook?.(createMessage({ toId: "agent-1", toType: "agent", id: "msg-1" }));
|
||||||
|
messageHook?.(createMessage({ toId: "agent-1", toType: "agent", id: "msg-2" }));
|
||||||
|
|
||||||
|
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
customMonitor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not execute heartbeat when agent is paused or error", () => {
|
||||||
|
let messageHook: ((message: Message) => void) | undefined;
|
||||||
|
const messageStore = createMockMessageStore((hook) => {
|
||||||
|
messageHook = hook;
|
||||||
|
});
|
||||||
|
const getCachedAgent = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
id: "agent-1",
|
||||||
|
state: "paused",
|
||||||
|
runtimeConfig: { messageResponseMode: "immediate" },
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
id: "agent-1",
|
||||||
|
state: "error",
|
||||||
|
runtimeConfig: { messageResponseMode: "immediate" },
|
||||||
|
});
|
||||||
|
const configStore = createMockStore({ getCachedAgent });
|
||||||
|
|
||||||
|
const customMonitor = new HeartbeatMonitor({
|
||||||
|
store,
|
||||||
|
agentStore: configStore,
|
||||||
|
messageStore,
|
||||||
|
});
|
||||||
|
const executeHeartbeatSpy = vi
|
||||||
|
.spyOn(customMonitor, "executeHeartbeat")
|
||||||
|
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
|
||||||
|
|
||||||
|
customMonitor.start();
|
||||||
|
messageHook?.(createMessage({ toId: "agent-1", toType: "agent", id: "msg-paused" }));
|
||||||
|
messageHook?.(createMessage({ toId: "agent-1", toType: "agent", id: "msg-error" }));
|
||||||
|
|
||||||
|
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
customMonitor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers the message hook on start and clears it on stop", () => {
|
||||||
|
const hooks: Array<(message: Message) => void> = [];
|
||||||
|
const messageStore = createMockMessageStore((hook) => {
|
||||||
|
hooks.push(hook);
|
||||||
|
});
|
||||||
|
const customMonitor = new HeartbeatMonitor({ store, messageStore });
|
||||||
|
|
||||||
|
customMonitor.start();
|
||||||
|
|
||||||
|
expect(messageStore.setMessageToAgentHook).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hooks).toHaveLength(1);
|
||||||
|
|
||||||
|
customMonitor.stop();
|
||||||
|
|
||||||
|
expect(messageStore.setMessageToAgentHook).toHaveBeenCalledTimes(2);
|
||||||
|
expect(hooks).toHaveLength(2);
|
||||||
|
expect(hooks[0]).not.toBe(hooks[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-agent messages", () => {
|
||||||
|
let messageHook: ((message: Message) => void) | undefined;
|
||||||
|
const messageStore = createMockMessageStore((hook) => {
|
||||||
|
messageHook = hook;
|
||||||
|
});
|
||||||
|
const configStore = createMockStore({
|
||||||
|
getCachedAgent: vi.fn().mockReturnValue({
|
||||||
|
id: "agent-1",
|
||||||
|
state: "active",
|
||||||
|
runtimeConfig: { messageResponseMode: "immediate" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const customMonitor = new HeartbeatMonitor({
|
||||||
|
store,
|
||||||
|
agentStore: configStore,
|
||||||
|
messageStore,
|
||||||
|
});
|
||||||
|
const executeHeartbeatSpy = vi
|
||||||
|
.spyOn(customMonitor, "executeHeartbeat")
|
||||||
|
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
|
||||||
|
|
||||||
|
customMonitor.start();
|
||||||
|
messageHook?.(createMessage({ toType: "user", toId: "user-1" }));
|
||||||
|
|
||||||
|
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
customMonitor.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("isActive", () => {
|
describe("isActive", () => {
|
||||||
it("reflects monitor state (false when not started)", () => {
|
it("reflects monitor state (false when not started)", () => {
|
||||||
expect(monitor.isActive()).toBe(false);
|
expect(monitor.isActive()).toBe(false);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* - onTerminated: Called when an unresponsive agent is terminated
|
* - onTerminated: Called when an unresponsive agent is terminated
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, TaskStore, TaskDetail } from "@fusion/core";
|
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, Message, MessageStore, TaskStore, TaskDetail } from "@fusion/core";
|
||||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||||
import { createTaskCreateTool, createTaskLogTool, taskCreateParams } from "./agent-tools.js";
|
import { createTaskCreateTool, createTaskLogTool, taskCreateParams } from "./agent-tools.js";
|
||||||
@@ -42,6 +42,8 @@ export interface HeartbeatMonitorOptions {
|
|||||||
/** Optional separate AgentStore reference for reading per-agent runtimeConfig.
|
/** Optional separate AgentStore reference for reading per-agent runtimeConfig.
|
||||||
* If not provided, falls back to `store`. */
|
* If not provided, falls back to `store`. */
|
||||||
agentStore?: AgentStore;
|
agentStore?: AgentStore;
|
||||||
|
/** Optional MessageStore for wake-on-message behavior */
|
||||||
|
messageStore?: MessageStore;
|
||||||
/** Polling interval in milliseconds (default: 30000) */
|
/** Polling interval in milliseconds (default: 30000) */
|
||||||
pollIntervalMs?: number;
|
pollIntervalMs?: number;
|
||||||
/** Heartbeat timeout in milliseconds (default: 60000) */
|
/** Heartbeat timeout in milliseconds (default: 60000) */
|
||||||
@@ -144,6 +146,7 @@ export class HeartbeatMonitor {
|
|||||||
private onRunCompleted?: (agentId: string, run: AgentHeartbeatRun) => void;
|
private onRunCompleted?: (agentId: string, run: AgentHeartbeatRun) => void;
|
||||||
private taskStore?: TaskStore;
|
private taskStore?: TaskStore;
|
||||||
private rootDir?: string;
|
private rootDir?: string;
|
||||||
|
private messageStore?: MessageStore;
|
||||||
|
|
||||||
private trackedAgents: Map<string, TrackedAgent> = new Map();
|
private trackedAgents: Map<string, TrackedAgent> = new Map();
|
||||||
private agentStartLocks: Map<string, Promise<unknown>> = new Map();
|
private agentStartLocks: Map<string, Promise<unknown>> = new Map();
|
||||||
@@ -166,6 +169,7 @@ export class HeartbeatMonitor {
|
|||||||
this.onRunCompleted = options.onRunCompleted;
|
this.onRunCompleted = options.onRunCompleted;
|
||||||
this.taskStore = options.taskStore;
|
this.taskStore = options.taskStore;
|
||||||
this.rootDir = options.rootDir;
|
this.rootDir = options.rootDir;
|
||||||
|
this.messageStore = options.messageStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,6 +180,9 @@ export class HeartbeatMonitor {
|
|||||||
if (this.isRunning) return;
|
if (this.isRunning) return;
|
||||||
|
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
|
if (this.messageStore) {
|
||||||
|
this.messageStore.setMessageToAgentHook(this.handleMessageToAgent.bind(this));
|
||||||
|
}
|
||||||
this.pollInterval = setInterval(() => {
|
this.pollInterval = setInterval(() => {
|
||||||
void this.checkMissedHeartbeats();
|
void this.checkMissedHeartbeats();
|
||||||
}, this.pollIntervalMs);
|
}, this.pollIntervalMs);
|
||||||
@@ -186,6 +193,9 @@ export class HeartbeatMonitor {
|
|||||||
* Does not untrack agents - they remain in memory.
|
* Does not untrack agents - they remain in memory.
|
||||||
*/
|
*/
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
if (this.messageStore) {
|
||||||
|
this.messageStore.setMessageToAgentHook(() => {});
|
||||||
|
}
|
||||||
if (!this.isRunning) return;
|
if (!this.isRunning) return;
|
||||||
|
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
@@ -417,6 +427,36 @@ export class HeartbeatMonitor {
|
|||||||
return this.trackedAgents.get(agentId)?.lastSeen;
|
return this.trackedAgents.get(agentId)?.lastSeen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleMessageToAgent(message: Message): void {
|
||||||
|
if (message.toType !== "agent") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = this.configStore.getCachedAgent(message.toId);
|
||||||
|
if (!agent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeConfig = agent.runtimeConfig as AgentHeartbeatConfig | undefined;
|
||||||
|
if (runtimeConfig?.messageResponseMode !== "immediate") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validStates = new Set(["active", "idle", "running"]);
|
||||||
|
if (!validStates.has(agent.state)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.executeHeartbeat({
|
||||||
|
agentId: message.toId,
|
||||||
|
source: "on_demand",
|
||||||
|
triggerDetail: "wake-on-message",
|
||||||
|
}).catch((error) => {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
heartbeatLog.warn(`Wake-on-message heartbeat failed for ${message.toId}: ${errorMessage}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Heartbeat execution (Paperclip wake → check → work → exit)
|
// Heartbeat execution (Paperclip wake → check → work → exit)
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user