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:
gsxdsm
2026-04-07 18:43:16 -07:00
parent 06fe36a64c
commit 981c5d951c
7 changed files with 365 additions and 5 deletions

View File

@@ -17,7 +17,7 @@
* - 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, type Static } from "@mariozechner/pi-ai";
import { createTaskCreateTool, createTaskLogTool, taskCreateParams } from "./agent-tools.js";
@@ -42,6 +42,8 @@ export interface HeartbeatMonitorOptions {
/** Optional separate AgentStore reference for reading per-agent runtimeConfig.
* If not provided, falls back to `store`. */
agentStore?: AgentStore;
/** Optional MessageStore for wake-on-message behavior */
messageStore?: MessageStore;
/** Polling interval in milliseconds (default: 30000) */
pollIntervalMs?: number;
/** Heartbeat timeout in milliseconds (default: 60000) */
@@ -144,6 +146,7 @@ export class HeartbeatMonitor {
private onRunCompleted?: (agentId: string, run: AgentHeartbeatRun) => void;
private taskStore?: TaskStore;
private rootDir?: string;
private messageStore?: MessageStore;
private trackedAgents: Map<string, TrackedAgent> = new Map();
private agentStartLocks: Map<string, Promise<unknown>> = new Map();
@@ -166,6 +169,7 @@ export class HeartbeatMonitor {
this.onRunCompleted = options.onRunCompleted;
this.taskStore = options.taskStore;
this.rootDir = options.rootDir;
this.messageStore = options.messageStore;
}
/**
@@ -176,6 +180,9 @@ export class HeartbeatMonitor {
if (this.isRunning) return;
this.isRunning = true;
if (this.messageStore) {
this.messageStore.setMessageToAgentHook(this.handleMessageToAgent.bind(this));
}
this.pollInterval = setInterval(() => {
void this.checkMissedHeartbeats();
}, this.pollIntervalMs);
@@ -186,6 +193,9 @@ export class HeartbeatMonitor {
* Does not untrack agents - they remain in memory.
*/
stop(): void {
if (this.messageStore) {
this.messageStore.setMessageToAgentHook(() => {});
}
if (!this.isRunning) return;
this.isRunning = false;
@@ -417,6 +427,36 @@ export class HeartbeatMonitor {
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)
// ─────────────────────────────────────────────────────────────────────────