/** * Run manager: the single source of truth for orchestrator runs. * * Each run works one kanban card inside an (optional) isolated git worktree. * The manager persists run state to SQLite, keeps active `Runner` instances in * memory (so routes can steer/stop/stream them), and finalizes the run record * when a runner settles — capturing the head commit and a work summary. */ import { randomUUID } from 'node:crypto'; import { db, type AgentRunRow } from '../db.js'; import type { AgentRun, AgentRunEvent, Card, RunStatus } from '../types.js'; import { appendRunEvent } from './events.js'; import { newToken, REPO_ROOT } from './config.js'; import { buildPrompt } from './prompt.js'; import { Runner, type PersistedEvent } from './runner.js'; import { BevyProcess } from './bevy.js'; import { createWorktree, dirtySummary, headSha, isWorktreePresent, removeWorktree, type Worktree } from './worktrees.js'; export interface StartRunInput { cardId: string; /** Extra operator instructions appended to the agent's prompt. */ prompt?: string; /** Default true: run inside an isolated git worktree. */ useWorktree?: boolean; /** If true, delete the worktree + branch when the run settles. */ cleanupOnFinish?: boolean; /** * If set, start a refinement run: a fresh worktree branched from this prior * run's branch (so its commits/work are present), seeded with `prompt` as the * operator's refinement feedback. Only valid when the prior run is settled * and its worktree is still present. */ refineRunId?: string; } class RunManager { /** Active agent runners keyed by run id. */ private active = new Map(); /** Active Bevy test processes keyed by run id (independent of agent runs). */ private bevy = new Map(); /** Create and start a run for a card. Returns the persisted run. */ start(card: Card, input: StartRunInput): AgentRun { const id = randomUUID(); const token = newToken(); const useWorktree = input.useWorktree ?? true; const now = new Date().toISOString(); // A refinement appends to a prior run's chat: it resumes that run's pi // session in the SAME worktree (so the working tree matches the // conversation) and is itself NOT the worktree's owner. Resolve it first so // any error surfaces before we create anything. let refineOf: { worktree: Worktree; sessionFile: string } | null = null; if (input.refineRunId) { if (!useWorktree) throw new Error('a refinement run requires a worktree'); const prior = this.get(input.refineRunId); if (!prior) throw new Error(`refinement source run not found: ${input.refineRunId}`); if (prior.cardId !== card.id) throw new Error('refinement source run belongs to a different card'); if ( !prior.ownsWorktree || !prior.worktreePath || !prior.branch || !isWorktreePresent(prior.worktreePath) ) { throw new Error('refinement source run has no worktree (it was cleaned up); start a fresh run instead'); } if (!prior.sessionFile) { throw new Error('refinement source run has no recorded session to resume'); } refineOf = { worktree: { path: prior.worktreePath, branch: prior.branch }, sessionFile: prior.sessionFile, }; } // Provision the worktree. Refinement runs inherit the prior run's worktree // (and never own it); fresh runs create their own (or fall back to repo root). let worktree: Worktree | null = null; let branch: string | null = null; let worktreePath: string | null = null; if (refineOf) { worktree = refineOf.worktree; branch = refineOf.worktree.branch; worktreePath = refineOf.worktree.path; } else if (useWorktree) { worktree = createWorktree(id, card.id); branch = worktree.branch; worktreePath = worktree.path; } const ownsWorktree = !refineOf; // Fresh runs get the full agent contract prompt; refinement runs just send // the operator's feedback as a new user turn in the resumed session (the // prior system prompt + conversation are already in the session history). const prompt = refineOf ? input.prompt?.trim() || '(no specific changes requested — review the work so far and improve it).' : buildPrompt(card, { token, runId: id }, input.prompt); try { db.prepare( `INSERT INTO agent_runs (id, card_id, status, use_worktree, owns_worktree, branch, worktree_path, session_file, prompt, token, created_at, started_at) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( id, card.id, useWorktree ? 1 : 0, ownsWorktree ? 1 : 0, branch, worktreePath, refineOf?.sessionFile ?? null, prompt, token, now, now, ); const cwd = worktreePath ?? REPO_ROOT; const runner = new Runner({ runId: id, cwd, prompt, resumeSession: refineOf?.sessionFile, onSettled: (status, summary) => this.settle(id, status, summary), // Only fresh runs need to discover + persist their session file; resumed // runs already point at the prior session via session_file above. onSessionResolved: refineOf ? undefined : (sessionFile) => { db.prepare('UPDATE agent_runs SET session_file = ? WHERE id = ?').run(sessionFile, id); }, }); // A refinement run never cleans up the worktree it inherited. this.active.set(id, { runner, worktree, cleanup: ownsWorktree && (input.cleanupOnFinish ?? false), }); runner.start(); } catch (err) { // Persisting or starting failed — reclaim only a worktree we created. if (worktree && ownsWorktree) removeWorktree(id, worktree.branch); throw err; } return this.get(id)!; } /** Whether a run is currently active (steerable / stoppable). */ isActive(id: string): boolean { return this.active.has(id); } /** Send a steer/follow-up message to an active run. */ message(id: string, text: string, mode: 'steer' | 'followUp'): void { const entry = this.active.get(id); if (!entry) throw new RunNotFoundError(id); entry.runner.message(text, mode); } /** Abort an active run. */ stop(id: string): void { const entry = this.active.get(id); if (!entry) throw new RunNotFoundError(id); entry.runner.stop(); } /** Subscribe to a run's live slim-event stream (best-effort; may be inactive). */ subscribe(id: string, fn: (event: PersistedEvent) => void): () => void { const entry = this.active.get(id); if (entry) return entry.runner.subscribe(fn); return () => {}; } /** Delete a run record (and its worktree if still present). */ remove(id: string): void { const entry = this.active.get(id); if (entry) throw new Error('cannot delete an active run; stop it first'); // Stop any live Bevy test before tearing down its worktree. this.bevy.get(id)?.stop(); this.bevy.delete(id); const row = this.get(id); // Only the run that created a worktree may reclaim it; refinement runs // inherit theirs and must never delete the owning run's worktree. if (row?.ownsWorktree && row.worktreePath && row.branch) removeWorktree(id, row.branch); db.prepare('DELETE FROM agent_run_events WHERE run_id = ?').run(id); db.prepare('DELETE FROM agent_runs WHERE id = ?').run(id); } // --- Bevy playtesting ---------------------------------------------------- /** Whether a Bevy test is currently running for a run. */ bevyRunning(id: string): boolean { return this.bevy.get(id)?.running ?? false; } /** Spawn `cargo run` in a run's worktree to playtest its branch. */ startBevy(id: string): void { const run = this.get(id); if (!run) throw new RunNotFoundError(id); if (!run.worktreePath || !isWorktreePresent(run.worktreePath)) { throw new Error('run has no worktree to run Bevy in'); } let bp = this.bevy.get(id); if (bp?.running) throw new Error('Bevy is already running for this run'); if (!bp) { bp = new BevyProcess(id); this.bevy.set(id, bp); } bp.start(run.worktreePath); } /** Stop a run's Bevy test (best-effort). */ stopBevy(id: string): void { this.bevy.get(id)?.stop(); } // --- reads --------------------------------------------------------------- get(id: string): AgentRun | undefined { const row = db.prepare('SELECT * FROM agent_runs WHERE id = ?').get(id) as AgentRunRow | undefined; return row ? hydrateRun(row) : undefined; } /** Resolve a run by its agent token (used to authorize internal calls). */ getByToken(token: string): AgentRun | undefined { const row = db .prepare('SELECT * FROM agent_runs WHERE token = ? ORDER BY created_at DESC LIMIT 1') .get(token) as AgentRunRow | undefined; return row ? hydrateRun(row) : undefined; } /** Recent runs, newest first. */ list(limit = 50): AgentRun[] { const rows = db .prepare('SELECT * FROM agent_runs ORDER BY created_at DESC LIMIT ?') .all(limit) as AgentRunRow[]; return rows.map(hydrateRun); } /** Runs for a given card (newest first). */ listForCard(cardId: string): AgentRun[] { const rows = db .prepare('SELECT * FROM agent_runs WHERE card_id = ? ORDER BY created_at DESC') .all(cardId) as AgentRunRow[]; return rows.map(hydrateRun); } /** Replayable event history for a run, optionally after a sequence number. */ events(id: string, sinceSeq = 0): AgentRunEvent[] { const rows = db .prepare('SELECT * FROM agent_run_events WHERE run_id = ? AND seq > ? ORDER BY seq ASC') .all(id, sinceSeq) as { id: number; run_id: string; seq: number; type: string; data: string; created_at: string }[]; return rows.map((r) => ({ id: r.id, runId: r.run_id, seq: r.seq, type: r.type, data: safeParse(r.data), createdAt: r.created_at, })); } // --- internal: finalize a run ------------------------------------------- private settle(id: string, status: RunStatus, summary: string | null): void { const entry = this.active.get(id); const now = new Date().toISOString(); let commitSha: string | null = null; let extraError: string | null = null; if (entry?.worktree) { commitSha = headSha(entry.worktree.path); const dirty = dirtySummary(entry.worktree.path); if (dirty) { // Uncommitted changes left behind — surface as an error note + event. extraError = `worktree has uncommitted changes:\n${dirty}`; appendRunEvent(id, 'log', { level: 'warn', text: extraError }); } } db.prepare( `UPDATE agent_runs SET status = ?, summary = ?, commit_sha = ?, error = COALESCE(?, error), finished_at = ? WHERE id = ?`, ).run(status, summary, commitSha, extraError, now, id); this.active.delete(id); // Optional cleanup of the worktree once the run is done. if (entry?.cleanup && entry.worktree) { removeWorktree(id, entry.worktree.branch); } } } export class RunNotFoundError extends Error { constructor(id: string) { super(`agent run not found: ${id}`); this.name = 'RunNotFoundError'; } } // --- helpers --------------------------------------------------------------- function hydrateRun(row: AgentRunRow): AgentRun { return { id: row.id, cardId: row.card_id, status: row.status as RunStatus, useWorktree: row.use_worktree === 1, ownsWorktree: row.owns_worktree === 1, branch: row.branch, worktreePath: row.worktree_path, sessionFile: row.session_file, prompt: row.prompt, summary: row.summary, commitSha: row.commit_sha, error: row.error, createdAt: row.created_at, startedAt: row.started_at, finishedAt: row.finished_at, }; } function safeParse(s: string): Record { try { return JSON.parse(s) as Record; } catch { return {}; } } /** Process-wide singleton. */ export const runManager = new RunManager();