From aa88db7238171efc7ce3fe6d10a9b9b93b8ec7f9 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 26 Mar 2026 23:33:41 -0700 Subject: [PATCH] Fix test failure and address Greptile review comments - Fix "keeps alive" test: set lastOutputAt to current time so idle reaper doesn't kill the test run (seed used a stale date) - Wire up lastOutputAt and startedAt params in test seed fixture - Remove dead wasIdle variable in clearDetachedRunWarning - Fix deferred lastOutputAt flush to use actual last output timestamp instead of wall-clock time at flush Co-Authored-By: Paperclip --- .../__tests__/heartbeat-process-recovery.test.ts | 4 +++- server/src/services/heartbeat.ts | 14 ++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 742bd5d4..75857cde 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -129,7 +129,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { processLossRetryCount: input?.processLossRetryCount ?? 0, errorCode: input?.runErrorCode ?? null, error: input?.runError ?? null, - startedAt: now, + startedAt: input?.startedAt ?? now, + lastOutputAt: input?.lastOutputAt ?? null, updatedAt: new Date("2026-03-19T00:00:00.000Z"), }); @@ -159,6 +160,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const { runId, wakeupRequestId } = await seedRunFixture({ processPid: child.pid ?? null, includeIssue: false, + lastOutputAt: new Date(), }); const heartbeat = heartbeatService(db); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index e6c6aa66..57660339 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1506,7 +1506,6 @@ export function heartbeatService(db: Db) { .then((rows) => rows[0] ?? null); if (!updated) return null; - const wasIdle = updated.errorCode === null; // errorCode was cleared await appendRunEvent(updated, await nextRunEventSeq(updated.id), { eventType: "lifecycle", stream: "system", @@ -2509,27 +2508,30 @@ export function heartbeatService(db: Db) { const currentUserRedactionOptions = await getCurrentUserRedactionOptions(); let lastOutputAtFlushPending = false; + let lastOutputAtLatest: Date | null = null; const onLog = async (stream: "stdout" | "stderr", chunk: string) => { const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions); if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk); if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk); const ts = new Date().toISOString(); + lastOutputAtLatest = new Date(ts); // Batch lastOutputAt writes — flush at most once per 30 seconds to avoid DB churn if (!lastOutputAtFlushPending) { lastOutputAtFlushPending = true; + // Flush immediately on first output + await db.update(heartbeatRuns) + .set({ lastOutputAt: lastOutputAtLatest, updatedAt: new Date() }) + .where(eq(heartbeatRuns.id, runId)); setTimeout(() => { lastOutputAtFlushPending = false; + if (!lastOutputAtLatest) return; db.update(heartbeatRuns) - .set({ lastOutputAt: new Date(), updatedAt: new Date() }) + .set({ lastOutputAt: lastOutputAtLatest, updatedAt: new Date() }) .where(eq(heartbeatRuns.id, runId)) .then(() => {}) .catch((err) => logger.warn({ err, runId }, "failed to flush lastOutputAt")); }, 30_000); - // Also flush immediately on first output - await db.update(heartbeatRuns) - .set({ lastOutputAt: new Date(ts), updatedAt: new Date() }) - .where(eq(heartbeatRuns.id, runId)); } if (handle) {