diff --git a/server/src/services/heartbeat-run-runtime-status.test.ts b/server/src/services/heartbeat-run-runtime-status.test.ts index 80984155eea..f75eb616444 100644 --- a/server/src/services/heartbeat-run-runtime-status.test.ts +++ b/server/src/services/heartbeat-run-runtime-status.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { clearAllHeartbeatRunRuntimeStatuses, clearHeartbeatRunRuntimeStatus, @@ -12,6 +12,7 @@ import { describe("heartbeat run runtime status store", () => { afterEach(() => { clearAllHeartbeatRunRuntimeStatuses(); + vi.useRealTimers(); }); it("stores scoped ephemeral status and expires stale entries", () => { @@ -166,6 +167,43 @@ describe("heartbeat run runtime status store", () => { }); }); + it("cleans unread abandoned statuses on later output without polling or expiring fresh context", () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const start = new Date("2026-10-30T00:00:00Z"); + vi.setSystemTime(start); + const write = (runId: string) => setHeartbeatRunRuntimeStatus({ + companyId: "company-1", issueId: null, agentId: "agent-1", runId, + phase: "run_activity", message: "Using Bash", + }); + write("abandoned"); + write("active"); + vi.setSystemTime(start.getTime() + 60_000); + touchHeartbeatRunRuntimeStatus({ + companyId: "company-1", issueId: null, agentId: "agent-1", runId: "active", + }); + vi.setSystemTime(start.getTime() + 90_000); + write("boundary"); + expect(getHeartbeatRunRuntimeStatus("abandoned")).toMatchObject({ message: "Using Bash" }); + expect(clearHeartbeatRunRuntimeStatus("boundary")).toBe(true); + vi.setSystemTime(start.getTime() + 90_001); + touchHeartbeatRunRuntimeStatus({ + companyId: "company-1", issueId: null, agentId: "agent-1", runId: "active", + }); + expect(clearHeartbeatRunRuntimeStatus("abandoned")).toBe(false); + expect(getHeartbeatRunRuntimeStatus("active")).toMatchObject({ message: "Using Bash" }); + setHeartbeatRunRuntimeStatus({ + companyId: "company-1", issueId: null, agentId: "agent-1", runId: "old-output", + phase: "run_activity", message: "Delayed output", updatedAt: start, + }); + vi.setSystemTime(start.getTime() + 90_002); + write("next-output"); + expect(clearHeartbeatRunRuntimeStatus("old-output")).toBe(true); + vi.setSystemTime(start.getTime() + 180_002); + write("new"); + expect(clearHeartbeatRunRuntimeStatus("active")).toBe(false); + expect(getHeartbeatRunRuntimeStatus("new")).toMatchObject({ message: "Using Bash" }); + }); + it("sweeps expired statuses without touching fresh entries", () => { setHeartbeatRunRuntimeStatus({ companyId: "company-1", diff --git a/server/src/services/heartbeat-run-runtime-status.ts b/server/src/services/heartbeat-run-runtime-status.ts index d2995337e23..1d78b1ffb0a 100644 --- a/server/src/services/heartbeat-run-runtime-status.ts +++ b/server/src/services/heartbeat-run-runtime-status.ts @@ -20,6 +20,18 @@ export interface HeartbeatRunRuntimeStatus { } const runtimeStatusesByRunId = new Map(); +let nextWriteCleanupAt: number | null = null; + +function cleanupExpiredStatusesOnWrite() { + const now = Date.now(); + if (nextWriteCleanupAt === null) { + nextWriteCleanupAt = now + HEARTBEAT_RUN_RUNTIME_STATUS_TTL_MS; + return; + } + if (now <= nextWriteCleanupAt) return; + sweepExpiredHeartbeatRunRuntimeStatuses(new Date(now)); + nextWriteCleanupAt = now + HEARTBEAT_RUN_RUNTIME_STATUS_TTL_MS; +} function cloneStatus(status: HeartbeatRunRuntimeStatus): HeartbeatRunRuntimeStatus { return { @@ -64,6 +76,7 @@ export function setHeartbeatRunRuntimeStatus( lastEventAt?: Date | null; }, ): HeartbeatRunRuntimeStatus | null { + cleanupExpiredStatusesOnWrite(); const message = sanitizeHeartbeatRunRuntimeStatusMessage(input.message); if (!message) { clearHeartbeatRunRuntimeStatus(input.runId); @@ -106,6 +119,7 @@ export function touchHeartbeatRunRuntimeStatus(input: { fallbackPhase?: HeartbeatRunStatusPhase; fallbackMessage?: string; }): HeartbeatRunRuntimeStatus | null { + cleanupExpiredStatusesOnWrite(); const at = input.at ?? new Date(); const existing = runtimeStatusesByRunId.get(input.runId); if ( @@ -167,6 +181,7 @@ export function clearHeartbeatRunRuntimeStatus(runId: string): boolean { export function clearAllHeartbeatRunRuntimeStatuses(): void { runtimeStatusesByRunId.clear(); + nextWriteCleanupAt = null; } export function sweepExpiredHeartbeatRunRuntimeStatuses(