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%
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