/** * KHAELOR * File: src/tasks/manager.ts * Description: SubtaskManager — spawn isolated child runs in worktrees, supervise, record subtask events (v2 design §6). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { DurableEventInput } from "../session/index.js"; import { ulid } from "../shared/index.js"; import { createWorktree, worktreeDiffStats } from "./worktree.js"; import type { ExecFn, WorktreeInfo } from "./worktree.js"; export type SubtaskStatus = "running" | "done" | "failed" | "interrupted"; export interface SubtaskRecord { taskId: string; description: string; childSessionId: string; worktree: WorktreeInfo; status: SubtaskStatus; startedAt: number; finishedAt: number | null; diff: { added: number; removed: number; files: string[] }; verifyOk: boolean | null; detail: string; } /** Outcome the injected child runner reports back. */ export interface ChildRunOutcome { status: Exclude; verifyOk: boolean | null; detail: string; } /** * The child runner seam: the CLI layer assembles a full child engine over the * worktree cwd — child session (own JSONL, meta.parent → orchestrator), * attenuated permissions (non-interactive: asks resolve deny), same kernel. */ export type ChildRunner = (args: { taskId: string; childSessionId: string; worktreePath: string; description: string; }) => Promise; export interface SubtaskManagerOptions { /** Orchestrator session sink for subtask.* durable events. */ publish: (event: DurableEventInput) => void; exec: ExecFn; projectRoot: string; runChild: ChildRunner; newId?: () => string; } /** * Orchestrates parallel subtasks (v2 §6): each gets a git worktree + branch, * a child session, and attenuated capabilities. Completion publishes a * durable `subtask.completed` with real diff stats — never fabricated. */ export class SubtaskManager { readonly #publish: (event: DurableEventInput) => void; readonly #exec: ExecFn; readonly #projectRoot: string; readonly #runChild: ChildRunner; readonly #newId: () => string; readonly #tasks = new Map(); readonly #running = new Map>(); constructor(options: SubtaskManagerOptions) { this.#publish = options.publish; this.#exec = options.exec; this.#projectRoot = options.projectRoot; this.#runChild = options.runChild; this.#newId = options.newId ?? ulid; } list(): SubtaskRecord[] { return [...this.#tasks.values()].sort((a, b) => a.startedAt - b.startedAt); } get(taskId: string): SubtaskRecord | undefined { return this.#tasks.get(taskId); } /** Create the worktree, record subtask.created, and launch the child run. */ async spawn(description: string): Promise { const taskId = this.#newId().slice(0, 10).toLowerCase(); const childSessionId = this.#newId(); const worktree = await createWorktree(this.#exec, this.#projectRoot, taskId); const record: SubtaskRecord = { taskId, description, childSessionId, worktree, status: "running", startedAt: Date.now(), finishedAt: null, diff: { added: 0, removed: 0, files: [] }, verifyOk: null, detail: "", }; this.#tasks.set(taskId, record); this.#publish({ type: "subtask.created", payload: { taskId, description, childSessionId, worktreePath: worktree.path, branch: worktree.branch, }, }); const run = this.#supervise(record); this.#running.set(taskId, run); return record; } /** Await every running subtask (used by --parallel batch mode and shutdown). */ async waitAll(): Promise { await Promise.all([...this.#running.values()]); } async #supervise(record: SubtaskRecord): Promise { let outcome: ChildRunOutcome; try { outcome = await this.#runChild({ taskId: record.taskId, childSessionId: record.childSessionId, worktreePath: record.worktree.path, description: record.description, }); } catch (error) { outcome = { status: "failed", verifyOk: null, detail: error instanceof Error ? error.message : String(error), }; } // Commit the worktree changes onto the subtask branch so diff/merge see them. await this.#exec("git add -A", record.worktree.path); await this.#exec( `git commit -m ${JSON.stringify(`khaelor subtask ${record.taskId}: ${record.description.slice(0, 60)}`)} --no-verify`, record.worktree.path, ); const diff = await worktreeDiffStats(this.#exec, this.#projectRoot, record.worktree.branch); record.status = outcome.status; record.finishedAt = Date.now(); record.diff = diff; record.verifyOk = outcome.verifyOk; record.detail = outcome.detail; this.#running.delete(record.taskId); this.#publish({ type: "subtask.completed", payload: { taskId: record.taskId, outcome: outcome.status, diffStats: { added: diff.added, removed: diff.removed }, verifyOk: outcome.verifyOk, detail: outcome.detail.slice(0, 2000), }, }); } }