From d43e7d278aac3110ced780233a4156268c7f5238 Mon Sep 17 00:00:00 2001 From: Dylan Date: Sat, 4 Apr 2026 13:44:03 +0000 Subject: [PATCH 01/11] Add heartbeat system for periodic background maintenance Runs a background agent at configurable intervals (default 60min) to perform workspace cleanup, memory consolidation, VM updates, and health checks. Co-Authored-By: Claude Opus 4.6 --- prompts/heartbeat.md | 28 ++++ src/bootstrap.ts | 8 + src/core/heartbeat.ts | 367 ++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 3 + src/util/config.ts | 3 + src/util/log.ts | 1 + src/util/paths.ts | 2 + 7 files changed, 412 insertions(+) create mode 100644 prompts/heartbeat.md create mode 100644 src/core/heartbeat.ts diff --git a/prompts/heartbeat.md b/prompts/heartbeat.md new file mode 100644 index 000000000..19cc294de --- /dev/null +++ b/prompts/heartbeat.md @@ -0,0 +1,28 @@ +You are Talon's background heartbeat agent. You run periodically (every {{intervalMinutes}} minutes) to perform maintenance tasks defined by the user. + +You have access ONLY to filesystem tools (Read, Write, Edit, Bash, Glob, Grep). Do NOT attempt to use any Telegram, MCP, or messaging tools. + +## Context + +- Workspace: `{{workspace}}` +- Memory file: `{{memoryFile}}` +- Logs directory: `{{logsDir}}` +- Last heartbeat: `{{lastRunIso}}` +- Run number: #{{runCount}} + +## Instructions + +Read the user-defined instructions file at `{{instructionsFile}}`. Follow whatever tasks are defined there. + +If the instructions file does not exist or is empty, perform these default tasks: + +1. **Review recent logs** — Check `{{logsDir}}/` for log files dated after `{{lastRunIso}}`. Extract any new facts, preferences, or notable events. +2. **Update memory** — Merge any new information into `{{memoryFile}}`, keeping entries concise and factual. +3. **Workspace hygiene** — Note any issues but do not delete files unless the instructions explicitly say to. + +## Rules + +- Be surgical and precise. Do not rewrite files unnecessarily. +- Do not modify files outside the workspace unless the instructions explicitly allow it. +- Keep your work focused and efficient — you have a 10-minute time limit. +- When done, stop. The system handles all state tracking. diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 96ce6ad78..ddaef4a38 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -21,6 +21,7 @@ import { initDispatcher } from "./core/dispatcher.js"; import { initPulse, resetPulseTimer } from "./core/pulse.js"; import { initCron } from "./core/cron.js"; import { initDream } from "./core/dream.js"; +import { initHeartbeat } from "./core/heartbeat.js"; import { log } from "./util/log.js"; import type { TalonConfig } from "./util/config.js"; import type { QueryBackend, ContextManager } from "./core/types.js"; @@ -124,4 +125,11 @@ export async function initBackendAndDispatcher( claudeBinary: config.claudeBinary, workspace: config.workspace, }); + initHeartbeat({ + model: config.model, + heartbeatModel: config.heartbeatModel, + dreamModel: config.dreamModel, + claudeBinary: config.claudeBinary, + workspace: config.workspace, + }); } diff --git a/src/core/heartbeat.ts b/src/core/heartbeat.ts new file mode 100644 index 000000000..e7f5b97c3 --- /dev/null +++ b/src/core/heartbeat.ts @@ -0,0 +1,367 @@ +/** + * Heartbeat — periodic background agent for user-defined maintenance tasks. + * + * Runs at a configurable interval (default: 60 minutes). + * The agent reads instructions from ~/.talon/workspace/heartbeat-instructions.md + * and executes them using filesystem-only tools (no Telegram/MCP access). + * + * Modeled after dream.ts but more general-purpose. + */ + +import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import writeFileAtomic from "write-file-atomic"; +import { query } from "@anthropic-ai/claude-agent-sdk"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { files as pathFiles, dirs } from "../util/paths.js"; +import { log, logError, logWarn } from "../util/log.js"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type HeartbeatState = { + /** Unix millisecond timestamp of the last completed heartbeat run. */ + last_run: number; + /** Human-readable ISO timestamp of the last completed heartbeat run. */ + last_run_at?: string; + /** "idle" when no heartbeat is running, "running" while one is active. */ + status: "idle" | "running"; + /** Total number of completed heartbeat runs. */ + run_count: number; +}; + +// ── Constants ──────────────────────────────────────────────────────────────── + +const HEARTBEAT_STATE_FILE = pathFiles.heartbeatState; +const HEARTBEAT_TIMEOUT_MS = 10 * 60 * 1000; // 10-minute max +const HEARTBEAT_LOGS_DIR = resolve(dirs.logs, "heartbeats"); +const STARTUP_DELAY_MS = 5 * 60 * 1000; // 5-minute delay before first run +const INSTRUCTIONS_FILE = resolve(dirs.workspace, "heartbeat-instructions.md"); + +// ── State ──────────────────────────────────────────────────────────────────── + +let running = false; // in-process guard (one heartbeat at a time) +let timer: ReturnType | null = null; +let startupTimer: ReturnType | null = null; +let intervalMinutesRef = 60; // stored from startHeartbeatTimer +let configRef: { + model?: string; + heartbeatModel?: string; + dreamModel?: string; + claudeBinary?: string; + workspace?: string; +} | null = null; + +export function initHeartbeat(cfg: { + model?: string; + /** Override model used specifically for heartbeat (e.g. haiku for cost savings). Falls back to main model. */ + heartbeatModel?: string; + dreamModel?: string; + claudeBinary?: string; + workspace?: string; +}): void { + configRef = cfg; +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +/** + * Start the heartbeat timer. First run happens after a 5-minute startup delay, + * then repeats at the configured interval. + */ +export function startHeartbeatTimer(intervalMinutes: number): void { + if (timer) return; // already running + + intervalMinutesRef = intervalMinutes; + const intervalMs = intervalMinutes * 60 * 1000; + log("heartbeat", `Starting heartbeat timer (every ${intervalMinutes}min, first run in 5min)`); + + startupTimer = setTimeout(() => { + startupTimer = null; + // Run immediately after startup delay + executeHeartbeat("auto").catch(() => {}); + + // Then set up the recurring interval + timer = setInterval(() => { + executeHeartbeat("auto").catch(() => {}); + }, intervalMs); + }, STARTUP_DELAY_MS); +} + +/** + * Stop the heartbeat timer. + */ +export function stopHeartbeatTimer(): void { + if (startupTimer) { + clearTimeout(startupTimer); + startupTimer = null; + } + if (timer) { + clearInterval(timer); + timer = null; + log("heartbeat", "Heartbeat timer stopped"); + } +} + +/** + * Force a heartbeat run immediately. + * Returns a promise that resolves when the heartbeat completes. + * Throws if a heartbeat is already running. + */ +export async function forceHeartbeat(): Promise { + if (running) throw new Error("Heartbeat already running"); + await executeHeartbeat("forced"); +} + +/** + * Get the current heartbeat status. + */ +export function getHeartbeatStatus(): HeartbeatState | null { + return readHeartbeatState(); +} + +// ── Core execution ────────────────────────────────────────────────────────── + +async function executeHeartbeat(trigger: "auto" | "forced"): Promise { + if (running) return; + + const state = readHeartbeatState(); + const now = Date.now(); + const runCount = (state?.run_count ?? 0) + 1; + + running = true; + writeHeartbeatState({ last_run: now, status: "running", run_count: runCount }); + log( + "heartbeat", + `${trigger === "forced" ? "Force-triggering" : "Triggering"} heartbeat #${runCount} (last run: ${state?.last_run ? new Date(state.last_run).toISOString() : "never"})`, + ); + + try { + const heartbeatLogPath = await runHeartbeatAgent(state?.last_run ?? 0, runCount); + writeHeartbeatState({ last_run: Date.now(), status: "idle", run_count: runCount }); + log("heartbeat", `Heartbeat #${runCount} complete (${trigger}), log: ${heartbeatLogPath}`); + } catch (err) { + logError("heartbeat", `Heartbeat #${runCount} failed (${trigger})`, err); + writeHeartbeatState({ last_run: Date.now(), status: "idle", run_count: runCount }); + if (trigger === "forced") throw err; + } finally { + running = false; + } +} + +// ── Heartbeat agent ───────────────────────────────────────────────────────── + +async function runHeartbeatAgent(lastRunTimestamp: number, runCount: number): Promise { + if (!configRef) { + logWarn("heartbeat", "Heartbeat agent not initialized — skipping"); + return ""; + } + + const lastRunIso = + lastRunTimestamp > 0 + ? new Date(lastRunTimestamp).toISOString() + : "never"; + + const logsDir = dirs.logs; + const memoryFile = pathFiles.memory; + const workspace = configRef.workspace ?? dirs.workspace; + + // Load prompt template from prompts/heartbeat.md and interpolate variables + const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + const promptPath = resolve(projectRoot, "prompts/heartbeat.md"); + + let prompt: string; + try { + prompt = readFileSync(promptPath, "utf-8") + .replace(/\{\{workspace\}\}/g, workspace) + .replace(/\{\{logsDir\}\}/g, logsDir) + .replace(/\{\{lastRunIso\}\}/g, lastRunIso) + .replace(/\{\{memoryFile\}\}/g, memoryFile) + .replace(/\{\{instructionsFile\}\}/g, INSTRUCTIONS_FILE) + .replace(/\{\{runCount\}\}/g, String(runCount)) + .replace(/\{\{intervalMinutes\}\}/g, String(intervalMinutesRef)); + } catch { + throw new Error(`Failed to read heartbeat prompt from ${promptPath}`); + } + + const model = configRef.heartbeatModel ?? configRef.model ?? "claude-sonnet-4-6"; + + // Set up heartbeat log file + const heartbeatLogFile = createHeartbeatLogFile(); + appendHeartbeatLog(heartbeatLogFile, `# Heartbeat Run #${runCount} — ${new Date().toISOString()}\n`); + appendHeartbeatLog(heartbeatLogFile, `**Trigger:** ${lastRunIso === "never" ? "first run" : `last_run=${lastRunIso}`}, model=${model}\n`); + appendHeartbeatLog(heartbeatLogFile, `**Prompt:**\n\`\`\`\n${prompt}\n\`\`\`\n\n---\n`); + + const options = { + model, + systemPrompt: + "You are a background heartbeat agent for Talon. Use only filesystem tools. Follow the user-defined instructions precisely. Be efficient — you have limited time.", + cwd: workspace, + permissionMode: "bypassPermissions" as const, + allowDangerouslySkipPermissions: true, + ...(configRef.claudeBinary + ? { pathToClaudeCodeExecutable: configRef.claudeBinary } + : {}), + // No MCP servers — filesystem tools only + mcpServers: {}, + disallowedTools: [ + "EnterPlanMode", + "ExitPlanMode", + "EnterWorktree", + "ExitWorktree", + "TodoWrite", + "TodoRead", + "TaskCreate", + "TaskUpdate", + "TaskGet", + "TaskList", + "TaskOutput", + "TaskStop", + "AskUserQuestion", + "Agent", + ], + }; + + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error("Heartbeat agent timed out")), + HEARTBEAT_TIMEOUT_MS, + ), + ); + + const agentPromise = (async () => { + const qi = query({ + prompt, + options: options as Parameters[0]["options"], + }); + for await (const msg of qi) { + logHeartbeatMessage(heartbeatLogFile, msg); + } + appendHeartbeatLog( + heartbeatLogFile, + `\n---\n**Heartbeat #${runCount} completed at ${new Date().toISOString()}**\n`, + ); + })(); + + try { + await Promise.race([agentPromise, timeoutPromise]); + } catch (err) { + appendHeartbeatLog( + heartbeatLogFile, + `\n---\n**Heartbeat #${runCount} FAILED at ${new Date().toISOString()}:** ${err}\n`, + ); + throw err; + } + + return heartbeatLogFile; +} + +// ── Logging helpers ───────────────────────────────────────────────────────── + +function createHeartbeatLogFile(): string { + if (!existsSync(HEARTBEAT_LOGS_DIR)) { + mkdirSync(HEARTBEAT_LOGS_DIR, { recursive: true }); + } + const now = new Date(); + const ts = now.toISOString().replace(/[:.]/g, "-").slice(0, 19); + return resolve(HEARTBEAT_LOGS_DIR, `heartbeat-${ts}.md`); +} + +function appendHeartbeatLog(logFile: string, text: string): void { + try { + appendFileSync(logFile, text); + } catch (err) { + logError("heartbeat", "Failed to write heartbeat log", err); + } +} + +function logHeartbeatMessage(logFile: string, msg: SDKMessage): void { + try { + const ts = new Date().toISOString().slice(11, 19); + + switch (msg.type) { + case "assistant": { + const textBlocks = msg.message.content + .filter((b) => b.type === "text") + .map((b) => ("text" in b ? (b as { text: string }).text : "")); + const toolUseBlocks = msg.message.content + .filter((b) => b.type === "tool_use") + .map((b) => { + const tu = b as { name: string; input: unknown }; + return `**Tool call:** \`${tu.name}\`\n\`\`\`json\n${JSON.stringify(tu.input, null, 2)}\n\`\`\``; + }); + + if (textBlocks.length > 0) { + appendHeartbeatLog(logFile, `\n## [${ts}] Assistant\n${textBlocks.join("\n")}\n`); + } + if (toolUseBlocks.length > 0) { + appendHeartbeatLog(logFile, `\n${toolUseBlocks.join("\n\n")}\n`); + } + break; + } + case "result": { + const result = + "result" in msg + ? (msg as { result: string }).result + : JSON.stringify(msg); + const truncated = + result.length > 2000 + ? result.slice(0, 2000) + "\n... (truncated)" + : result; + appendHeartbeatLog(logFile, `\n### [${ts}] Result (${msg.subtype})\n\`\`\`\n${truncated}\n\`\`\`\n`); + break; + } + case "system": { + appendHeartbeatLog(logFile, `\n### [${ts}] System (${msg.subtype})\n`); + break; + } + case "user": { + if (msg.tool_use_result != null) { + const raw = + typeof msg.tool_use_result === "string" + ? msg.tool_use_result + : JSON.stringify(msg.tool_use_result, null, 2); + const truncated = + raw.length > 2000 ? raw.slice(0, 2000) + "\n... (truncated)" : raw; + appendHeartbeatLog(logFile, `\n### [${ts}] Tool Result\n\`\`\`\n${truncated}\n\`\`\`\n`); + } + break; + } + default: + break; + } + } catch { + // Don't let logging errors break the heartbeat + } +} + +// ── State helpers ──────────────────────────────────────────────────────────── + +function readHeartbeatState(): HeartbeatState | null { + try { + if (!existsSync(HEARTBEAT_STATE_FILE)) return null; + const raw = readFileSync(HEARTBEAT_STATE_FILE, "utf-8"); + const parsed = JSON.parse(raw) as HeartbeatState; + if (typeof parsed.last_run !== "number") return null; + return parsed; + } catch { + return null; + } +} + +function writeHeartbeatState(state: HeartbeatState): void { + try { + const dir = resolve(HEARTBEAT_STATE_FILE, ".."); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const enriched: HeartbeatState = { + ...state, + last_run_at: new Date(state.last_run).toISOString(), + }; + writeFileAtomic.sync( + HEARTBEAT_STATE_FILE, + JSON.stringify(enriched, null, 2) + "\n", + ); + } catch (err) { + logError("heartbeat", "Failed to write heartbeat state", err); + } +} diff --git a/src/index.ts b/src/index.ts index fed6398a7..6cd5e56ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { flushHistory } from "./storage/history.js"; import { flushMediaIndex } from "./storage/media-index.js"; import { getActiveCount } from "./core/dispatcher.js"; import { startPulseTimer, stopPulseTimer } from "./core/pulse.js"; +import { startHeartbeatTimer, stopHeartbeatTimer } from "./core/heartbeat.js"; import { startCronTimer, stopCronTimer } from "./core/cron.js"; import { startWatchdog, stopWatchdog } from "./util/watchdog.js"; import { log, logError, logWarn } from "./util/log.js"; @@ -97,6 +98,7 @@ async function gracefulShutdown(signal: string): Promise { await destroyPlugins(); } stopPulseTimer(); + stopHeartbeatTimer(); stopCronTimer(); stopWatchdog(); stopUploadCleanup(); @@ -147,6 +149,7 @@ async function main(): Promise { log("bot", "Starting Talon..."); if (config.pulse) startPulseTimer(config.pulseIntervalMs); + if (config.heartbeat) startHeartbeatTimer(config.heartbeatInterval); startCronTimer(); startWatchdog(config.workspace); startUploadCleanup(config.workspace); diff --git a/src/util/config.ts b/src/util/config.ts index 0891d7d7f..41734a850 100644 --- a/src/util/config.ts +++ b/src/util/config.ts @@ -35,6 +35,9 @@ const configSchema = z.object({ adminUserId: z.number().int().optional(), pulse: z.boolean().default(true), pulseIntervalMs: z.number().int().min(60000).default(300000), + heartbeat: z.boolean().default(false), + heartbeatInterval: z.number().int().min(5).default(60), // minutes + heartbeatModel: z.string().optional(), // Model for heartbeat agent (defaults to main model) braveApiKey: z.string().optional(), searxngUrl: z.string().default("http://localhost:8080"), timezone: z.string().optional(), diff --git a/src/util/log.ts b/src/util/log.ts index de4da6808..8c0d713a1 100644 --- a/src/util/log.ts +++ b/src/util/log.ts @@ -35,6 +35,7 @@ export type LogComponent = | "commands" | "cron" | "dream" + | "heartbeat" | "dispatcher" | "gateway" | "plugin" diff --git a/src/util/paths.ts b/src/util/paths.ts index e558eaa64..b7d69175b 100644 --- a/src/util/paths.ts +++ b/src/util/paths.ts @@ -77,4 +77,6 @@ export const files = { pid: resolve(TALON_ROOT, "talon.pid"), /** Dream mode state: ~/.talon/workspace/memory/dream_state.json */ dreamState: resolve(TALON_ROOT, "workspace", "memory", "dream_state.json"), + /** Heartbeat state: ~/.talon/workspace/memory/heartbeat_state.json */ + heartbeatState: resolve(TALON_ROOT, "workspace", "memory", "heartbeat_state.json"), } as const; From a3dfa2407e023ede3869b731eb822f2c87046b1d Mon Sep 17 00:00:00 2001 From: Dylan Date: Sat, 4 Apr 2026 14:45:15 +0000 Subject: [PATCH 02/11] style: auto-format heartbeat.ts and paths.ts with prettier Co-Authored-By: Claude Opus 4.6 --- src/core/heartbeat.ts | 75 +++++++++++++++++++++++++++++++++---------- src/util/paths.ts | 7 +++- 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/src/core/heartbeat.ts b/src/core/heartbeat.ts index e7f5b97c3..ba19f8f44 100644 --- a/src/core/heartbeat.ts +++ b/src/core/heartbeat.ts @@ -74,7 +74,10 @@ export function startHeartbeatTimer(intervalMinutes: number): void { intervalMinutesRef = intervalMinutes; const intervalMs = intervalMinutes * 60 * 1000; - log("heartbeat", `Starting heartbeat timer (every ${intervalMinutes}min, first run in 5min)`); + log( + "heartbeat", + `Starting heartbeat timer (every ${intervalMinutes}min, first run in 5min)`, + ); startupTimer = setTimeout(() => { startupTimer = null; @@ -130,19 +133,37 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise { const runCount = (state?.run_count ?? 0) + 1; running = true; - writeHeartbeatState({ last_run: now, status: "running", run_count: runCount }); + writeHeartbeatState({ + last_run: now, + status: "running", + run_count: runCount, + }); log( "heartbeat", `${trigger === "forced" ? "Force-triggering" : "Triggering"} heartbeat #${runCount} (last run: ${state?.last_run ? new Date(state.last_run).toISOString() : "never"})`, ); try { - const heartbeatLogPath = await runHeartbeatAgent(state?.last_run ?? 0, runCount); - writeHeartbeatState({ last_run: Date.now(), status: "idle", run_count: runCount }); - log("heartbeat", `Heartbeat #${runCount} complete (${trigger}), log: ${heartbeatLogPath}`); + const heartbeatLogPath = await runHeartbeatAgent( + state?.last_run ?? 0, + runCount, + ); + writeHeartbeatState({ + last_run: Date.now(), + status: "idle", + run_count: runCount, + }); + log( + "heartbeat", + `Heartbeat #${runCount} complete (${trigger}), log: ${heartbeatLogPath}`, + ); } catch (err) { logError("heartbeat", `Heartbeat #${runCount} failed (${trigger})`, err); - writeHeartbeatState({ last_run: Date.now(), status: "idle", run_count: runCount }); + writeHeartbeatState({ + last_run: Date.now(), + status: "idle", + run_count: runCount, + }); if (trigger === "forced") throw err; } finally { running = false; @@ -151,16 +172,17 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise { // ── Heartbeat agent ───────────────────────────────────────────────────────── -async function runHeartbeatAgent(lastRunTimestamp: number, runCount: number): Promise { +async function runHeartbeatAgent( + lastRunTimestamp: number, + runCount: number, +): Promise { if (!configRef) { logWarn("heartbeat", "Heartbeat agent not initialized — skipping"); return ""; } const lastRunIso = - lastRunTimestamp > 0 - ? new Date(lastRunTimestamp).toISOString() - : "never"; + lastRunTimestamp > 0 ? new Date(lastRunTimestamp).toISOString() : "never"; const logsDir = dirs.logs; const memoryFile = pathFiles.memory; @@ -184,13 +206,23 @@ async function runHeartbeatAgent(lastRunTimestamp: number, runCount: number): Pr throw new Error(`Failed to read heartbeat prompt from ${promptPath}`); } - const model = configRef.heartbeatModel ?? configRef.model ?? "claude-sonnet-4-6"; + const model = + configRef.heartbeatModel ?? configRef.model ?? "claude-sonnet-4-6"; // Set up heartbeat log file const heartbeatLogFile = createHeartbeatLogFile(); - appendHeartbeatLog(heartbeatLogFile, `# Heartbeat Run #${runCount} — ${new Date().toISOString()}\n`); - appendHeartbeatLog(heartbeatLogFile, `**Trigger:** ${lastRunIso === "never" ? "first run" : `last_run=${lastRunIso}`}, model=${model}\n`); - appendHeartbeatLog(heartbeatLogFile, `**Prompt:**\n\`\`\`\n${prompt}\n\`\`\`\n\n---\n`); + appendHeartbeatLog( + heartbeatLogFile, + `# Heartbeat Run #${runCount} — ${new Date().toISOString()}\n`, + ); + appendHeartbeatLog( + heartbeatLogFile, + `**Trigger:** ${lastRunIso === "never" ? "first run" : `last_run=${lastRunIso}`}, model=${model}\n`, + ); + appendHeartbeatLog( + heartbeatLogFile, + `**Prompt:**\n\`\`\`\n${prompt}\n\`\`\`\n\n---\n`, + ); const options = { model, @@ -292,7 +324,10 @@ function logHeartbeatMessage(logFile: string, msg: SDKMessage): void { }); if (textBlocks.length > 0) { - appendHeartbeatLog(logFile, `\n## [${ts}] Assistant\n${textBlocks.join("\n")}\n`); + appendHeartbeatLog( + logFile, + `\n## [${ts}] Assistant\n${textBlocks.join("\n")}\n`, + ); } if (toolUseBlocks.length > 0) { appendHeartbeatLog(logFile, `\n${toolUseBlocks.join("\n\n")}\n`); @@ -308,7 +343,10 @@ function logHeartbeatMessage(logFile: string, msg: SDKMessage): void { result.length > 2000 ? result.slice(0, 2000) + "\n... (truncated)" : result; - appendHeartbeatLog(logFile, `\n### [${ts}] Result (${msg.subtype})\n\`\`\`\n${truncated}\n\`\`\`\n`); + appendHeartbeatLog( + logFile, + `\n### [${ts}] Result (${msg.subtype})\n\`\`\`\n${truncated}\n\`\`\`\n`, + ); break; } case "system": { @@ -323,7 +361,10 @@ function logHeartbeatMessage(logFile: string, msg: SDKMessage): void { : JSON.stringify(msg.tool_use_result, null, 2); const truncated = raw.length > 2000 ? raw.slice(0, 2000) + "\n... (truncated)" : raw; - appendHeartbeatLog(logFile, `\n### [${ts}] Tool Result\n\`\`\`\n${truncated}\n\`\`\`\n`); + appendHeartbeatLog( + logFile, + `\n### [${ts}] Tool Result\n\`\`\`\n${truncated}\n\`\`\`\n`, + ); } break; } diff --git a/src/util/paths.ts b/src/util/paths.ts index b7d69175b..fa7786b99 100644 --- a/src/util/paths.ts +++ b/src/util/paths.ts @@ -78,5 +78,10 @@ export const files = { /** Dream mode state: ~/.talon/workspace/memory/dream_state.json */ dreamState: resolve(TALON_ROOT, "workspace", "memory", "dream_state.json"), /** Heartbeat state: ~/.talon/workspace/memory/heartbeat_state.json */ - heartbeatState: resolve(TALON_ROOT, "workspace", "memory", "heartbeat_state.json"), + heartbeatState: resolve( + TALON_ROOT, + "workspace", + "memory", + "heartbeat_state.json", + ), } as const; From 613320f484752256dad6fa14f038bebe8d4e7da9 Mon Sep 17 00:00:00 2001 From: Dylan Date: Sat, 4 Apr 2026 14:54:42 +0000 Subject: [PATCH 03/11] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20state=20semantics,=20shutdown,=20timer=20guard,=20t?= =?UTF-8?q?ests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard startHeartbeatTimer against double-start during startup delay - Track last_started separately; only update last_run/run_count on success - Add awaitCurrentRun() for graceful shutdown of in-flight heartbeats - Add comprehensive test suite (16 tests covering all review concerns) Co-Authored-By: Claude Opus 4.6 --- src/__tests__/heartbeat.test.ts | 290 ++++++++++++++++++++++++++++++++ src/core/heartbeat.ts | 104 ++++++++---- src/index.ts | 7 +- 3 files changed, 366 insertions(+), 35 deletions(-) create mode 100644 src/__tests__/heartbeat.test.ts diff --git a/src/__tests__/heartbeat.test.ts b/src/__tests__/heartbeat.test.ts new file mode 100644 index 000000000..38201b26c --- /dev/null +++ b/src/__tests__/heartbeat.test.ts @@ -0,0 +1,290 @@ +/** + * Tests for src/core/heartbeat.ts + * + * Covers: initHeartbeat, startHeartbeatTimer (double-start guard), + * forceHeartbeat (concurrency guard), state persistence semantics + * (success vs failure paths), and awaitCurrentRun. + * The actual SDK agent call is mocked to avoid spawning a real Claude agent. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// ── Mocks ───────────────────────────────────────────────────────────────── + +vi.mock("../util/log.js", () => ({ + log: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), +})); + +const existsSyncMock = vi.fn(() => false); +const readFileSyncMock = vi.fn(() => "null"); +const mkdirSyncMock = vi.fn(); +const appendFileSyncMock = vi.fn(); + +vi.mock("node:fs", () => ({ + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + mkdirSync: mkdirSyncMock, + appendFileSync: appendFileSyncMock, +})); + +const writeAtomicSyncMock = vi.fn(); +vi.mock("write-file-atomic", () => ({ + default: { sync: writeAtomicSyncMock }, +})); + +// Mock the agent SDK so runHeartbeatAgent doesn't actually spawn Claude +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const queryMock = vi.fn<() => AsyncGenerator>(async function* () { + // Yield nothing — simulates a clean run +}); +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: queryMock, +})); + +vi.mock("../util/paths.js", () => ({ + files: { + heartbeatState: "/fake/.talon/workspace/memory/heartbeat_state.json", + dreamState: "/fake/.talon/workspace/memory/dream_state.json", + memory: "/fake/.talon/workspace/memory/memory.md", + log: "/fake/.talon/talon.log", + }, + dirs: { + root: "/fake/.talon", + logs: "/fake/.talon/workspace/logs", + workspace: "/fake/.talon/workspace", + data: "/fake/.talon/data", + memory: "/fake/.talon/workspace/memory", + }, +})); + +// ── Tests ───────────────────────────────────────────────────────────────── + +const { + initHeartbeat, + startHeartbeatTimer, + stopHeartbeatTimer, + forceHeartbeat, + getHeartbeatStatus, + awaitCurrentRun, +} = await import("../core/heartbeat.js"); + +describe("initHeartbeat", () => { + it("accepts a config object without throwing", () => { + expect(() => initHeartbeat({ model: "claude-sonnet-4-6" })).not.toThrow(); + }); + + it("accepts all optional config fields", () => { + expect(() => + initHeartbeat({ + model: "claude-sonnet-4-6", + heartbeatModel: "claude-haiku-4-5", + claudeBinary: "/usr/local/bin/claude", + workspace: "/tmp/test-workspace", + }), + ).not.toThrow(); + }); +}); + +describe("startHeartbeatTimer", () => { + beforeEach(() => { + vi.useFakeTimers(); + initHeartbeat({ model: "claude-sonnet-4-6" }); + }); + + afterEach(() => { + stopHeartbeatTimer(); + vi.useRealTimers(); + }); + + it("guards against double-start during startup delay", () => { + startHeartbeatTimer(60); + // Calling again during the 5-minute startup delay should be a no-op + // (no duplicate timers created) + startHeartbeatTimer(60); + // If the guard works, stopping once should clean up everything + stopHeartbeatTimer(); + expect(true).toBe(true); // no crash = success + }); + + it("guards against double-start after interval is set", () => { + startHeartbeatTimer(60); + // Advance past startup delay to create the interval timer + vi.advanceTimersByTime(5 * 60 * 1000 + 100); + // Now try to start again — should be a no-op + startHeartbeatTimer(60); + stopHeartbeatTimer(); + expect(true).toBe(true); + }); +}); + +describe("forceHeartbeat", () => { + beforeEach(() => { + initHeartbeat({ model: "claude-sonnet-4-6" }); + existsSyncMock.mockReturnValue(false); + writeAtomicSyncMock.mockClear(); + queryMock.mockClear(); + }); + + it("writes heartbeat state twice (running then idle) on success", async () => { + await forceHeartbeat(); + + expect(writeAtomicSyncMock).toHaveBeenCalledTimes(2); + + const firstCall = JSON.parse( + writeAtomicSyncMock.mock.calls[0][1] as string, + ); + expect(firstCall.status).toBe("running"); + + const secondCall = JSON.parse( + writeAtomicSyncMock.mock.calls[1][1] as string, + ); + expect(secondCall.status).toBe("idle"); + }); + + it("increments run_count only on success", async () => { + await forceHeartbeat(); + + const finalState = JSON.parse( + writeAtomicSyncMock.mock.calls[ + writeAtomicSyncMock.mock.calls.length - 1 + ][1] as string, + ); + expect(finalState.run_count).toBe(1); + expect(finalState.status).toBe("idle"); + }); + + it("preserves previous last_run on failure", async () => { + const previousLastRun = Date.now() - 3600_000; + existsSyncMock.mockReturnValue(true); + readFileSyncMock.mockReturnValue( + JSON.stringify({ + last_run: previousLastRun, + status: "idle", + run_count: 5, + }), + ); + + // Make agent throw + queryMock.mockImplementationOnce(async function* () { + throw new Error("Agent exploded"); + }); + + await expect(forceHeartbeat()).rejects.toThrow("Agent exploded"); + + // The last write should preserve previous last_run and run_count + const finalState = JSON.parse( + writeAtomicSyncMock.mock.calls[ + writeAtomicSyncMock.mock.calls.length - 1 + ][1] as string, + ); + expect(finalState.last_run).toBe(previousLastRun); + expect(finalState.run_count).toBe(5); + expect(finalState.status).toBe("idle"); + }); + + it("sets last_started even on failure", async () => { + existsSyncMock.mockReturnValue(false); + + queryMock.mockImplementationOnce(async function* () { + throw new Error("Boom"); + }); + + await expect(forceHeartbeat()).rejects.toThrow("Boom"); + + const finalState = JSON.parse( + writeAtomicSyncMock.mock.calls[ + writeAtomicSyncMock.mock.calls.length - 1 + ][1] as string, + ); + expect(finalState.last_started).toBeGreaterThan(0); + }); + + it("rejects concurrent runs (concurrency guard)", async () => { + const firstRun = forceHeartbeat().catch(() => {}); + + // The running flag should now be true + await expect(forceHeartbeat()).rejects.toThrow("Heartbeat already running"); + await firstRun; + }); + + it("resolves successfully when agent mock yields no messages", async () => { + await expect(forceHeartbeat()).resolves.toBeUndefined(); + }); +}); + +describe("getHeartbeatStatus", () => { + it("returns null when no state file exists", () => { + existsSyncMock.mockReturnValue(false); + expect(getHeartbeatStatus()).toBeNull(); + }); + + it("returns parsed state when file exists", () => { + const state = { + last_run: Date.now(), + status: "idle", + run_count: 3, + }; + existsSyncMock.mockReturnValue(true); + readFileSyncMock.mockReturnValue(JSON.stringify(state)); + const result = getHeartbeatStatus(); + expect(result?.run_count).toBe(3); + expect(result?.status).toBe("idle"); + }); + + it("returns null for corrupt JSON", () => { + existsSyncMock.mockReturnValue(true); + readFileSyncMock.mockReturnValue("{ invalid json "); + expect(getHeartbeatStatus()).toBeNull(); + }); + + it("returns null when last_run is not a number", () => { + existsSyncMock.mockReturnValue(true); + readFileSyncMock.mockReturnValue( + JSON.stringify({ last_run: "not-a-number", status: "idle" }), + ); + expect(getHeartbeatStatus()).toBeNull(); + }); +}); + +describe("awaitCurrentRun", () => { + beforeEach(() => { + initHeartbeat({ model: "claude-sonnet-4-6" }); + existsSyncMock.mockReturnValue(false); + writeAtomicSyncMock.mockClear(); + }); + + it("resolves immediately when no run is in progress", async () => { + await expect(awaitCurrentRun()).resolves.toBeUndefined(); + }); + + it("waits for in-flight run to complete", async () => { + let resolveAgent!: () => void; + const agentPromise = new Promise((r) => { + resolveAgent = r; + }); + + queryMock.mockImplementationOnce(async function* () { + await agentPromise; + }); + + const runPromise = forceHeartbeat().catch(() => {}); + + // awaitCurrentRun should not resolve until the agent finishes + let awaited = false; + const awaitPromise = awaitCurrentRun().then(() => { + awaited = true; + }); + + // Give microtasks a chance to run + await new Promise((r) => setTimeout(r, 10)); + expect(awaited).toBe(false); + + // Now resolve the agent + resolveAgent(); + await awaitPromise; + await runPromise; + expect(awaited).toBe(true); + }); +}); diff --git a/src/core/heartbeat.ts b/src/core/heartbeat.ts index ba19f8f44..9d445a20e 100644 --- a/src/core/heartbeat.ts +++ b/src/core/heartbeat.ts @@ -20,13 +20,15 @@ import { log, logError, logWarn } from "../util/log.js"; // ── Types ──────────────────────────────────────────────────────────────────── export type HeartbeatState = { - /** Unix millisecond timestamp of the last completed heartbeat run. */ + /** Unix millisecond timestamp of the last successfully completed heartbeat run. */ last_run: number; - /** Human-readable ISO timestamp of the last completed heartbeat run. */ + /** Human-readable ISO timestamp of the last successfully completed heartbeat run. */ last_run_at?: string; + /** Unix millisecond timestamp of the last time a heartbeat was started (success or failure). */ + last_started?: number; /** "idle" when no heartbeat is running, "running" while one is active. */ status: "idle" | "running"; - /** Total number of completed heartbeat runs. */ + /** Total number of successfully completed heartbeat runs. */ run_count: number; }; @@ -41,6 +43,7 @@ const INSTRUCTIONS_FILE = resolve(dirs.workspace, "heartbeat-instructions.md"); // ── State ──────────────────────────────────────────────────────────────────── let running = false; // in-process guard (one heartbeat at a time) +let currentRunPromise: Promise | null = null; // tracks in-flight run for graceful shutdown let timer: ReturnType | null = null; let startupTimer: ReturnType | null = null; let intervalMinutesRef = 60; // stored from startHeartbeatTimer @@ -70,7 +73,7 @@ export function initHeartbeat(cfg: { * then repeats at the configured interval. */ export function startHeartbeatTimer(intervalMinutes: number): void { - if (timer) return; // already running + if (timer || startupTimer) return; // already running or scheduled to start intervalMinutesRef = intervalMinutes; const intervalMs = intervalMinutes * 60 * 1000; @@ -92,7 +95,8 @@ export function startHeartbeatTimer(intervalMinutes: number): void { } /** - * Stop the heartbeat timer. + * Stop the heartbeat timer. Does not wait for in-flight runs. + * Use awaitCurrentRun() after this to wait for a running heartbeat to finish. */ export function stopHeartbeatTimer(): void { if (startupTimer) { @@ -106,6 +110,21 @@ export function stopHeartbeatTimer(): void { } } +/** + * Wait for any in-flight heartbeat run to complete. + * Call after stopHeartbeatTimer() during graceful shutdown. + */ +export async function awaitCurrentRun(): Promise { + if (currentRunPromise) { + log("heartbeat", "Waiting for in-flight heartbeat to complete..."); + try { + await currentRunPromise; + } catch { + // Already logged in executeHeartbeat + } + } +} + /** * Force a heartbeat run immediately. * Returns a promise that resolves when the heartbeat completes. @@ -130,44 +149,61 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise { const state = readHeartbeatState(); const now = Date.now(); - const runCount = (state?.run_count ?? 0) + 1; + const previousRunCount = state?.run_count ?? 0; + const previousLastRun = state?.last_run ?? 0; running = true; + // Mark as running with last_started, but preserve last_run from previous successful run writeHeartbeatState({ - last_run: now, + last_run: previousLastRun, + last_started: now, status: "running", - run_count: runCount, + run_count: previousRunCount, }); log( "heartbeat", - `${trigger === "forced" ? "Force-triggering" : "Triggering"} heartbeat #${runCount} (last run: ${state?.last_run ? new Date(state.last_run).toISOString() : "never"})`, + `${trigger === "forced" ? "Force-triggering" : "Triggering"} heartbeat #${previousRunCount + 1} (last run: ${previousLastRun ? new Date(previousLastRun).toISOString() : "never"})`, ); - try { - const heartbeatLogPath = await runHeartbeatAgent( - state?.last_run ?? 0, - runCount, - ); - writeHeartbeatState({ - last_run: Date.now(), - status: "idle", - run_count: runCount, - }); - log( - "heartbeat", - `Heartbeat #${runCount} complete (${trigger}), log: ${heartbeatLogPath}`, - ); - } catch (err) { - logError("heartbeat", `Heartbeat #${runCount} failed (${trigger})`, err); - writeHeartbeatState({ - last_run: Date.now(), - status: "idle", - run_count: runCount, - }); - if (trigger === "forced") throw err; - } finally { - running = false; - } + const run = (async () => { + try { + const heartbeatLogPath = await runHeartbeatAgent( + previousLastRun, + previousRunCount + 1, + ); + // Only update last_run and increment run_count on success + writeHeartbeatState({ + last_run: Date.now(), + last_started: now, + status: "idle", + run_count: previousRunCount + 1, + }); + log( + "heartbeat", + `Heartbeat #${previousRunCount + 1} complete (${trigger}), log: ${heartbeatLogPath}`, + ); + } catch (err) { + logError( + "heartbeat", + `Heartbeat #${previousRunCount + 1} failed (${trigger})`, + err, + ); + // On failure, revert to idle but keep previous last_run and run_count + writeHeartbeatState({ + last_run: previousLastRun, + last_started: now, + status: "idle", + run_count: previousRunCount, + }); + if (trigger === "forced") throw err; + } finally { + running = false; + currentRunPromise = null; + } + })(); + + currentRunPromise = run; + await run; } // ── Heartbeat agent ───────────────────────────────────────────────────────── diff --git a/src/index.ts b/src/index.ts index 6cd5e56ec..0b34d8848 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,11 @@ import { flushHistory } from "./storage/history.js"; import { flushMediaIndex } from "./storage/media-index.js"; import { getActiveCount } from "./core/dispatcher.js"; import { startPulseTimer, stopPulseTimer } from "./core/pulse.js"; -import { startHeartbeatTimer, stopHeartbeatTimer } from "./core/heartbeat.js"; +import { + startHeartbeatTimer, + stopHeartbeatTimer, + awaitCurrentRun as awaitHeartbeat, +} from "./core/heartbeat.js"; import { startCronTimer, stopCronTimer } from "./core/cron.js"; import { startWatchdog, stopWatchdog } from "./util/watchdog.js"; import { log, logError, logWarn } from "./util/log.js"; @@ -99,6 +103,7 @@ async function gracefulShutdown(signal: string): Promise { } stopPulseTimer(); stopHeartbeatTimer(); + await awaitHeartbeat(); stopCronTimer(); stopWatchdog(); stopUploadCleanup(); From 3e6f378f48fd67e13fc221cf327429b94c19e4ad Mon Sep 17 00:00:00 2001 From: Dylan Date: Sat, 4 Apr 2026 15:05:50 +0000 Subject: [PATCH 04/11] fix: address second round of Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate all HeartbeatState fields (run_count, status, last_started) via normalizeHeartbeatState() — corrupt state now returns null - Throw on uninitialized configRef instead of silently succeeding - Add sequence number to log filenames for uniqueness - Remove unused dreamModel from heartbeat config shape - Clarify prompt template for first-ever run ("never" case) - Add 3 new validation tests (19 total) Co-Authored-By: Claude Opus 4.6 --- prompts/heartbeat.md | 2 +- src/__tests__/heartbeat.test.ts | 43 ++++++++++++++++++++++++++++++++- src/bootstrap.ts | 1 - src/core/heartbeat.ts | 42 +++++++++++++++++++++++++------- 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/prompts/heartbeat.md b/prompts/heartbeat.md index 19cc294de..492ba3d13 100644 --- a/prompts/heartbeat.md +++ b/prompts/heartbeat.md @@ -16,7 +16,7 @@ Read the user-defined instructions file at `{{instructionsFile}}`. Follow whatev If the instructions file does not exist or is empty, perform these default tasks: -1. **Review recent logs** — Check `{{logsDir}}/` for log files dated after `{{lastRunIso}}`. Extract any new facts, preferences, or notable events. +1. **Review recent logs** — Check `{{logsDir}}/` for log files dated after `{{lastRunIso}}`. If `{{lastRunIso}}` is `never`, treat it as the beginning of time and review all available logs. Extract any new facts, preferences, or notable events. 2. **Update memory** — Merge any new information into `{{memoryFile}}`, keeping entries concise and factual. 3. **Workspace hygiene** — Note any issues but do not delete files unless the instructions explicitly say to. diff --git a/src/__tests__/heartbeat.test.ts b/src/__tests__/heartbeat.test.ts index 38201b26c..492990d1d 100644 --- a/src/__tests__/heartbeat.test.ts +++ b/src/__tests__/heartbeat.test.ts @@ -242,8 +242,49 @@ describe("getHeartbeatStatus", () => { it("returns null when last_run is not a number", () => { existsSyncMock.mockReturnValue(true); readFileSyncMock.mockReturnValue( - JSON.stringify({ last_run: "not-a-number", status: "idle" }), + JSON.stringify({ + last_run: "not-a-number", + status: "idle", + run_count: 0, + }), + ); + expect(getHeartbeatStatus()).toBeNull(); + }); + + it("returns null when run_count is not a number", () => { + existsSyncMock.mockReturnValue(true); + readFileSyncMock.mockReturnValue( + JSON.stringify({ + last_run: Date.now(), + status: "idle", + run_count: "five", + }), + ); + expect(getHeartbeatStatus()).toBeNull(); + }); + + it("returns null when status is invalid", () => { + existsSyncMock.mockReturnValue(true); + readFileSyncMock.mockReturnValue( + JSON.stringify({ + last_run: Date.now(), + status: "broken", + run_count: 1, + }), + ); + expect(getHeartbeatStatus()).toBeNull(); + }); + + it("returns null when last_run is NaN", () => { + existsSyncMock.mockReturnValue(true); + readFileSyncMock.mockReturnValue( + JSON.stringify({ + last_run: NaN, + status: "idle", + run_count: 0, + }), ); + // NaN is typeof number but not finite expect(getHeartbeatStatus()).toBeNull(); }); }); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index ddaef4a38..98718f89a 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -128,7 +128,6 @@ export async function initBackendAndDispatcher( initHeartbeat({ model: config.model, heartbeatModel: config.heartbeatModel, - dreamModel: config.dreamModel, claudeBinary: config.claudeBinary, workspace: config.workspace, }); diff --git a/src/core/heartbeat.ts b/src/core/heartbeat.ts index 9d445a20e..f3ccbfdf3 100644 --- a/src/core/heartbeat.ts +++ b/src/core/heartbeat.ts @@ -50,7 +50,6 @@ let intervalMinutesRef = 60; // stored from startHeartbeatTimer let configRef: { model?: string; heartbeatModel?: string; - dreamModel?: string; claudeBinary?: string; workspace?: string; } | null = null; @@ -59,7 +58,6 @@ export function initHeartbeat(cfg: { model?: string; /** Override model used specifically for heartbeat (e.g. haiku for cost savings). Falls back to main model. */ heartbeatModel?: string; - dreamModel?: string; claudeBinary?: string; workspace?: string; }): void { @@ -213,8 +211,7 @@ async function runHeartbeatAgent( runCount: number, ): Promise { if (!configRef) { - logWarn("heartbeat", "Heartbeat agent not initialized — skipping"); - return ""; + throw new Error("Heartbeat agent not initialized"); } const lastRunIso = @@ -326,13 +323,16 @@ async function runHeartbeatAgent( // ── Logging helpers ───────────────────────────────────────────────────────── +let heartbeatLogFileSequence = 0; + function createHeartbeatLogFile(): string { if (!existsSync(HEARTBEAT_LOGS_DIR)) { mkdirSync(HEARTBEAT_LOGS_DIR, { recursive: true }); } const now = new Date(); - const ts = now.toISOString().replace(/[:.]/g, "-").slice(0, 19); - return resolve(HEARTBEAT_LOGS_DIR, `heartbeat-${ts}.md`); + const ts = now.toISOString().replace(/[:.]/g, "-"); + const seq = heartbeatLogFileSequence++; + return resolve(HEARTBEAT_LOGS_DIR, `heartbeat-${ts}-${seq}.md`); } function appendHeartbeatLog(logFile: string, text: string): void { @@ -414,13 +414,37 @@ function logHeartbeatMessage(logFile: string, msg: SDKMessage): void { // ── State helpers ──────────────────────────────────────────────────────────── +function normalizeHeartbeatState(parsed: unknown): HeartbeatState | null { + if (!parsed || typeof parsed !== "object") return null; + + const candidate = parsed as Record; + const { last_run, last_run_at, last_started, status, run_count } = candidate; + + if (typeof last_run !== "number" || !Number.isFinite(last_run)) return null; + if (typeof run_count !== "number" || !Number.isFinite(run_count)) return null; + if (status !== "idle" && status !== "running") return null; + if (last_run_at !== undefined && typeof last_run_at !== "string") return null; + if ( + last_started !== undefined && + (typeof last_started !== "number" || !Number.isFinite(last_started)) + ) { + return null; + } + + return { + last_run, + run_count, + status, + ...(last_run_at !== undefined ? { last_run_at } : {}), + ...(last_started !== undefined ? { last_started } : {}), + }; +} + function readHeartbeatState(): HeartbeatState | null { try { if (!existsSync(HEARTBEAT_STATE_FILE)) return null; const raw = readFileSync(HEARTBEAT_STATE_FILE, "utf-8"); - const parsed = JSON.parse(raw) as HeartbeatState; - if (typeof parsed.last_run !== "number") return null; - return parsed; + return normalizeHeartbeatState(JSON.parse(raw)); } catch { return null; } From 41d4f19492194eae08e9705a853f58c0494199df Mon Sep 17 00:00:00 2001 From: Dylan Date: Sat, 4 Apr 2026 15:17:52 +0000 Subject: [PATCH 05/11] fix: address third round of Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate intervalMinutes (reject NaN, 0, negative values) - Derive instructions file path from workspace config, not dirs constant - Document timeout limitation (SDK lacks abort mechanism) - Omit last_run_at when last_run is 0 to avoid misleading epoch timestamps - Rename heartbeatInterval → heartbeatIntervalMinutes for clarity - Keep logWarn import (now used for interval validation) Co-Authored-By: Claude Opus 4.6 --- src/core/heartbeat.ts | 23 +++++++++++++++++++---- src/index.ts | 2 +- src/util/config.ts | 2 +- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/core/heartbeat.ts b/src/core/heartbeat.ts index f3ccbfdf3..5c08999dd 100644 --- a/src/core/heartbeat.ts +++ b/src/core/heartbeat.ts @@ -38,7 +38,6 @@ const HEARTBEAT_STATE_FILE = pathFiles.heartbeatState; const HEARTBEAT_TIMEOUT_MS = 10 * 60 * 1000; // 10-minute max const HEARTBEAT_LOGS_DIR = resolve(dirs.logs, "heartbeats"); const STARTUP_DELAY_MS = 5 * 60 * 1000; // 5-minute delay before first run -const INSTRUCTIONS_FILE = resolve(dirs.workspace, "heartbeat-instructions.md"); // ── State ──────────────────────────────────────────────────────────────────── @@ -73,6 +72,14 @@ export function initHeartbeat(cfg: { export function startHeartbeatTimer(intervalMinutes: number): void { if (timer || startupTimer) return; // already running or scheduled to start + if (!Number.isFinite(intervalMinutes) || intervalMinutes <= 0) { + logWarn( + "heartbeat", + `Refusing to start heartbeat timer with invalid intervalMinutes: ${intervalMinutes}`, + ); + return; + } + intervalMinutesRef = intervalMinutes; const intervalMs = intervalMinutes * 60 * 1000; log( @@ -220,6 +227,7 @@ async function runHeartbeatAgent( const logsDir = dirs.logs; const memoryFile = pathFiles.memory; const workspace = configRef.workspace ?? dirs.workspace; + const instructionsFile = resolve(workspace, "heartbeat-instructions.md"); // Load prompt template from prompts/heartbeat.md and interpolate variables const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); @@ -232,7 +240,7 @@ async function runHeartbeatAgent( .replace(/\{\{logsDir\}\}/g, logsDir) .replace(/\{\{lastRunIso\}\}/g, lastRunIso) .replace(/\{\{memoryFile\}\}/g, memoryFile) - .replace(/\{\{instructionsFile\}\}/g, INSTRUCTIONS_FILE) + .replace(/\{\{instructionsFile\}\}/g, instructionsFile) .replace(/\{\{runCount\}\}/g, String(runCount)) .replace(/\{\{intervalMinutes\}\}/g, String(intervalMinutesRef)); } catch { @@ -287,6 +295,10 @@ async function runHeartbeatAgent( ], }; + // NOTE: The timeout races against the agent promise but cannot abort the + // underlying Claude subprocess (the Agent SDK does not expose an abort + // mechanism). On timeout, the running flag is cleared and state set to idle, + // but the subprocess may continue briefly until it naturally completes. const timeoutPromise = new Promise((_, reject) => setTimeout( () => reject(new Error("Heartbeat agent timed out")), @@ -454,9 +466,12 @@ function writeHeartbeatState(state: HeartbeatState): void { try { const dir = resolve(HEARTBEAT_STATE_FILE, ".."); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const { last_run_at: _lastRunAt, ...rest } = state; const enriched: HeartbeatState = { - ...state, - last_run_at: new Date(state.last_run).toISOString(), + ...rest, + ...(state.last_run !== 0 + ? { last_run_at: new Date(state.last_run).toISOString() } + : {}), }; writeFileAtomic.sync( HEARTBEAT_STATE_FILE, diff --git a/src/index.ts b/src/index.ts index 0b34d8848..a713f05d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -154,7 +154,7 @@ async function main(): Promise { log("bot", "Starting Talon..."); if (config.pulse) startPulseTimer(config.pulseIntervalMs); - if (config.heartbeat) startHeartbeatTimer(config.heartbeatInterval); + if (config.heartbeat) startHeartbeatTimer(config.heartbeatIntervalMinutes); startCronTimer(); startWatchdog(config.workspace); startUploadCleanup(config.workspace); diff --git a/src/util/config.ts b/src/util/config.ts index 41734a850..5d52976f0 100644 --- a/src/util/config.ts +++ b/src/util/config.ts @@ -36,7 +36,7 @@ const configSchema = z.object({ pulse: z.boolean().default(true), pulseIntervalMs: z.number().int().min(60000).default(300000), heartbeat: z.boolean().default(false), - heartbeatInterval: z.number().int().min(5).default(60), // minutes + heartbeatIntervalMinutes: z.number().int().min(5).default(60), heartbeatModel: z.string().optional(), // Model for heartbeat agent (defaults to main model) braveApiKey: z.string().optional(), searxngUrl: z.string().default("http://localhost:8080"), From 7eb2d229bfd34bb47393489e772a7f995d72c13b Mon Sep 17 00:00:00 2001 From: Dylan Date: Sat, 4 Apr 2026 15:25:19 +0000 Subject: [PATCH 06/11] fix: clear timeout timer on early finish and hold lock through timeout - clearTimeout on successful agent completion to prevent dangling timers - On timeout, await agentPromise before releasing running lock to prevent overlapping heartbeat runs Co-Authored-By: Claude Opus 4.6 --- src/core/heartbeat.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/core/heartbeat.ts b/src/core/heartbeat.ts index 5c08999dd..f94fc6292 100644 --- a/src/core/heartbeat.ts +++ b/src/core/heartbeat.ts @@ -297,14 +297,15 @@ async function runHeartbeatAgent( // NOTE: The timeout races against the agent promise but cannot abort the // underlying Claude subprocess (the Agent SDK does not expose an abort - // mechanism). On timeout, the running flag is cleared and state set to idle, - // but the subprocess may continue briefly until it naturally completes. - const timeoutPromise = new Promise((_, reject) => - setTimeout( + // mechanism). On timeout, we still await the agent promise to ensure the + // running lock is not released while the subprocess is still active. + let timeoutHandle: ReturnType | null = null; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout( () => reject(new Error("Heartbeat agent timed out")), HEARTBEAT_TIMEOUT_MS, - ), - ); + ); + }); const agentPromise = (async () => { const qi = query({ @@ -327,7 +328,12 @@ async function runHeartbeatAgent( heartbeatLogFile, `\n---\n**Heartbeat #${runCount} FAILED at ${new Date().toISOString()}:** ${err}\n`, ); + // On timeout, wait for the agent to actually finish before releasing the lock + // to prevent overlapping heartbeat runs + await agentPromise.catch(() => {}); throw err; + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); } return heartbeatLogFile; From 41bd173ea6bcf757fe40db60ea853d65ed948ff8 Mon Sep 17 00:00:00 2001 From: Dylan Date: Sat, 4 Apr 2026 15:31:37 +0000 Subject: [PATCH 07/11] fix: bound shutdown await and improve test isolation - awaitCurrentRun() now accepts a timeout (default 10s) to avoid blocking shutdown indefinitely when a heartbeat agent is mid-run - Reset readFileSyncMock in test beforeEach with path-sensitive impl to prevent order-dependent test failures Co-Authored-By: Claude Opus 4.6 --- src/__tests__/heartbeat.test.ts | 14 ++++++++++++++ src/core/heartbeat.ts | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/__tests__/heartbeat.test.ts b/src/__tests__/heartbeat.test.ts index 492990d1d..7e7452c80 100644 --- a/src/__tests__/heartbeat.test.ts +++ b/src/__tests__/heartbeat.test.ts @@ -123,6 +123,13 @@ describe("forceHeartbeat", () => { beforeEach(() => { initHeartbeat({ model: "claude-sonnet-4-6" }); existsSyncMock.mockReturnValue(false); + readFileSyncMock.mockReset(); + readFileSyncMock.mockImplementation((filePath: string) => { + const p = String(filePath).replace(/\\/g, "/"); + if (p.endsWith("/prompts/heartbeat.md")) + return "heartbeat prompt {{workspace}} {{logsDir}} {{lastRunIso}} {{memoryFile}} {{instructionsFile}} {{runCount}} {{intervalMinutes}}"; + return "null"; + }); writeAtomicSyncMock.mockClear(); queryMock.mockClear(); }); @@ -293,6 +300,13 @@ describe("awaitCurrentRun", () => { beforeEach(() => { initHeartbeat({ model: "claude-sonnet-4-6" }); existsSyncMock.mockReturnValue(false); + readFileSyncMock.mockReset(); + readFileSyncMock.mockImplementation((filePath: string) => { + const p = String(filePath).replace(/\\/g, "/"); + if (p.endsWith("/prompts/heartbeat.md")) + return "heartbeat prompt {{workspace}} {{logsDir}} {{lastRunIso}} {{memoryFile}} {{instructionsFile}} {{runCount}} {{intervalMinutes}}"; + return "null"; + }); writeAtomicSyncMock.mockClear(); }); diff --git a/src/core/heartbeat.ts b/src/core/heartbeat.ts index f94fc6292..7c54aa559 100644 --- a/src/core/heartbeat.ts +++ b/src/core/heartbeat.ts @@ -119,11 +119,23 @@ export function stopHeartbeatTimer(): void { * Wait for any in-flight heartbeat run to complete. * Call after stopHeartbeatTimer() during graceful shutdown. */ -export async function awaitCurrentRun(): Promise { +export async function awaitCurrentRun(timeoutMs = 10_000): Promise { if (currentRunPromise) { log("heartbeat", "Waiting for in-flight heartbeat to complete..."); try { - await currentRunPromise; + await Promise.race([ + currentRunPromise, + new Promise((resolve) => { + const t = setTimeout(() => { + logWarn( + "heartbeat", + "In-flight heartbeat did not finish within shutdown budget, proceeding", + ); + resolve(); + }, timeoutMs); + t.unref(); + }), + ]); } catch { // Already logged in executeHeartbeat } From 58caeddf61c000596191735c737a33829b5be95e Mon Sep 17 00:00:00 2001 From: Dylan Date: Sat, 4 Apr 2026 15:34:26 +0000 Subject: [PATCH 08/11] fix: resolve type error in heartbeat test mock implementation Cast path-sensitive readFileSyncMock to any to satisfy vi.fn() type constraint while accepting a filePath parameter. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/heartbeat.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/__tests__/heartbeat.test.ts b/src/__tests__/heartbeat.test.ts index 7e7452c80..b8bdd736c 100644 --- a/src/__tests__/heartbeat.test.ts +++ b/src/__tests__/heartbeat.test.ts @@ -124,12 +124,13 @@ describe("forceHeartbeat", () => { initHeartbeat({ model: "claude-sonnet-4-6" }); existsSyncMock.mockReturnValue(false); readFileSyncMock.mockReset(); - readFileSyncMock.mockImplementation((filePath: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readFileSyncMock.mockImplementation(((filePath: string) => { const p = String(filePath).replace(/\\/g, "/"); if (p.endsWith("/prompts/heartbeat.md")) return "heartbeat prompt {{workspace}} {{logsDir}} {{lastRunIso}} {{memoryFile}} {{instructionsFile}} {{runCount}} {{intervalMinutes}}"; return "null"; - }); + }) as any); writeAtomicSyncMock.mockClear(); queryMock.mockClear(); }); @@ -301,12 +302,13 @@ describe("awaitCurrentRun", () => { initHeartbeat({ model: "claude-sonnet-4-6" }); existsSyncMock.mockReturnValue(false); readFileSyncMock.mockReset(); - readFileSyncMock.mockImplementation((filePath: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readFileSyncMock.mockImplementation(((filePath: string) => { const p = String(filePath).replace(/\\/g, "/"); if (p.endsWith("/prompts/heartbeat.md")) return "heartbeat prompt {{workspace}} {{logsDir}} {{lastRunIso}} {{memoryFile}} {{instructionsFile}} {{runCount}} {{intervalMinutes}}"; return "null"; - }); + }) as any); writeAtomicSyncMock.mockClear(); }); From 9698bcb610332e7aab3497971ff9a0c6ff64ca36 Mon Sep 17 00:00:00 2001 From: Dylan Date: Sat, 4 Apr 2026 15:43:28 +0000 Subject: [PATCH 09/11] fix: use dirs.prompts for prompt path, strengthen timer guard tests - Load heartbeat prompt from dirs.prompts instead of relative import.meta.url resolution, fixing breakage when running from non-root working directories - Remove unused dirname/fileURLToPath imports - Replace no-op timer guard assertions with setTimeout/setInterval spy counts - Add prompts dir to test path mock Co-Authored-By: Claude Opus 4.6 --- src/__tests__/heartbeat.test.ts | 33 ++++++++++++++++++++++++--------- src/core/heartbeat.ts | 8 +++----- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/__tests__/heartbeat.test.ts b/src/__tests__/heartbeat.test.ts index b8bdd736c..e89d37a08 100644 --- a/src/__tests__/heartbeat.test.ts +++ b/src/__tests__/heartbeat.test.ts @@ -56,6 +56,7 @@ vi.mock("../util/paths.js", () => ({ workspace: "/fake/.talon/workspace", data: "/fake/.talon/data", memory: "/fake/.talon/workspace/memory", + prompts: "/fake/.talon/prompts", }, })); @@ -99,23 +100,37 @@ describe("startHeartbeatTimer", () => { }); it("guards against double-start during startup delay", () => { + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); + startHeartbeatTimer(60); + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).not.toHaveBeenCalled(); + // Calling again during the 5-minute startup delay should be a no-op - // (no duplicate timers created) startHeartbeatTimer(60); - // If the guard works, stopping once should clean up everything - stopHeartbeatTimer(); - expect(true).toBe(true); // no crash = success + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).not.toHaveBeenCalled(); + + setTimeoutSpy.mockRestore(); + setIntervalSpy.mockRestore(); }); it("guards against double-start after interval is set", () => { + const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); + startHeartbeatTimer(60); + expect(setIntervalSpy).not.toHaveBeenCalled(); + // Advance past startup delay to create the interval timer vi.advanceTimersByTime(5 * 60 * 1000 + 100); - // Now try to start again — should be a no-op + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + + // Now try to start again — should be a no-op (interval count stays at 1) startHeartbeatTimer(60); - stopHeartbeatTimer(); - expect(true).toBe(true); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + + setIntervalSpy.mockRestore(); }); }); @@ -127,7 +142,7 @@ describe("forceHeartbeat", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any readFileSyncMock.mockImplementation(((filePath: string) => { const p = String(filePath).replace(/\\/g, "/"); - if (p.endsWith("/prompts/heartbeat.md")) + if (p.endsWith("/heartbeat.md")) return "heartbeat prompt {{workspace}} {{logsDir}} {{lastRunIso}} {{memoryFile}} {{instructionsFile}} {{runCount}} {{intervalMinutes}}"; return "null"; }) as any); @@ -305,7 +320,7 @@ describe("awaitCurrentRun", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any readFileSyncMock.mockImplementation(((filePath: string) => { const p = String(filePath).replace(/\\/g, "/"); - if (p.endsWith("/prompts/heartbeat.md")) + if (p.endsWith("/heartbeat.md")) return "heartbeat prompt {{workspace}} {{logsDir}} {{lastRunIso}} {{memoryFile}} {{instructionsFile}} {{runCount}} {{intervalMinutes}}"; return "null"; }) as any); diff --git a/src/core/heartbeat.ts b/src/core/heartbeat.ts index 7c54aa559..4ee4f99e5 100644 --- a/src/core/heartbeat.ts +++ b/src/core/heartbeat.ts @@ -9,8 +9,7 @@ */ import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; import writeFileAtomic from "write-file-atomic"; import { query } from "@anthropic-ai/claude-agent-sdk"; import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; @@ -241,9 +240,8 @@ async function runHeartbeatAgent( const workspace = configRef.workspace ?? dirs.workspace; const instructionsFile = resolve(workspace, "heartbeat-instructions.md"); - // Load prompt template from prompts/heartbeat.md and interpolate variables - const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); - const promptPath = resolve(projectRoot, "prompts/heartbeat.md"); + // Load prompt template from the prompts directory (seeded to ~/.talon/prompts/) + const promptPath = resolve(dirs.prompts, "heartbeat.md"); let prompt: string; try { From 63ef83fc51e5d566d5885f0c7394b5d068c12aa9 Mon Sep 17 00:00:00 2001 From: Dylan Date: Sat, 4 Apr 2026 15:58:36 +0000 Subject: [PATCH 10/11] refactor: switch heartbeat logging to async I/O Replace appendFileSync/mkdirSync with async appendFile/mkdir from node:fs/promises to avoid blocking the event loop during heartbeat agent streaming. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/heartbeat.test.ts | 9 ++++-- src/core/heartbeat.ts | 49 +++++++++++++++++++++------------ 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/__tests__/heartbeat.test.ts b/src/__tests__/heartbeat.test.ts index e89d37a08..4ba48297f 100644 --- a/src/__tests__/heartbeat.test.ts +++ b/src/__tests__/heartbeat.test.ts @@ -20,13 +20,18 @@ vi.mock("../util/log.js", () => ({ const existsSyncMock = vi.fn(() => false); const readFileSyncMock = vi.fn(() => "null"); const mkdirSyncMock = vi.fn(); -const appendFileSyncMock = vi.fn(); vi.mock("node:fs", () => ({ existsSync: existsSyncMock, readFileSync: readFileSyncMock, mkdirSync: mkdirSyncMock, - appendFileSync: appendFileSyncMock, +})); + +const appendFileMock = vi.fn(async () => {}); +const mkdirAsyncMock = vi.fn(async () => undefined); +vi.mock("node:fs/promises", () => ({ + appendFile: appendFileMock, + mkdir: mkdirAsyncMock, })); const writeAtomicSyncMock = vi.fn(); diff --git a/src/core/heartbeat.ts b/src/core/heartbeat.ts index 4ee4f99e5..00f3689d6 100644 --- a/src/core/heartbeat.ts +++ b/src/core/heartbeat.ts @@ -8,7 +8,8 @@ * Modeled after dream.ts but more general-purpose. */ -import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs"; +import { existsSync, readFileSync, mkdirSync } from "node:fs"; +import { appendFile, mkdir } from "node:fs/promises"; import { resolve } from "node:path"; import writeFileAtomic from "write-file-atomic"; import { query } from "@anthropic-ai/claude-agent-sdk"; @@ -261,16 +262,16 @@ async function runHeartbeatAgent( configRef.heartbeatModel ?? configRef.model ?? "claude-sonnet-4-6"; // Set up heartbeat log file - const heartbeatLogFile = createHeartbeatLogFile(); - appendHeartbeatLog( + const heartbeatLogFile = await createHeartbeatLogFile(); + await appendHeartbeatLog( heartbeatLogFile, `# Heartbeat Run #${runCount} — ${new Date().toISOString()}\n`, ); - appendHeartbeatLog( + await appendHeartbeatLog( heartbeatLogFile, `**Trigger:** ${lastRunIso === "never" ? "first run" : `last_run=${lastRunIso}`}, model=${model}\n`, ); - appendHeartbeatLog( + await appendHeartbeatLog( heartbeatLogFile, `**Prompt:**\n\`\`\`\n${prompt}\n\`\`\`\n\n---\n`, ); @@ -323,9 +324,9 @@ async function runHeartbeatAgent( options: options as Parameters[0]["options"], }); for await (const msg of qi) { - logHeartbeatMessage(heartbeatLogFile, msg); + await logHeartbeatMessage(heartbeatLogFile, msg); } - appendHeartbeatLog( + await appendHeartbeatLog( heartbeatLogFile, `\n---\n**Heartbeat #${runCount} completed at ${new Date().toISOString()}**\n`, ); @@ -334,7 +335,7 @@ async function runHeartbeatAgent( try { await Promise.race([agentPromise, timeoutPromise]); } catch (err) { - appendHeartbeatLog( + await appendHeartbeatLog( heartbeatLogFile, `\n---\n**Heartbeat #${runCount} FAILED at ${new Date().toISOString()}:** ${err}\n`, ); @@ -353,9 +354,9 @@ async function runHeartbeatAgent( let heartbeatLogFileSequence = 0; -function createHeartbeatLogFile(): string { +async function createHeartbeatLogFile(): Promise { if (!existsSync(HEARTBEAT_LOGS_DIR)) { - mkdirSync(HEARTBEAT_LOGS_DIR, { recursive: true }); + await mkdir(HEARTBEAT_LOGS_DIR, { recursive: true }); } const now = new Date(); const ts = now.toISOString().replace(/[:.]/g, "-"); @@ -363,15 +364,21 @@ function createHeartbeatLogFile(): string { return resolve(HEARTBEAT_LOGS_DIR, `heartbeat-${ts}-${seq}.md`); } -function appendHeartbeatLog(logFile: string, text: string): void { +async function appendHeartbeatLog( + logFile: string, + text: string, +): Promise { try { - appendFileSync(logFile, text); + await appendFile(logFile, text); } catch (err) { logError("heartbeat", "Failed to write heartbeat log", err); } } -function logHeartbeatMessage(logFile: string, msg: SDKMessage): void { +async function logHeartbeatMessage( + logFile: string, + msg: SDKMessage, +): Promise { try { const ts = new Date().toISOString().slice(11, 19); @@ -388,13 +395,16 @@ function logHeartbeatMessage(logFile: string, msg: SDKMessage): void { }); if (textBlocks.length > 0) { - appendHeartbeatLog( + await appendHeartbeatLog( logFile, `\n## [${ts}] Assistant\n${textBlocks.join("\n")}\n`, ); } if (toolUseBlocks.length > 0) { - appendHeartbeatLog(logFile, `\n${toolUseBlocks.join("\n\n")}\n`); + await appendHeartbeatLog( + logFile, + `\n${toolUseBlocks.join("\n\n")}\n`, + ); } break; } @@ -407,14 +417,17 @@ function logHeartbeatMessage(logFile: string, msg: SDKMessage): void { result.length > 2000 ? result.slice(0, 2000) + "\n... (truncated)" : result; - appendHeartbeatLog( + await appendHeartbeatLog( logFile, `\n### [${ts}] Result (${msg.subtype})\n\`\`\`\n${truncated}\n\`\`\`\n`, ); break; } case "system": { - appendHeartbeatLog(logFile, `\n### [${ts}] System (${msg.subtype})\n`); + await appendHeartbeatLog( + logFile, + `\n### [${ts}] System (${msg.subtype})\n`, + ); break; } case "user": { @@ -425,7 +438,7 @@ function logHeartbeatMessage(logFile: string, msg: SDKMessage): void { : JSON.stringify(msg.tool_use_result, null, 2); const truncated = raw.length > 2000 ? raw.slice(0, 2000) + "\n... (truncated)" : raw; - appendHeartbeatLog( + await appendHeartbeatLog( logFile, `\n### [${ts}] Tool Result\n\`\`\`\n${truncated}\n\`\`\`\n`, ); From 666f653d2cac8b26ffaded99bbcf1c7dbd4a2bd5 Mon Sep 17 00:00:00 2001 From: Dylan Date: Sat, 4 Apr 2026 18:25:01 +0000 Subject: [PATCH 11/11] fix: test non-finite number path correctly (Infinity, not NaN) JSON.stringify(NaN) serializes to null, so the previous test was hitting the wrong validation path. Use raw JSON with 1e309 (Infinity) to actually exercise the Number.isFinite() guard. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/heartbeat.test.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/__tests__/heartbeat.test.ts b/src/__tests__/heartbeat.test.ts index 4ba48297f..43d500522 100644 --- a/src/__tests__/heartbeat.test.ts +++ b/src/__tests__/heartbeat.test.ts @@ -303,16 +303,12 @@ describe("getHeartbeatStatus", () => { expect(getHeartbeatStatus()).toBeNull(); }); - it("returns null when last_run is NaN", () => { + it("returns null when last_run is non-finite", () => { existsSyncMock.mockReturnValue(true); + // 1e309 parses to Infinity, which is typeof number but not finite readFileSyncMock.mockReturnValue( - JSON.stringify({ - last_run: NaN, - status: "idle", - run_count: 0, - }), + '{"last_run":1e309,"status":"idle","run_count":0}', ); - // NaN is typeof number but not finite expect(getHeartbeatStatus()).toBeNull(); }); });