fix(dashboard): respond to manual heartbeat run as soon as run record exists

POST /api/agents/:id/runs previously awaited resolvedMonitor.executeHeartbeat
end-to-end before sending the response. For real provider runs that take
tens of seconds to minutes, Safari (and intermediate proxies) drop the
client socket and the dashboard surfaces "Failed to start heartbeat run:
load failed" — the run is actually in flight, but the toast suggests it
failed to start.

The route now kicks off executeHeartbeat in the background, polls briefly
for the active-run record (created synchronously inside executeHeartbeat
→ startRun), and returns 201 with that record. Synchronous failures of
executeHeartbeat are still surfaced to the client; background failures
are logged via runtimeLogger.child("heartbeat"). The 409 active-run
conflict contract is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-06 08:31:39 -07:00
parent 3e68271693
commit 22250ebd19
2 changed files with 57 additions and 3 deletions

View File

@@ -0,0 +1,9 @@
---
"@runfusion/fusion": patch
---
Manual heartbeat runs (POST /api/agents/:id/runs) now respond as soon
as the run record is created instead of blocking on the full
executeHeartbeat call. Long-running heartbeats no longer cause the
dashboard to surface "Failed to start heartbeat run: load failed" when
the client socket times out before the run completes.

View File

@@ -1043,8 +1043,11 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
throw new ApiError(409, "Agent already has an active run", { runId: activeRun.id }); throw new ApiError(409, "Agent already has an active run", { runId: activeRun.id });
} }
// Execute heartbeat end-to-end (single run record, no duplicate startRun call) // Kick off heartbeat in the background; respond as soon as the run
const run = await resolvedMonitor.executeHeartbeat({ // record exists so slow runs don't time out the client socket.
// executeHeartbeat creates the run record synchronously near the top,
// then performs provider work that can take many seconds-to-minutes.
const heartbeatPromise = resolvedMonitor.executeHeartbeat({
agentId: req.params.id, agentId: req.params.id,
source: invocationSource, source: invocationSource,
triggerDetail: trigger, triggerDetail: trigger,
@@ -1053,8 +1056,50 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
triggeringCommentType: normalizedTriggeringCommentType, triggeringCommentType: normalizedTriggeringCommentType,
contextSnapshot, contextSnapshot,
}); });
heartbeatPromise.catch((err) => {
runtimeLogger.child("heartbeat").warn(
`background executeHeartbeat for ${req.params.id} failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
res.status(201).json(run); // Track heartbeatPromise settlement so we can: (a) surface synchronous
// failures to the caller, and (b) exit the poll loop early if the
// entire run completes faster than the polling deadline (e.g. tests
// that mock executeHeartbeat).
type Settled =
| { state: "pending" }
| { state: "resolved"; value: Awaited<typeof heartbeatPromise> }
| { state: "rejected"; error: unknown };
const settledRef: { current: Settled } = { current: { state: "pending" } };
heartbeatPromise.then(
(value) => { settledRef.current = { state: "resolved", value }; },
(error) => { settledRef.current = { state: "rejected", error }; },
);
// Poll briefly for the run record (created synchronously inside
// executeHeartbeat → startRun). Bounded so a stuck monitor still
// returns rather than hanging the request.
const pollDeadline = Date.now() + 5000;
let createdRun = await agentStore.getActiveHeartbeatRun(req.params.id);
while (!createdRun && Date.now() < pollDeadline && settledRef.current.state === "pending") {
await new Promise((resolve) => setTimeout(resolve, 50));
createdRun = await agentStore.getActiveHeartbeatRun(req.params.id);
}
if (settledRef.current.state === "rejected") {
throw settledRef.current.error;
}
if (!createdRun && settledRef.current.state === "resolved") {
createdRun = settledRef.current.value;
}
if (!createdRun) {
// Last resort: await the promise so the caller gets the completed
// run rather than a timeout. This only triggers if the active-run
// record never materialized within the poll window.
createdRun = await heartbeatPromise;
}
res.status(201).json(createdRun);
} else { } else {
// Fallback: record-only behavior without HeartbeatMonitor // Fallback: record-only behavior without HeartbeatMonitor
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);