feat(kanban): resume runs' chat via Refine + isolate the event stream

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
This commit is contained in:
2026-06-17 18:34:05 -04:00
parent 407bc4f790
commit 6531dc00df
10 changed files with 391 additions and 182 deletions

View File

@@ -25,6 +25,13 @@ export interface StartRunInput {
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 {
@@ -40,37 +47,100 @@ class RunManager {
const useWorktree = input.useWorktree ?? true;
const now = new Date().toISOString();
// Provision the worktree (or fall back to the repo root).
// 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 (useWorktree) {
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;
const prompt = buildPrompt(card, { token, runId: id }, input.prompt);
// 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, branch, worktree_path, prompt, token, created_at, started_at)
VALUES (?, ?, 'running', ?, ?, ?, ?, ?, ?, ?)`,
).run(id, card.id, useWorktree ? 1 : 0, branch, worktreePath, prompt, token, now, now);
(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),
});
this.active.set(id, { runner, worktree, cleanup: input.cleanupOnFinish ?? false });
runner.start();
} catch (err) {
// Persisting or starting failed — reclaim the worktree so we never leak one.
if (worktree) removeWorktree(id, worktree.branch);
// Persisting or starting failed — reclaim only a worktree we created.
if (worktree && ownsWorktree) removeWorktree(id, worktree.branch);
throw err;
}
@@ -111,7 +181,9 @@ class RunManager {
this.bevy.get(id)?.stop();
this.bevy.delete(id);
const row = this.get(id);
if (row?.worktreePath && row.branch) removeWorktree(id, row.branch);
// 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);
}
@@ -237,8 +309,10 @@ function hydrateRun(row: AgentRunRow): AgentRun { return {
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,