repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources16d ago| #1 | /** |
| #2 | * Heartbeat Daemon |
| #3 | * |
| #4 | * Runs periodic tasks on cron schedules inside the same Node.js process. |
| #5 | * The heartbeat runs even when the agent is sleeping. |
| #6 | * It IS the automaton's pulse. When it stops, the automaton is dead. |
| #7 | */ |
| #8 | |
| #9 | import cronParser from "cron-parser"; |
| #10 | import type { |
| #11 | AutomatonConfig, |
| #12 | AutomatonDatabase, |
| #13 | ClawdRuntimeClient, |
| #14 | AutomatonIdentity, |
| #15 | HeartbeatEntry, |
| #16 | SocialClientInterface, |
| #17 | InferenceClient, |
| #18 | } from "../types.js"; |
| #19 | import { BUILTIN_TASKS, type HeartbeatTaskContext } from "./tasks.js"; |
| #20 | import { getSurvivalTier } from "../clawd/credits.js"; |
| #21 | |
| #22 | export interface HeartbeatDaemonOptions { |
| #23 | identity: AutomatonIdentity; |
| #24 | config: AutomatonConfig; |
| #25 | db: AutomatonDatabase; |
| #26 | runtime: ClawdRuntimeClient; |
| #27 | inference?: InferenceClient; |
| #28 | social?: SocialClientInterface; |
| #29 | onWakeRequest?: (reason: string) => void; |
| #30 | } |
| #31 | |
| #32 | export interface HeartbeatDaemon { |
| #33 | start(): void; |
| #34 | stop(): void; |
| #35 | isRunning(): boolean; |
| #36 | forceRun(taskName: string): Promise<void>; |
| #37 | } |
| #38 | |
| #39 | /** |
| #40 | * Create and return the heartbeat daemon. |
| #41 | */ |
| #42 | export function createHeartbeatDaemon( |
| #43 | options: HeartbeatDaemonOptions, |
| #44 | ): HeartbeatDaemon { |
| #45 | const { identity, config, db, runtime, inference, social, onWakeRequest } = options; |
| #46 | let intervalId: ReturnType<typeof setInterval> | null = null; |
| #47 | let running = false; |
| #48 | |
| #49 | const taskContext: HeartbeatTaskContext = { |
| #50 | identity, |
| #51 | config, |
| #52 | db, |
| #53 | runtime, |
| #54 | inference, |
| #55 | social, |
| #56 | }; |
| #57 | |
| #58 | /** |
| #59 | * Check if a heartbeat entry is due to run. |
| #60 | */ |
| #61 | function isDue(entry: HeartbeatEntry): boolean { |
| #62 | if (!entry.enabled) return false; |
| #63 | if (!entry.schedule) return false; |
| #64 | |
| #65 | try { |
| #66 | const interval = cronParser.parseExpression(entry.schedule, { |
| #67 | currentDate: entry.lastRun |
| #68 | ? new Date(entry.lastRun) |
| #69 | : new Date(Date.now() - 86400000), // If never run, assume due |
| #70 | }); |
| #71 | |
| #72 | const nextRun = interval.next().toDate(); |
| #73 | return nextRun <= new Date(); |
| #74 | } catch { |
| #75 | return false; |
| #76 | } |
| #77 | } |
| #78 | |
| #79 | /** |
| #80 | * Execute a single heartbeat task. |
| #81 | */ |
| #82 | async function executeTask(entry: HeartbeatEntry): Promise<void> { |
| #83 | const taskFn = BUILTIN_TASKS[entry.task]; |
| #84 | if (!taskFn) { |
| #85 | // Unknown task -- skip silently |
| #86 | return; |
| #87 | } |
| #88 | |
| #89 | try { |
| #90 | const result = await taskFn(taskContext); |
| #91 | |
| #92 | // Update last run |
| #93 | const now = new Date().toISOString(); |
| #94 | db.updateHeartbeatLastRun(entry.name, now); |
| #95 | |
| #96 | // If the task says we should wake, fire the callback |
| #97 | if (result.shouldWake && onWakeRequest) { |
| #98 | onWakeRequest( |
| #99 | result.message || `Heartbeat task '${entry.name}' requested wake`, |
| #100 | ); |
| #101 | } |
| #102 | } catch (err: any) { |
| #103 | // Log error but don't crash the daemon |
| #104 | console.error( |
| #105 | `[HEARTBEAT] Task '${entry.name}' failed: ${err.message}`, |
| #106 | ); |
| #107 | } |
| #108 | } |
| #109 | |
| #110 | /** |
| #111 | * The main tick function. Runs on every interval. |
| #112 | */ |
| #113 | async function tick(): Promise<void> { |
| #114 | const entries = db.getHeartbeatEntries(); |
| #115 | |
| #116 | // Check survival tier to adjust behavior |
| #117 | let creditsCents = 0; |
| #118 | try { |
| #119 | creditsCents = await runtime.getCreditsBalance(); |
| #120 | } catch {} |
| #121 | |
| #122 | const tier = getSurvivalTier(creditsCents); |
| #123 | const isLowCompute = tier === "low_compute" || tier === "critical" || tier === "dead"; |
| #124 | |
| #125 | for (const entry of entries) { |
| #126 | if (!entry.enabled) continue; |
| #127 | |
| #128 | // In low compute mode, only run essential tasks |
| #129 | if (isLowCompute) { |
| #130 | const essentialTasks = [ |
| #131 | "heartbeat_ping", |
| #132 | "check_credits", |
| #133 | "check_usdc_balance", |
| #134 | "check_social_inbox", |
| #135 | ]; |
| #136 | if (!essentialTasks.includes(entry.task)) continue; |
| #137 | } |
| #138 | |
| #139 | if (isDue(entry)) { |
| #140 | await executeTask(entry); |
| #141 | } |
| #142 | } |
| #143 | } |
| #144 | |
| #145 | // ─── Public API ────────────────────────────────────────────── |
| #146 | |
| #147 | const start = (): void => { |
| #148 | if (running) return; |
| #149 | running = true; |
| #150 | |
| #151 | // Get tick interval -- default 60 seconds |
| #152 | const tickMs = config.logLevel === "debug" ? 15_000 : 60_000; |
| #153 | |
| #154 | // Run first tick immediately |
| #155 | tick().catch((err) => { |
| #156 | console.error(`[HEARTBEAT] First tick failed: ${err.message}`); |
| #157 | }); |
| #158 | |
| #159 | intervalId = setInterval(() => { |
| #160 | tick().catch((err) => { |
| #161 | console.error(`[HEARTBEAT] Tick failed: ${err.message}`); |
| #162 | }); |
| #163 | }, tickMs); |
| #164 | |
| #165 | console.log( |
| #166 | `[HEARTBEAT] Daemon started. Tick interval: ${tickMs / 1000}s`, |
| #167 | ); |
| #168 | }; |
| #169 | |
| #170 | const stop = (): void => { |
| #171 | if (!running) return; |
| #172 | running = false; |
| #173 | if (intervalId) { |
| #174 | clearInterval(intervalId); |
| #175 | intervalId = null; |
| #176 | } |
| #177 | console.log("[HEARTBEAT] Daemon stopped."); |
| #178 | }; |
| #179 | |
| #180 | const isRunning = (): boolean => running; |
| #181 | |
| #182 | const forceRun = async (taskName: string): Promise<void> => { |
| #183 | const entries = db.getHeartbeatEntries(); |
| #184 | const entry = entries.find((e) => e.name === taskName); |
| #185 | if (entry) { |
| #186 | await executeTask(entry); |
| #187 | } |
| #188 | }; |
| #189 | |
| #190 | return { start, stop, isRunning, forceRun }; |
| #191 | } |
| #192 |