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%
13.0 KB · 429 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/workspace/processes.ts4 * Description: ProcessManager — background processes with ring buffers, spill logs, and group kill (TOOL_PROTOCOL §8).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { spawn } from "node:child_process";11import type { ChildProcess } from "node:child_process";12import { appendFileSync, mkdirSync, readFileSync } from "node:fs";13import * as path from "node:path";14import { WorkspaceError } from "./workspace.js";1516export type ManagedProcessStatus = "running" | "exited" | "stopped" | "failed";1718/** A background process managed for the session (TOOL_PROTOCOL §8.2). */19export interface ManagedProcess {20  /** "p1", "p2" … session-scoped handle — NOT the OS pid. */21  id: string;22  /** OS pid, with start time recorded (PID-reuse guard, Hermes §6.2). */23  pid: number;24  command: string;25  status: ManagedProcessStatus;26  exitCode: number | null;27  startedAt: number;28  cwd: string;29  /** Full output spill — never truncated. */30  logPath: string;31}3233/** One page of process output. */34export interface ProcessRead {35  id: string;36  lines: string[];37  /** 1-based absolute line number of lines[0]; 0 when lines is empty. */38  startLine: number;39  totalLines: number;40  /** True when the page cap cut this read short — continue with offset, or read logPath. */41  truncated: boolean;42  status: ManagedProcessStatus;43  exitCode: number | null;44  logPath: string;45}4647/** An already-spawned child handed over by the `bash` timeout redirect (TOOL_PROTOCOL §7.2). */48export interface SpawnedChild {49  child: ChildProcess;50  command: string;51  cwd: string;52  startedAt: number;53  /** Output captured before adoption — replayed into the ring + log. */54  outputSoFar?: string;55}5657/**58 * Injected lifecycle listener. The kernel wires this to the event bus59 * (ProcessStarted / ProcessExited durable events) — the workspace never60 * imports `session`; the dependency is inverted here.61 */62export interface ProcessEventSink {63  onProcessStarted?(process: ManagedProcess): void;64  onProcessExited?(process: ManagedProcess): void;65}6667/** Model-facing background process manager (TOOL_PROTOCOL §8.2). */68export interface ProcessManager {69  start(command: string, cwd: string): Promise<ManagedProcess>;70  list(): ManagedProcess[];71  read(id: string, opts?: { offset?: number }): ProcessRead;72  write(id: string, input: string): Promise<void>;73  /** SIGTERM → grace → SIGKILL, whole process group. */74  stop(id: string): Promise<{ exitCode: number | null }>;75  /** bash timeout redirect (§7.2). */76  adopt(spawned: SpawnedChild): ManagedProcess;77  /** Session end only — NOT on interrupt (ADR-11). */78  stopAll(): Promise<void>;79}8081/** Per-process in-memory ring: 10,000 lines / 2 MB (overflow loses nothing — the log has it). */82const DEFAULT_MAX_RING_LINES = 10_000;83const DEFAULT_MAX_RING_BYTES = 2 * 1024 * 1024;84/** Read page cap: 300 lines / 20 KB (TOOL_PROTOCOL §8.2). */85const DEFAULT_READ_PAGE_LINES = 300;86const DEFAULT_READ_PAGE_BYTES = 20 * 1024;87/** SIGTERM → grace → SIGKILL. */88const DEFAULT_GRACE_MS = 3000;8990export interface LocalProcessManagerOptions {91  /** Directory for spill logs, e.g. ~/.khaelor/process-logs/<session>/. */92  logDir: string;93  eventSink?: ProcessEventSink;94  /** Shell used for `start`. Default: $SHELL, falling back to /bin/sh. */95  shell?: string;96  graceMs?: number;97  maxRingLines?: number;98  maxRingBytes?: number;99  readPageLines?: number;100  readPageBytes?: number;101}102103interface ProcRecord {104  info: ManagedProcess;105  child: ChildProcess;106  /** Complete output lines currently held in memory. */107  ring: string[];108  ringBytes: number;109  /** Total complete lines ever produced (ring + evicted-to-log). */110  totalLines: number;111  /** Trailing output not yet terminated by a newline. */112  partial: string;113  /** Absolute line number last returned by a cursor read (0 initially). */114  cursor: number;115  exited: boolean;116  failed: boolean;117  stopRequested: boolean;118  exitWaiters: Array<() => void>;119}120121export class LocalProcessManager implements ProcessManager {122  private readonly logDir: string;123  private readonly sink: ProcessEventSink | undefined;124  private readonly shell: string;125  private readonly graceMs: number;126  private readonly maxRingLines: number;127  private readonly maxRingBytes: number;128  private readonly readPageLines: number;129  private readonly readPageBytes: number;130  private readonly procs = new Map<string, ProcRecord>();131  private counter = 0;132133  constructor(options: LocalProcessManagerOptions) {134    this.logDir = path.resolve(options.logDir);135    this.sink = options.eventSink;136    this.shell = options.shell ?? process.env["SHELL"] ?? "/bin/sh";137    this.graceMs = options.graceMs ?? DEFAULT_GRACE_MS;138    this.maxRingLines = options.maxRingLines ?? DEFAULT_MAX_RING_LINES;139    this.maxRingBytes = options.maxRingBytes ?? DEFAULT_MAX_RING_BYTES;140    this.readPageLines = options.readPageLines ?? DEFAULT_READ_PAGE_LINES;141    this.readPageBytes = options.readPageBytes ?? DEFAULT_READ_PAGE_BYTES;142    mkdirSync(this.logDir, { recursive: true });143  }144145  async start(command: string, cwd: string): Promise<ManagedProcess> {146    const child = spawn(this.shell, ["-c", command], {147      cwd,148      env: process.env,149      detached: true,150      stdio: ["pipe", "pipe", "pipe"],151    });152    return this.register(child, command, cwd, Date.now(), undefined);153  }154155  adopt(spawned: SpawnedChild): ManagedProcess {156    return this.register(157      spawned.child,158      spawned.command,159      spawned.cwd,160      spawned.startedAt,161      spawned.outputSoFar,162    );163  }164165  list(): ManagedProcess[] {166    return [...this.procs.values()].map((rec) => ({ ...rec.info }));167  }168169  read(id: string, opts?: { offset?: number }): ProcessRead {170    const rec = this.require(id);171    const cursorRead = opts?.offset === undefined;172    const start = opts?.offset ?? rec.cursor + 1;173    const total = rec.totalLines;174175    if (start < 1 || start > total) {176      return {177        id,178        lines: [],179        startLine: 0,180        totalLines: total,181        truncated: false,182        status: rec.info.status,183        exitCode: rec.info.exitCode,184        logPath: rec.info.logPath,185      };186    }187188    const ringFirst = total - rec.ring.length + 1;189    let lines: string[];190    if (start >= ringFirst) {191      lines = rec.ring.slice(start - ringFirst);192    } else {193      // Evicted from the ring — nothing is lost; re-read from the spill log.194      const raw = readFileSync(rec.info.logPath, "utf8");195      const all = raw.split("\n");196      if (all.length > 0 && all[all.length - 1] === "") all.pop();197      lines = all.slice(start - 1, total);198    }199200    // Page cap: N lines / M bytes with continuation via offset or logPath.201    let truncated = false;202    if (lines.length > this.readPageLines) {203      lines = lines.slice(0, this.readPageLines);204      truncated = true;205    }206    let bytes = 0;207    for (let i = 0; i < lines.length; i++) {208      bytes += (lines[i] as string).length + 1;209      if (bytes > this.readPageBytes && i > 0) {210        lines = lines.slice(0, i);211        truncated = true;212        break;213      }214    }215216    if (cursorRead) {217      rec.cursor = start - 1 + lines.length;218    }219220    return {221      id,222      lines,223      startLine: lines.length > 0 ? start : 0,224      totalLines: total,225      truncated,226      status: rec.info.status,227      exitCode: rec.info.exitCode,228      logPath: rec.info.logPath,229    };230  }231232  async write(id: string, input: string): Promise<void> {233    const rec = this.require(id);234    if (rec.info.status !== "running") {235      throw new WorkspaceError(236        "process-not-running",237        `Process ${id} is not running (status ${rec.info.status}, exit code ${String(rec.info.exitCode)}). Full log: ${rec.info.logPath}`,238        { id, status: rec.info.status },239      );240    }241    const stdin = rec.child.stdin;242    if (stdin === null || !stdin.writable) {243      throw new WorkspaceError("process-not-running", `Process ${id} has no writable stdin.`, { id });244    }245    await new Promise<void>((resolvePromise, rejectPromise) => {246      stdin.write(input, (cause) => {247        if (cause) {248          rejectPromise(249            new WorkspaceError("process-not-running", `Failed to write to ${id} stdin: ${cause.message}`, {250              id,251            }),252          );253        } else {254          resolvePromise();255        }256      });257    });258  }259260  async stop(id: string): Promise<{ exitCode: number | null }> {261    const rec = this.require(id);262    if (rec.exited) {263      return { exitCode: rec.info.exitCode };264    }265    rec.stopRequested = true;266    this.signalGroup(rec, "SIGTERM");267    const graceful = await this.waitExit(rec, this.graceMs);268    if (!graceful) {269      this.signalGroup(rec, "SIGKILL");270      await this.waitExit(rec, this.graceMs);271    }272    return { exitCode: rec.info.exitCode };273  }274275  async stopAll(): Promise<void> {276    const running = [...this.procs.values()].filter((rec) => !rec.exited);277    await Promise.all(running.map((rec) => this.stop(rec.info.id)));278  }279280  private register(281    child: ChildProcess,282    command: string,283    cwd: string,284    startedAt: number,285    outputSoFar: string | undefined,286  ): ManagedProcess {287    this.counter += 1;288    const id = `p${this.counter}`;289    const logPath = path.join(this.logDir, `${id}.log`);290    const info: ManagedProcess = {291      id,292      pid: child.pid ?? -1,293      command,294      status: "running",295      exitCode: null,296      startedAt,297      cwd,298      logPath,299    };300    const rec: ProcRecord = {301      info,302      child,303      ring: [],304      ringBytes: 0,305      totalLines: 0,306      partial: "",307      cursor: 0,308      exited: false,309      failed: false,310      stopRequested: false,311      exitWaiters: [],312    };313    this.procs.set(id, rec);314315    // Create the log file immediately so it exists even before any output.316    appendFileSync(logPath, "");317    if (outputSoFar !== undefined && outputSoFar.length > 0) {318      this.ingest(rec, outputSoFar);319    }320321    const onData = (chunk: Buffer): void => {322      this.ingest(rec, chunk.toString("utf8"));323    };324    child.stdout?.on("data", onData);325    child.stderr?.on("data", onData);326327    child.on("error", () => {328      rec.failed = true;329      this.finalize(rec, null);330    });331    child.on("close", (code) => {332      this.finalize(rec, code);333    });334335    this.sink?.onProcessStarted?.({ ...info });336    return info;337  }338339  private ingest(rec: ProcRecord, text: string): void {340    // Everything goes to the spill log first — the ring may evict, the log never does.341    appendFileSync(rec.info.logPath, text);342    const combined = rec.partial + text;343    const parts = combined.split("\n");344    rec.partial = parts.pop() ?? "";345    for (const line of parts) {346      this.pushLine(rec, line);347    }348  }349350  private pushLine(rec: ProcRecord, line: string): void {351    rec.ring.push(line);352    rec.ringBytes += line.length + 1;353    rec.totalLines += 1;354    while (355      rec.ring.length > 0 &&356      (rec.ring.length > this.maxRingLines || rec.ringBytes > this.maxRingBytes)357    ) {358      const evicted = rec.ring.shift() as string;359      rec.ringBytes -= evicted.length + 1;360    }361  }362363  private finalize(rec: ProcRecord, exitCode: number | null): void {364    if (rec.exited) return;365    rec.exited = true;366    if (rec.partial.length > 0) {367      // Flush the trailing unterminated line so reads see the final output.368      this.pushLine(rec, rec.partial);369      rec.partial = "";370    }371    rec.info.exitCode = exitCode;372    rec.info.status = rec.failed ? "failed" : rec.stopRequested ? "stopped" : "exited";373    const waiters = rec.exitWaiters.splice(0);374    for (const waiter of waiters) waiter();375    this.sink?.onProcessExited?.({ ...rec.info });376  }377378  /**379   * Signal the whole process group. PID-reuse guard (Hermes §6.2): never380   * signal after we have observed the exit, and only signal the exact pid381   * recorded at spawn time.382   */383  private signalGroup(rec: ProcRecord, signal: NodeJS.Signals): void {384    if (rec.exited) return;385    const pid = rec.child.pid;386    if (pid === undefined || pid !== rec.info.pid) return;387    try {388      process.kill(-pid, signal);389    } catch {390      try {391        rec.child.kill(signal);392      } catch {393        // Already gone.394      }395    }396  }397398  private waitExit(rec: ProcRecord, ms: number): Promise<boolean> {399    if (rec.exited) return Promise.resolve(true);400    return new Promise<boolean>((resolvePromise) => {401      const timer = setTimeout(() => {402        const index = rec.exitWaiters.indexOf(waiter);403        if (index !== -1) rec.exitWaiters.splice(index, 1);404        resolvePromise(false);405      }, ms);406      const waiter = (): void => {407        clearTimeout(timer);408        resolvePromise(true);409      };410      rec.exitWaiters.push(waiter);411    });412  }413414  private require(id: string): ProcRecord {415    const rec = this.procs.get(id);416    if (rec === undefined) {417      const active = [...this.procs.values()]418        .map((r) => `${r.info.id} (${r.info.command}, ${r.info.status})`)419        .join(", ");420      throw new WorkspaceError(421        "process-unknown",422        `No process "${id}".${active.length > 0 ? ` Known: ${active}.` : " No processes have been started."}`,423        { id },424      );425    }426    return rec;427  }428}429