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%
5.1 KB · 170 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/tasks/manager.ts4 * Description: SubtaskManager — spawn isolated child runs in worktrees, supervise, record subtask events (v2 design §6).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import type { DurableEventInput } from "../session/index.js";11import { ulid } from "../shared/index.js";12import { createWorktree, worktreeDiffStats } from "./worktree.js";13import type { ExecFn, WorktreeInfo } from "./worktree.js";1415export type SubtaskStatus = "running" | "done" | "failed" | "interrupted";1617export interface SubtaskRecord {18  taskId: string;19  description: string;20  childSessionId: string;21  worktree: WorktreeInfo;22  status: SubtaskStatus;23  startedAt: number;24  finishedAt: number | null;25  diff: { added: number; removed: number; files: string[] };26  verifyOk: boolean | null;27  detail: string;28}2930/** Outcome the injected child runner reports back. */31export interface ChildRunOutcome {32  status: Exclude<SubtaskStatus, "running">;33  verifyOk: boolean | null;34  detail: string;35}3637/**38 * The child runner seam: the CLI layer assembles a full child engine over the39 * worktree cwd — child session (own JSONL, meta.parent → orchestrator),40 * attenuated permissions (non-interactive: asks resolve deny), same kernel.41 */42export type ChildRunner = (args: {43  taskId: string;44  childSessionId: string;45  worktreePath: string;46  description: string;47}) => Promise<ChildRunOutcome>;4849export interface SubtaskManagerOptions {50  /** Orchestrator session sink for subtask.* durable events. */51  publish: (event: DurableEventInput) => void;52  exec: ExecFn;53  projectRoot: string;54  runChild: ChildRunner;55  newId?: () => string;56}5758/**59 * Orchestrates parallel subtasks (v2 §6): each gets a git worktree + branch,60 * a child session, and attenuated capabilities. Completion publishes a61 * durable `subtask.completed` with real diff stats — never fabricated.62 */63export class SubtaskManager {64  readonly #publish: (event: DurableEventInput) => void;65  readonly #exec: ExecFn;66  readonly #projectRoot: string;67  readonly #runChild: ChildRunner;68  readonly #newId: () => string;69  readonly #tasks = new Map<string, SubtaskRecord>();70  readonly #running = new Map<string, Promise<void>>();7172  constructor(options: SubtaskManagerOptions) {73    this.#publish = options.publish;74    this.#exec = options.exec;75    this.#projectRoot = options.projectRoot;76    this.#runChild = options.runChild;77    this.#newId = options.newId ?? ulid;78  }7980  list(): SubtaskRecord[] {81    return [...this.#tasks.values()].sort((a, b) => a.startedAt - b.startedAt);82  }8384  get(taskId: string): SubtaskRecord | undefined {85    return this.#tasks.get(taskId);86  }8788  /** Create the worktree, record subtask.created, and launch the child run. */89  async spawn(description: string): Promise<SubtaskRecord> {90    const taskId = this.#newId().slice(0, 10).toLowerCase();91    const childSessionId = this.#newId();92    const worktree = await createWorktree(this.#exec, this.#projectRoot, taskId);93    const record: SubtaskRecord = {94      taskId,95      description,96      childSessionId,97      worktree,98      status: "running",99      startedAt: Date.now(),100      finishedAt: null,101      diff: { added: 0, removed: 0, files: [] },102      verifyOk: null,103      detail: "",104    };105    this.#tasks.set(taskId, record);106    this.#publish({107      type: "subtask.created",108      payload: {109        taskId,110        description,111        childSessionId,112        worktreePath: worktree.path,113        branch: worktree.branch,114      },115    });116117    const run = this.#supervise(record);118    this.#running.set(taskId, run);119    return record;120  }121122  /** Await every running subtask (used by --parallel batch mode and shutdown). */123  async waitAll(): Promise<void> {124    await Promise.all([...this.#running.values()]);125  }126127  async #supervise(record: SubtaskRecord): Promise<void> {128    let outcome: ChildRunOutcome;129    try {130      outcome = await this.#runChild({131        taskId: record.taskId,132        childSessionId: record.childSessionId,133        worktreePath: record.worktree.path,134        description: record.description,135      });136    } catch (error) {137      outcome = {138        status: "failed",139        verifyOk: null,140        detail: error instanceof Error ? error.message : String(error),141      };142    }143    // Commit the worktree changes onto the subtask branch so diff/merge see them.144    await this.#exec("git add -A", record.worktree.path);145    await this.#exec(146      `git commit -m ${JSON.stringify(`khaelor subtask ${record.taskId}: ${record.description.slice(0, 60)}`)} --no-verify`,147      record.worktree.path,148    );149    const diff = await worktreeDiffStats(this.#exec, this.#projectRoot, record.worktree.branch);150151    record.status = outcome.status;152    record.finishedAt = Date.now();153    record.diff = diff;154    record.verifyOk = outcome.verifyOk;155    record.detail = outcome.detail;156    this.#running.delete(record.taskId);157158    this.#publish({159      type: "subtask.completed",160      payload: {161        taskId: record.taskId,162        outcome: outcome.status,163        diffStats: { added: diff.added, removed: diff.removed },164        verifyOk: outcome.verifyOk,165        detail: outcome.detail.slice(0, 2000),166      },167    });168  }169}170