/** * KHAELOR * File: src/daemon/daemon.ts * Description: KhaelorDaemon — scheduler tick, heartbeat, goal dispatch, budget enforcement, control socket (v2 design §7.3). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; import type { Server, Socket } from "node:net"; import { join } from "node:path"; import { ulid } from "../shared/index.js"; import type { ApprovalQueue } from "./approvals.js"; import type { BudgetGuard } from "./budget.js"; import type { ChannelRouter } from "./channels.js"; import type { DaemonConfig } from "./config.js"; import { nextCronRun, parseActiveHours, parseCron, withinActiveHours } from "./cron.js"; import type { Goal, GoalStore } from "./goals.js"; /** What the injected goal runner reports back — real numbers only. */ export interface GoalRunResult { outcome: "done" | "failed"; detail: string; costUsd: number; sessionId: string | null; verifyOk: boolean | null; branch: string | null; } export type GoalRunner = (goal: Goal, runId: string) => Promise; /** Exec seam for goal `check` commands. */ export type CheckExec = (cmd: string) => Promise<{ exitCode: number | null }>; export interface DaemonDeps { daemonDir: string; config: DaemonConfig; goals: GoalStore; budget: BudgetGuard; approvals: ApprovalQueue; channels: ChannelRouter; runGoal: GoalRunner; execCheck: CheckExec; log: (level: "info" | "warn" | "error", message: string) => void; /** Tick interval override (tests). Default 30 s. */ tickMs?: number; } interface GoalClock { lastConsideredAt: number; nextCronAt: number | null; } /** * The daemon core (v2 §7.3): a tick loop over the goal set. Every decision — * skip, run, escalate, budget stop — lands in the goal's event log, so 3 AM * behavior is always explainable (autonomy WITH auditability). */ export class KhaelorDaemon { readonly #deps: DaemonDeps; readonly #clocks = new Map(); #timer: NodeJS.Timeout | null = null; #server: Server | null = null; #running = false; #startedAt = 0; #activeRuns = 0; constructor(deps: DaemonDeps) { this.#deps = deps; } get statusFilePath(): string { return join(this.#deps.daemonDir, "daemon.json"); } get socketPath(): string { return join(this.#deps.daemonDir, "daemon.sock"); } async start(): Promise { if (this.#running) return; this.#running = true; this.#startedAt = Date.now(); await mkdir(this.#deps.daemonDir, { recursive: true }); await this.#deps.budget.load(); await writeFile( this.statusFilePath, `${JSON.stringify({ pid: process.pid, startedAt: this.#startedAt }, null, 2)}\n`, "utf8", ); await this.#startControlSocket(); const tickMs = this.#deps.tickMs ?? 30_000; this.#timer = setInterval(() => { void this.tick().catch((error: unknown) => { this.#deps.log("error", `tick failed: ${error instanceof Error ? error.message : String(error)}`); }); }, tickMs); this.#deps.log("info", `khaelord started (pid ${process.pid}, tick ${tickMs}ms)`); await this.tick(); } async stop(): Promise { if (!this.#running) return; this.#running = false; if (this.#timer !== null) clearInterval(this.#timer); this.#timer = null; if (this.#server !== null) { await new Promise((resolve) => this.#server?.close(() => resolve())); this.#server = null; } await unlink(this.statusFilePath).catch(() => undefined); await unlink(this.socketPath).catch(() => undefined); this.#deps.log("info", "khaelord stopped"); } /** One scheduler pass — public for tests and for a manual `khaelord tick`. */ async tick(now = new Date()): Promise { const hours = parseActiveHours(this.#deps.config.activeHours); if (!withinActiveHours(hours, now)) return; const goals = await this.#deps.goals.list(); for (const goal of goals) { if (goal.status !== "active") continue; if (!this.#isDue(goal, now)) continue; await this.#consider(goal, now); } } #isDue(goal: Goal, now: Date): boolean { const clock = this.#clocks.get(goal.id) ?? { lastConsideredAt: 0, nextCronAt: null }; if (goal.schedule === "heartbeat") { const interval = this.#deps.config.heartbeatMinutes * 60_000; if (now.getTime() - clock.lastConsideredAt < interval) return false; clock.lastConsideredAt = now.getTime(); this.#clocks.set(goal.id, clock); return true; } const spec = parseCron(goal.schedule); if (spec === null) return false; if (clock.nextCronAt === null) { clock.nextCronAt = nextCronRun(spec, new Date(clock.lastConsideredAt || now.getTime()))?.getTime() ?? null; this.#clocks.set(goal.id, clock); return false; // first sighting schedules, never fires immediately } if (now.getTime() >= clock.nextCronAt) { clock.lastConsideredAt = now.getTime(); clock.nextCronAt = nextCronRun(spec, now)?.getTime() ?? null; this.#clocks.set(goal.id, clock); return true; } return false; } async #consider(goal: Goal, now: Date): Promise { // Bicouche routing, cheap tier first (v2 §7.6): the goal's own check // command decides whether there is anything to do at all. if (goal.check !== undefined && goal.check.length > 0) { try { const result = await this.#deps.execCheck(goal.check); if (result.exitCode === 0) { await this.#deps.goals.appendEvent(goal.id, { ts: now.getTime(), type: "run.skipped", payload: { reason: "check passed (HEARTBEAT_OK)" }, }); return; } } catch (error) { this.#deps.log("warn", `goal ${goal.id} check errored: ${String(error)}`); } } const runsToday = await this.#deps.goals.runsToday(goal.id, now); if (runsToday >= goal.budget.maxRunsPerDay) { await this.#deps.goals.appendEvent(goal.id, { ts: now.getTime(), type: "run.skipped", payload: { reason: `maxRunsPerDay reached (${goal.budget.maxRunsPerDay})` }, }); return; } const allowed = this.#deps.budget.canStart(goal.id, goal.budget.maxUsdPerDay, now); if (!allowed.ok) { await this.#deps.goals.appendEvent(goal.id, { ts: now.getTime(), type: "run.skipped", payload: { reason: allowed.reason ?? "budget" }, }); await this.#deps.channels.send({ kind: "budget-exhausted", goalId: goal.id, title: `Budget stop: ${goal.description.slice(0, 60)}`, body: allowed.reason ?? "budget exhausted", ts: now.getTime(), }); return; } const runId = ulid().slice(0, 12).toLowerCase(); await this.#deps.goals.appendEvent(goal.id, { ts: now.getTime(), type: "run.started", payload: { runId }, }); this.#activeRuns += 1; let result: GoalRunResult; try { result = await this.#deps.runGoal(goal, runId); } catch (error) { result = { outcome: "failed", detail: error instanceof Error ? error.message : String(error), costUsd: 0, sessionId: null, verifyOk: null, branch: null, }; } finally { this.#activeRuns -= 1; } await this.#deps.budget.record(goal.id, result.costUsd); await this.#deps.goals.appendEvent(goal.id, { ts: Date.now(), type: "run.completed", payload: { runId, outcome: result.outcome, detail: result.detail.slice(0, 1000), costUsd: result.costUsd, sessionId: result.sessionId, verifyOk: result.verifyOk, branch: result.branch, }, }); await this.#deps.channels.send({ kind: "run-completed", goalId: goal.id, runId, title: `${result.outcome === "done" ? "✓" : "✗"} ${goal.description.slice(0, 60)}`, body: `outcome: ${result.outcome} · verify: ${result.verifyOk === null ? "n/a" : result.verifyOk ? "ok" : "FAILED"}` + `${result.branch !== null ? ` · branch: ${result.branch}` : ""}\n${result.detail.slice(0, 500)}`, ts: Date.now(), }); } // ── control socket: status | goals | approvals | approve/deny | stop ── async #startControlSocket(): Promise { await unlink(this.socketPath).catch(() => undefined); this.#server = createServer((socket: Socket) => { let buffer = ""; socket.on("data", (chunk) => { buffer += chunk.toString("utf8"); const newline = buffer.indexOf("\n"); if (newline === -1) return; const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); void this.#handleControl(line) .then((response) => { socket.end(`${JSON.stringify(response)}\n`); }) .catch((error: unknown) => { socket.end(`${JSON.stringify({ ok: false, error: String(error) })}\n`); }); }); }); await new Promise((resolve, reject) => { this.#server?.once("error", reject); this.#server?.listen(this.socketPath, () => resolve()); }); } async #handleControl(line: string): Promise> { let request: Record; try { request = JSON.parse(line) as Record; } catch { return { ok: false, error: "invalid JSON" }; } switch (request["cmd"]) { case "status": return { ok: true, pid: process.pid, startedAt: this.#startedAt, activeRuns: this.#activeRuns, spentTodayUsd: this.#deps.budget.spentToday(), budget: this.#deps.budget.config, }; case "goals": return { ok: true, goals: await this.#deps.goals.list() }; case "approvals": return { ok: true, approvals: await this.#deps.approvals.list("pending") }; case "approve": case "deny": { const id = request["id"]; if (typeof id !== "string") return { ok: false, error: "missing id" }; const resolved = await this.#deps.approvals.resolve( id, request["cmd"] === "approve" ? "approved" : "denied", ); return resolved !== null ? { ok: true, approval: resolved } : { ok: false, error: "unknown or already resolved" }; } case "stop": setTimeout(() => { void this.stop().then(() => process.exit(0)); }, 50); return { ok: true, stopping: true }; default: return { ok: false, error: `unknown cmd: ${String(request["cmd"])}` }; } } } /** Read the daemon status file (pid liveness checked by the caller). */ export async function readDaemonStatus( daemonDir: string, ): Promise<{ pid: number; startedAt: number } | null> { try { const raw = JSON.parse(await readFile(join(daemonDir, "daemon.json"), "utf8")) as Record< string, unknown >; if (typeof raw["pid"] === "number" && typeof raw["startedAt"] === "number") { return { pid: raw["pid"], startedAt: raw["startedAt"] }; } } catch { // no status file } return null; }