fix(FN-31): resolve per-project HeartbeatMonitor in multi-project setups

When running multiple projects (e.g., primary + fitness-app), on-demand
agent heartbeat triggers from the dashboard silently created zombie run
records that never executed. The agent appeared "running" but produced
no logs, no system prompt, no execution prompt.

Root cause: the dashboard routes used a single HeartbeatMonitor bound to
whichever project initialized first. For agents in other projects,
isHeartbeatMonitorForProject() returned false, causing either a 400
error or a fallback to agentStore.startHeartbeatRun() which only creates
a record without executing.

Fix: add resolveHeartbeatMonitor() which looks up the correct engine's
HeartbeatMonitor via engineManager by matching the scoped store's root
directory. Update POST /agents/:id/runs, POST /agents/:id/heartbeat,
POST /agents/:id/runs/stop, and triggerCommentWakeForAssignedAgent to
use the resolved monitor instead of the single static one.

Closes Runfusion/Fusion#31

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Timothy Laurent
2026-05-03 23:38:19 -07:00
parent 344aaa73c1
commit 4bdd1d2bcf
2 changed files with 90 additions and 20 deletions

View File

@@ -979,6 +979,30 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
} }
} }
/**
* Resolve the HeartbeatMonitor for the engine that owns the given scopedStore.
*
* In multi-project setups each ProjectEngine has its own HeartbeatMonitor.
* This function walks all engines in the engineManager and returns the one
* whose working directory matches the scopedStore's root.
* Returns undefined when no matching engine is found.
*/
function resolveHeartbeatMonitor(scopedStore: TaskStore): ServerOptions["heartbeatMonitor"] {
const engineManager = options?.engineManager;
if (!engineManager) return undefined;
try {
const storeRoot = resolve(scopedStore.getRootDir());
for (const engine of engineManager.getAllEngines().values()) {
if (resolve(engine.getWorkingDirectory()) === storeRoot) {
return engine.getHeartbeatMonitor() ?? undefined;
}
}
} catch {
// path resolution failure — fall through
}
return undefined;
}
/** /**
* Trigger a heartbeat wake for an assigned agent based on a comment event. * Trigger a heartbeat wake for an assigned agent based on a comment event.
* *
@@ -1007,9 +1031,14 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return; return;
} }
// Guard: heartbeatMonitor is bound to a specific project root directory. // Resolve the correct HeartbeatMonitor for this project.
// Skip the wake when the scoped store belongs to a different project. const resolvedMonitor =
if (!isHeartbeatMonitorForProject(scopedStore)) { isHeartbeatMonitorForProject(scopedStore)
? heartbeatMonitor
: resolveHeartbeatMonitor(scopedStore);
// Skip: no heartbeat executor available for this project
if (!resolvedMonitor) {
return; return;
} }
@@ -1044,7 +1073,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
triggeringCommentType: wake.triggeringCommentType, triggeringCommentType: wake.triggeringCommentType,
}; };
await heartbeatMonitor.executeHeartbeat({ await resolvedMonitor.executeHeartbeat({
agentId: assignedAgent.id, agentId: assignedAgent.id,
source: "on_demand", source: "on_demand",
triggerDetail: wake.triggerDetail, triggerDetail: wake.triggerDetail,
@@ -2966,6 +2995,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
hasHeartbeatExecutor, hasHeartbeatExecutor,
heartbeatMonitor, heartbeatMonitor,
isHeartbeatMonitorForProject, isHeartbeatMonitorForProject,
resolveHeartbeatMonitor,
runExcerptToAgentLogs, runExcerptToAgentLogs,
parseRunAuditFilters, parseRunAuditFilters,
normalizeRunAuditEvent, normalizeRunAuditEvent,

View File

@@ -16,6 +16,10 @@ interface AgentRuntimeRouteDeps {
hasHeartbeatExecutor: boolean; hasHeartbeatExecutor: boolean;
heartbeatMonitor: import("../server.js").ServerOptions["heartbeatMonitor"]; heartbeatMonitor: import("../server.js").ServerOptions["heartbeatMonitor"];
isHeartbeatMonitorForProject: (scopedStore: import("@fusion/core").TaskStore) => boolean; isHeartbeatMonitorForProject: (scopedStore: import("@fusion/core").TaskStore) => boolean;
/** Resolve the HeartbeatMonitor for the engine backing a scoped store.
* Used for multi-project setups where each engine has its own monitor.
* Returns undefined when no matching engine is found. */
resolveHeartbeatMonitor: (scopedStore: import("@fusion/core").TaskStore) => import("../server.js").ServerOptions["heartbeatMonitor"];
runExcerptToAgentLogs: (run: import("@fusion/core").AgentHeartbeatRun) => import("@fusion/core").AgentLogEntry[]; runExcerptToAgentLogs: (run: import("@fusion/core").AgentHeartbeatRun) => import("@fusion/core").AgentLogEntry[];
parseRunAuditFilters: (query: Record<string, unknown>) => { parseRunAuditFilters: (query: Record<string, unknown>) => {
taskId?: string; taskId?: string;
@@ -42,6 +46,7 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
hasHeartbeatExecutor, hasHeartbeatExecutor,
heartbeatMonitor, heartbeatMonitor,
isHeartbeatMonitorForProject, isHeartbeatMonitorForProject,
resolveHeartbeatMonitor,
runExcerptToAgentLogs, runExcerptToAgentLogs,
parseRunAuditFilters, parseRunAuditFilters,
normalizeRunAuditEvent, normalizeRunAuditEvent,
@@ -794,16 +799,22 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
// Optionally trigger execution // Optionally trigger execution
let run: import("@fusion/core").AgentHeartbeatRun | undefined; let run: import("@fusion/core").AgentHeartbeatRun | undefined;
if (triggerExecution && hasHeartbeatExecutor && heartbeatMonitor && isHeartbeatMonitorForProject(scopedStore)) { if (triggerExecution && hasHeartbeatExecutor && heartbeatMonitor) {
run = await heartbeatMonitor.executeHeartbeat({ const resolvedMonitor =
agentId: req.params.id, isHeartbeatMonitorForProject(scopedStore)
source: "on_demand", ? heartbeatMonitor
triggerDetail: "Triggered from heartbeat", : resolveHeartbeatMonitor(scopedStore);
contextSnapshot: { if (resolvedMonitor) {
wakeReason: "on_demand", run = await resolvedMonitor.executeHeartbeat({
agentId: req.params.id,
source: "on_demand",
triggerDetail: "Triggered from heartbeat", triggerDetail: "Triggered from heartbeat",
}, contextSnapshot: {
}); wakeReason: "on_demand",
triggerDetail: "Triggered from heartbeat",
},
});
}
} }
res.json(run ? { event, run } : event); res.json(run ? { event, run } : event);
@@ -939,10 +950,15 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
// Check for existing active run // Check for existing active run
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
// Guard: heartbeatMonitor is bound to a specific project root directory. // Resolve the correct HeartbeatMonitor for this project.
// Reject when the scoped store belongs to a different project. // In multi-project setups, each engine has its own monitor.
if (!isHeartbeatMonitorForProject(scopedStore)) { const resolvedMonitor =
throw new ApiError(400, "Agent execution is only available for the server's primary project. The heartbeat monitor is not bound to this project."); isHeartbeatMonitorForProject(scopedStore)
? heartbeatMonitor
: resolveHeartbeatMonitor(scopedStore);
if (!resolvedMonitor) {
throw new ApiError(400, "No heartbeat executor available for this project.");
} }
const { AgentStore: AgentStoreClass } = await import("@fusion/core"); const { AgentStore: AgentStoreClass } = await import("@fusion/core");
@@ -960,7 +976,7 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
} }
// Execute heartbeat end-to-end (single run record, no duplicate startRun call) // Execute heartbeat end-to-end (single run record, no duplicate startRun call)
const run = await heartbeatMonitor.executeHeartbeat({ const run = await resolvedMonitor.executeHeartbeat({
agentId: req.params.id, agentId: req.params.id,
source: invocationSource, source: invocationSource,
triggerDetail: trigger, triggerDetail: trigger,
@@ -1033,8 +1049,32 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
return; return;
} }
if (hasHeartbeatExecutor && heartbeatMonitor && isHeartbeatMonitorForProject(scopedStore)) { if (hasHeartbeatExecutor && heartbeatMonitor) {
await heartbeatMonitor.stopRun(req.params.id); const resolvedMonitor =
isHeartbeatMonitorForProject(scopedStore)
? heartbeatMonitor
: resolveHeartbeatMonitor(scopedStore);
if (resolvedMonitor) {
await resolvedMonitor.stopRun(req.params.id);
} else {
const existingRun = await agentStore.getRunDetail(req.params.id, activeRun.id);
if (existingRun) {
await agentStore.saveRun({
...existingRun,
endedAt: new Date().toISOString(),
status: "terminated",
stderrExcerpt: existingRun.stderrExcerpt ?? "Run stopped by user",
});
}
await agentStore.endHeartbeatRun(activeRun.id, "terminated");
try {
await agentStore.updateAgentState(req.params.id, "active");
} catch {
// Best effort to restore an idle/active state for follow-up runs.
}
}
} else { } else {
const existingRun = await agentStore.getRunDetail(req.params.id, activeRun.id); const existingRun = await agentStore.getRunDetail(req.params.id, activeRun.id);
if (existingRun) { if (existingRun) {