diff --git a/.changeset/heartbeat-staleness-10min.md b/.changeset/heartbeat-staleness-10min.md new file mode 100644 index 0000000000..543d12b360 --- /dev/null +++ b/.changeset/heartbeat-staleness-10min.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Raise the minimum agent heartbeat staleness floor from 5 to 10 minutes. Agents go silent during long-running but legitimate work (notably a verification step running a multi-minute test command, where the agent is blocked awaiting the command and cannot tick/heartbeat). The 5-minute floor could misread such a busy agent as dead and reclaim its in-progress task mid-run; 10 minutes gives long operations room before the liveness gate acts. diff --git a/packages/engine/src/__tests__/heartbeat-executor.test.ts b/packages/engine/src/__tests__/heartbeat-executor.test.ts index ade0b5a345..9b0420a53d 100644 --- a/packages/engine/src/__tests__/heartbeat-executor.test.ts +++ b/packages/engine/src/__tests__/heartbeat-executor.test.ts @@ -460,15 +460,18 @@ describe("executeHeartbeat", () => { expect(section).toMatch(/\| Long Interval \| active \| FN-209 \| .* \| healthy \|/); }); - it("buildReportsHealthSection enforces a 5-minute minimum staleness floor", async () => { + it("buildReportsHealthSection enforces a 10-minute minimum staleness floor", async () => { const now = Date.now(); const store = createStoreWithAgentForExec(); vi.mocked(store.getCachedAgent).mockImplementation((id: string) => ({ id, runtimeConfig: { heartbeatIntervalMs: 1_000 }, }) as unknown as Agent); + // 7 minutes silent with a 1s interval: under the old 5-minute floor this + // would read as stale, but the 10-minute floor must still treat a busy + // long-running agent as healthy (e.g. mid multi-minute test command). vi.mocked(store.getAgentsByReportsTo).mockResolvedValue([ - { id: "agent-fast", name: "Fast Poller", state: "active", taskId: "FN-210", lastHeartbeatAt: new Date(now - 2 * 60_000).toISOString(), updatedAt: new Date(now - 2 * 60_000).toISOString() } as Agent, + { id: "agent-fast", name: "Fast Poller", state: "active", taskId: "FN-210", lastHeartbeatAt: new Date(now - 7 * 60_000).toISOString(), updatedAt: new Date(now - 7 * 60_000).toISOString() } as Agent, ]); const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 66008ab61b..c5b2d69fe5 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -204,8 +204,11 @@ const REPORTS_STALE_INTERVAL_MULTIPLIER = 1.5; /** * Minimum staleness threshold floor for very short heartbeat intervals. + * 10 minutes: long-running but legitimately-busy agents (e.g. a verification + * step running a multi-minute test command, during which the agent does not + * tick/heartbeat) must not be misread as dead and reclaimed mid-run. */ -const MIN_HEARTBEAT_STALENESS_MS = 5 * 60_000; +const MIN_HEARTBEAT_STALENESS_MS = 10 * 60_000; /** Format milliseconds into a human-readable duration string (e.g. "5m", "1h 20m", "2h"). */ export function formatDuration(ms: number): string {