SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
11.1 KB · 334 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/daemon/daemon.ts4 * Description: KhaelorDaemon — scheduler tick, heartbeat, goal dispatch, budget enforcement, control socket (v2 design §7.3).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";11import { createServer } from "node:net";12import type { Server, Socket } from "node:net";13import { join } from "node:path";14import { ulid } from "../shared/index.js";15import type { ApprovalQueue } from "./approvals.js";16import type { BudgetGuard } from "./budget.js";17import type { ChannelRouter } from "./channels.js";18import type { DaemonConfig } from "./config.js";19import { nextCronRun, parseActiveHours, parseCron, withinActiveHours } from "./cron.js";20import type { Goal, GoalStore } from "./goals.js";2122/** What the injected goal runner reports back — real numbers only. */23export interface GoalRunResult {24  outcome: "done" | "failed";25  detail: string;26  costUsd: number;27  sessionId: string | null;28  verifyOk: boolean | null;29  branch: string | null;30}3132export type GoalRunner = (goal: Goal, runId: string) => Promise<GoalRunResult>;3334/** Exec seam for goal `check` commands. */35export type CheckExec = (cmd: string) => Promise<{ exitCode: number | null }>;3637export interface DaemonDeps {38  daemonDir: string;39  config: DaemonConfig;40  goals: GoalStore;41  budget: BudgetGuard;42  approvals: ApprovalQueue;43  channels: ChannelRouter;44  runGoal: GoalRunner;45  execCheck: CheckExec;46  log: (level: "info" | "warn" | "error", message: string) => void;47  /** Tick interval override (tests). Default 30 s. */48  tickMs?: number;49}5051interface GoalClock {52  lastConsideredAt: number;53  nextCronAt: number | null;54}5556/**57 * The daemon core (v2 §7.3): a tick loop over the goal set. Every decision —58 * skip, run, escalate, budget stop — lands in the goal's event log, so 3 AM59 * behavior is always explainable (autonomy WITH auditability).60 */61export class KhaelorDaemon {62  readonly #deps: DaemonDeps;63  readonly #clocks = new Map<string, GoalClock>();64  #timer: NodeJS.Timeout | null = null;65  #server: Server | null = null;66  #running = false;67  #startedAt = 0;68  #activeRuns = 0;6970  constructor(deps: DaemonDeps) {71    this.#deps = deps;72  }7374  get statusFilePath(): string {75    return join(this.#deps.daemonDir, "daemon.json");76  }7778  get socketPath(): string {79    return join(this.#deps.daemonDir, "daemon.sock");80  }8182  async start(): Promise<void> {83    if (this.#running) return;84    this.#running = true;85    this.#startedAt = Date.now();86    await mkdir(this.#deps.daemonDir, { recursive: true });87    await this.#deps.budget.load();88    await writeFile(89      this.statusFilePath,90      `${JSON.stringify({ pid: process.pid, startedAt: this.#startedAt }, null, 2)}\n`,91      "utf8",92    );93    await this.#startControlSocket();94    const tickMs = this.#deps.tickMs ?? 30_000;95    this.#timer = setInterval(() => {96      void this.tick().catch((error: unknown) => {97        this.#deps.log("error", `tick failed: ${error instanceof Error ? error.message : String(error)}`);98      });99    }, tickMs);100    this.#deps.log("info", `khaelord started (pid ${process.pid}, tick ${tickMs}ms)`);101    await this.tick();102  }103104  async stop(): Promise<void> {105    if (!this.#running) return;106    this.#running = false;107    if (this.#timer !== null) clearInterval(this.#timer);108    this.#timer = null;109    if (this.#server !== null) {110      await new Promise<void>((resolve) => this.#server?.close(() => resolve()));111      this.#server = null;112    }113    await unlink(this.statusFilePath).catch(() => undefined);114    await unlink(this.socketPath).catch(() => undefined);115    this.#deps.log("info", "khaelord stopped");116  }117118  /** One scheduler pass — public for tests and for a manual `khaelord tick`. */119  async tick(now = new Date()): Promise<void> {120    const hours = parseActiveHours(this.#deps.config.activeHours);121    if (!withinActiveHours(hours, now)) return;122    const goals = await this.#deps.goals.list();123    for (const goal of goals) {124      if (goal.status !== "active") continue;125      if (!this.#isDue(goal, now)) continue;126      await this.#consider(goal, now);127    }128  }129130  #isDue(goal: Goal, now: Date): boolean {131    const clock = this.#clocks.get(goal.id) ?? { lastConsideredAt: 0, nextCronAt: null };132    if (goal.schedule === "heartbeat") {133      const interval = this.#deps.config.heartbeatMinutes * 60_000;134      if (now.getTime() - clock.lastConsideredAt < interval) return false;135      clock.lastConsideredAt = now.getTime();136      this.#clocks.set(goal.id, clock);137      return true;138    }139    const spec = parseCron(goal.schedule);140    if (spec === null) return false;141    if (clock.nextCronAt === null) {142      clock.nextCronAt = nextCronRun(spec, new Date(clock.lastConsideredAt || now.getTime()))?.getTime() ?? null;143      this.#clocks.set(goal.id, clock);144      return false; // first sighting schedules, never fires immediately145    }146    if (now.getTime() >= clock.nextCronAt) {147      clock.lastConsideredAt = now.getTime();148      clock.nextCronAt = nextCronRun(spec, now)?.getTime() ?? null;149      this.#clocks.set(goal.id, clock);150      return true;151    }152    return false;153  }154155  async #consider(goal: Goal, now: Date): Promise<void> {156    // Bicouche routing, cheap tier first (v2 §7.6): the goal's own check157    // command decides whether there is anything to do at all.158    if (goal.check !== undefined && goal.check.length > 0) {159      try {160        const result = await this.#deps.execCheck(goal.check);161        if (result.exitCode === 0) {162          await this.#deps.goals.appendEvent(goal.id, {163            ts: now.getTime(),164            type: "run.skipped",165            payload: { reason: "check passed (HEARTBEAT_OK)" },166          });167          return;168        }169      } catch (error) {170        this.#deps.log("warn", `goal ${goal.id} check errored: ${String(error)}`);171      }172    }173174    const runsToday = await this.#deps.goals.runsToday(goal.id, now);175    if (runsToday >= goal.budget.maxRunsPerDay) {176      await this.#deps.goals.appendEvent(goal.id, {177        ts: now.getTime(),178        type: "run.skipped",179        payload: { reason: `maxRunsPerDay reached (${goal.budget.maxRunsPerDay})` },180      });181      return;182    }183    const allowed = this.#deps.budget.canStart(goal.id, goal.budget.maxUsdPerDay, now);184    if (!allowed.ok) {185      await this.#deps.goals.appendEvent(goal.id, {186        ts: now.getTime(),187        type: "run.skipped",188        payload: { reason: allowed.reason ?? "budget" },189      });190      await this.#deps.channels.send({191        kind: "budget-exhausted",192        goalId: goal.id,193        title: `Budget stop: ${goal.description.slice(0, 60)}`,194        body: allowed.reason ?? "budget exhausted",195        ts: now.getTime(),196      });197      return;198    }199200    const runId = ulid().slice(0, 12).toLowerCase();201    await this.#deps.goals.appendEvent(goal.id, {202      ts: now.getTime(),203      type: "run.started",204      payload: { runId },205    });206    this.#activeRuns += 1;207    let result: GoalRunResult;208    try {209      result = await this.#deps.runGoal(goal, runId);210    } catch (error) {211      result = {212        outcome: "failed",213        detail: error instanceof Error ? error.message : String(error),214        costUsd: 0,215        sessionId: null,216        verifyOk: null,217        branch: null,218      };219    } finally {220      this.#activeRuns -= 1;221    }222    await this.#deps.budget.record(goal.id, result.costUsd);223    await this.#deps.goals.appendEvent(goal.id, {224      ts: Date.now(),225      type: "run.completed",226      payload: {227        runId,228        outcome: result.outcome,229        detail: result.detail.slice(0, 1000),230        costUsd: result.costUsd,231        sessionId: result.sessionId,232        verifyOk: result.verifyOk,233        branch: result.branch,234      },235    });236    await this.#deps.channels.send({237      kind: "run-completed",238      goalId: goal.id,239      runId,240      title: `${result.outcome === "done" ? "✓" : "✗"} ${goal.description.slice(0, 60)}`,241      body:242        `outcome: ${result.outcome} · verify: ${result.verifyOk === null ? "n/a" : result.verifyOk ? "ok" : "FAILED"}` +243        `${result.branch !== null ? ` · branch: ${result.branch}` : ""}\n${result.detail.slice(0, 500)}`,244      ts: Date.now(),245    });246  }247248  // ── control socket: status | goals | approvals | approve/deny | stop ──249250  async #startControlSocket(): Promise<void> {251    await unlink(this.socketPath).catch(() => undefined);252    this.#server = createServer((socket: Socket) => {253      let buffer = "";254      socket.on("data", (chunk) => {255        buffer += chunk.toString("utf8");256        const newline = buffer.indexOf("\n");257        if (newline === -1) return;258        const line = buffer.slice(0, newline);259        buffer = buffer.slice(newline + 1);260        void this.#handleControl(line)261          .then((response) => {262            socket.end(`${JSON.stringify(response)}\n`);263          })264          .catch((error: unknown) => {265            socket.end(`${JSON.stringify({ ok: false, error: String(error) })}\n`);266          });267      });268    });269    await new Promise<void>((resolve, reject) => {270      this.#server?.once("error", reject);271      this.#server?.listen(this.socketPath, () => resolve());272    });273  }274275  async #handleControl(line: string): Promise<Record<string, unknown>> {276    let request: Record<string, unknown>;277    try {278      request = JSON.parse(line) as Record<string, unknown>;279    } catch {280      return { ok: false, error: "invalid JSON" };281    }282    switch (request["cmd"]) {283      case "status":284        return {285          ok: true,286          pid: process.pid,287          startedAt: this.#startedAt,288          activeRuns: this.#activeRuns,289          spentTodayUsd: this.#deps.budget.spentToday(),290          budget: this.#deps.budget.config,291        };292      case "goals":293        return { ok: true, goals: await this.#deps.goals.list() };294      case "approvals":295        return { ok: true, approvals: await this.#deps.approvals.list("pending") };296      case "approve":297      case "deny": {298        const id = request["id"];299        if (typeof id !== "string") return { ok: false, error: "missing id" };300        const resolved = await this.#deps.approvals.resolve(301          id,302          request["cmd"] === "approve" ? "approved" : "denied",303        );304        return resolved !== null ? { ok: true, approval: resolved } : { ok: false, error: "unknown or already resolved" };305      }306      case "stop":307        setTimeout(() => {308          void this.stop().then(() => process.exit(0));309        }, 50);310        return { ok: true, stopping: true };311      default:312        return { ok: false, error: `unknown cmd: ${String(request["cmd"])}` };313    }314  }315}316317/** Read the daemon status file (pid liveness checked by the caller). */318export async function readDaemonStatus(319  daemonDir: string,320): Promise<{ pid: number; startedAt: number } | null> {321  try {322    const raw = JSON.parse(await readFile(join(daemonDir, "daemon.json"), "utf8")) as Record<323      string,324      unknown325    >;326    if (typeof raw["pid"] === "number" && typeof raw["startedAt"] === "number") {327      return { pid: raw["pid"], startedAt: raw["startedAt"] };328    }329  } catch {330    // no status file331  }332  return null;333}334