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
This commit is contained in:
Chinar Amrutkar
2026-04-03 22:40:09 +00:00
committed by Peter Steinberger
parent 728d14e918
commit ba09426707
2 changed files with 35 additions and 25 deletions

View File

@@ -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;
}
}

View File

@@ -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<string, number>)?.[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<string, number>) || {};
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<string, number>) || {}) };
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);