Two intertwined changes that both touch the orchestrator hook + run console: Isolate the agent event stream (perf): - useRunStream owns the SSE stream + event log locally inside AgentRunBar, so a burst of streamed events re-renders only the console — never the board page or card modal (which was causing frame drops at run start). - useOrchestrator is now a registry only; lifecycle events reflect back up via stable patchRun/reflectBevy reflectors (effect deps depend on those, not the whole object, avoiding a stream-teardown loop). Session resume for Refine: - Runs now persist their pi session (drop --no-session); each fresh run captures its session JSONL path into a new agent_runs.session_file column (additive, idempotent migration). - Refine resumes the prior run's actual session (--session <path> → appends) in that run's own worktree (inherited, never owned), sending the operator's feedback as the next message in the same conversation with full prior context. - owns_worktree guards remove()/cleanup so a refinement never destroys the owning run's worktree; bad refinement targets return 409. - AgentRunBar shows Refine only for settled runs with a recorded session. EOF && echo "" && git log --oneline -3
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
/**
|
|
* 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<string, { runner: Runner; worktree: Worktree | null; cleanup: boolean }>();
|
|
/** Active Bevy test processes keyed by run id (independent of agent runs). */
|
|
private bevy = new Map<string, BevyProcess>();
|
|
|
|
/** 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<string, unknown> {
|
|
try {
|
|
return JSON.parse(s) as Record<string, unknown>;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** Process-wide singleton. */
|
|
export const runManager = new RunManager();
|