From ba09426707436a986f1ed69864bca2ef7d58e6b9 Mon Sep 17 00:00:00 2001 From: Chinar Amrutkar Date: Fri, 3 Apr 2026 22:40:09 +0000 Subject: [PATCH] fix(heartbeat): address review comments - parsing, timing, state, skips - Fix YAML parsing to capture interval:/prompt: before breaking - Record task timestamps AFTER successful execution (not before) - Initialize task state on first run (handle undefined session) - Skip API call when no tasks due (return null) - Use startedAt consistently for due-task filtering Fixes: #3030568439, #3033833124, #3030570872, #3030568408, #3030570872, #3035434022, #3035434368 --- src/auto-reply/heartbeat.ts | 10 +++---- src/infra/heartbeat-runner.ts | 50 +++++++++++++++++++++-------------- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index 312c4972d68..838327a6129 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -234,12 +234,8 @@ export function parseHeartbeatTasks(content: string): HeartbeatTask[] { if (nextTrimmed.startsWith("- name:")) { break; } - // End of tasks block - if (!nextTrimmed.startsWith(" ") && !nextTrimmed.startsWith("\t") && nextTrimmed) { - inTasksBlock = false; - break; - } + // Check for task fields BEFORE checking for end of block if (nextTrimmed.startsWith("interval:")) { interval = nextTrimmed .replace("interval:", "") @@ -250,6 +246,10 @@ export function parseHeartbeatTasks(content: string): HeartbeatTask[] { .replace("prompt:", "") .trim() .replace(/^["']|["']$/g, ""); + } else if (!nextTrimmed.startsWith(" ") && !nextTrimmed.startsWith("\t") && nextTrimmed) { + // End of tasks block + inTasksBlock = false; + break; } } diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 71d136b1ba8..7f1c40bb0c6 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -537,12 +537,11 @@ function resolveHeartbeatRunPrompt(params: { // If tasks are defined, build a batched prompt with due tasks if (params.preflight.tasks && params.preflight.tasks.length > 0) { const tasks = params.preflight.tasks; - const nowMs = Date.now(); const dueTasks = tasks.filter((task) => isTaskDue( (params.preflight.session.entry?.heartbeatTaskState as Record)?.[task.name], task.interval, - nowMs, + startedAt, ), ); @@ -555,9 +554,8 @@ ${taskList} After completing all due tasks, reply HEARTBEAT_OK.`; return { prompt, hasExecCompletion: false, hasCronEvents: false }; } - // No tasks due - still run but with empty task list - const prompt = `No periodic tasks are due right now. Reply HEARTBEAT_OK.`; - return { prompt, hasExecCompletion: false, hasCronEvents: false }; + // No tasks due - skip this heartbeat to avoid wasteful API calls + return null; } // Fallback to original behavior @@ -698,25 +696,35 @@ export async function runHeartbeatOnce(opts: { workspaceDir, }); - // Update task last run times BEFORE model runs - ensures timestamps are persisted - // even when model completes with HEARTBEAT_OK (which triggers early return) - if (preflight.tasks && preflight.tasks.length > 0) { + // Track if heartbeat completed successfully (for updating task timestamps) + let heartbeatSuccess = false; + + // Update task last run times AFTER successful heartbeat completion + const updateTaskTimestamps = async () => { + if (!preflight.tasks || preflight.tasks.length === 0) return; + const store = loadSessionStore(storePath); const current = store[sessionKey]; - if (current) { - const taskState = (current.heartbeatTaskState as Record) || {}; - for (const task of preflight.tasks) { - if (isTaskDue(taskState[task.name], task.interval, startedAt)) { - taskState[task.name] = startedAt; - } + // Initialize stub entry on first run when current doesn't exist + const base = current ?? { + sessionId: sessionKey, + updatedAt: startedAt, + createdAt: startedAt, + messageCount: 0, + lastMessageAt: startedAt, + heartbeatTaskState: {} + }; + const taskState = { ...((base.heartbeatTaskState as Record) || {}) }; + + for (const task of preflight.tasks) { + if (isTaskDue(taskState[task.name], task.interval, startedAt)) { + taskState[task.name] = startedAt; } - store[sessionKey] = { - ...current, - heartbeatTaskState: taskState, - }; - await saveSessionStore(storePath, store); } - } + + store[sessionKey] = { ...base, heartbeatTaskState: taskState }; + await saveSessionStore(storePath, store); + }; const ctx = { Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt), @@ -1011,6 +1019,8 @@ export async function runHeartbeatOnce(opts: { accountId: delivery.accountId, indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined, }); + heartbeatSuccess = true; + await updateTaskTimestamps(); return { status: "ran", durationMs: Date.now() - startedAt }; } catch (err) { const reason = formatErrorMessage(err);