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:
@@ -4,16 +4,21 @@ import {
|
||||
type AgentRun,
|
||||
type DiffResult,
|
||||
type MergeResult,
|
||||
type RunEvent,
|
||||
} from '../../lib/orchestratorApi';
|
||||
|
||||
/**
|
||||
* Drives the agentic orchestrator from the board UI.
|
||||
* Shared run registry for the implementation board.
|
||||
*
|
||||
* Holds the latest run per card plus a live, replayable event log for any run
|
||||
* currently being "watched" (typically the expanded card's active run). Live
|
||||
* updates arrive over Server-Sent Events; the board stays fully interactive
|
||||
* while an agent works a card — start, steer, and stop at any time.
|
||||
* Owns the lightweight, board-level slice of orchestrator state: the run list,
|
||||
* Bevy-playtest flags, and the derived active-run index. It deliberately does
|
||||
* NOT hold the streaming event log — that lives in `useRunStream`, scoped to the
|
||||
* run console (`AgentRunBar`), so a burst of agent events re-renders only the
|
||||
* console and never the board page or card modal. Lifecycle changes observed in
|
||||
* a stream (status / bevy / done) are pushed back here via `patchRun` /
|
||||
* `reflectBevy` so the board's active indicators stay correct.
|
||||
*
|
||||
* The returned object is memoized on its state/callbacks, so its identity is
|
||||
* stable between registry lifecycle changes (not, e.g., on every render).
|
||||
*/
|
||||
|
||||
export interface UseOrchestrator {
|
||||
@@ -23,24 +28,22 @@ export interface UseOrchestrator {
|
||||
isRunning: (cardId: string) => boolean;
|
||||
/** All known runs (newest first), for a global activity view. */
|
||||
runs: AgentRun[];
|
||||
/** Watched events keyed by run id (ordered). */
|
||||
eventsForRun: (runId: string) => RunEvent[];
|
||||
/** Load initial state. */
|
||||
reload: () => Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** Begin a run for a card. Returns the created run. */
|
||||
start: (input: { cardId: string; prompt?: string }) => Promise<AgentRun>;
|
||||
start: (input: { cardId: string; prompt?: string; refineRunId?: string }) => Promise<AgentRun>;
|
||||
/** Send a steer/follow-up message to an active run. */
|
||||
message: (runId: string, text: string, mode: 'steer' | 'followUp') => Promise<void>;
|
||||
/** Stop an active run. */
|
||||
stop: (runId: string) => Promise<void>;
|
||||
/** Open the live event stream for a run (ref-counted; safe to call repeatedly). */
|
||||
watch: (runId: string) => void;
|
||||
/** Release a watch (ref-counted; closes the stream when the last watcher leaves). */
|
||||
unwatch: (runId: string) => void;
|
||||
/** Remove a settled run from the UI (and reclaim its worktree). */
|
||||
remove: (runId: string) => Promise<void>;
|
||||
/** Apply a partial update to a run record (used by stream reflectors). */
|
||||
patchRun: (runId: string, patch: Partial<AgentRun>) => void;
|
||||
/** Reflect a Bevy playtest lifecycle change (used by stream reflectors). */
|
||||
reflectBevy: (runId: string, running: boolean) => void;
|
||||
/** Fetch a run's branch diff vs main. */
|
||||
getDiff: (runId: string) => Promise<DiffResult>;
|
||||
/** Merge a run's branch into the main worktree. */
|
||||
@@ -63,16 +66,10 @@ export interface UseOrchestrator {
|
||||
|
||||
export function useOrchestrator(): UseOrchestrator {
|
||||
const [runs, setRuns] = useState<AgentRun[]>([]);
|
||||
const [eventsByRun, setEventsByRun] = useState<Record<string, RunEvent[]>>({});
|
||||
const [bevyRunning, setBevyRunning] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Refcounted EventSource subscriptions + replay cursors, kept in refs so the
|
||||
// SSE callbacks always see fresh state without re-subscribing.
|
||||
const sources = useRef(new Map<string, EventSource>());
|
||||
const refcounts = useRef(new Map<string, number>());
|
||||
const cursors = useRef(new Map<string, number>());
|
||||
// Signature of the last loaded run list, so the background poll can skip
|
||||
// state updates (and re-renders) when nothing actually changed.
|
||||
const lastSig = useRef('');
|
||||
@@ -101,9 +98,10 @@ export function useOrchestrator(): UseOrchestrator {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
// Background poll keeps run status fresh even when no SSE stream is open
|
||||
// (i.e. when no card modal is open). Poll faster while any run is active so a
|
||||
// collapsed card's running indicator turns over promptly when it settles.
|
||||
// Background poll keeps run status fresh even when no card modal is open.
|
||||
// Poll faster while any run is active so a collapsed card's running indicator
|
||||
// turns over promptly when it settles. (The live event stream, when a modal is
|
||||
// open, is the primary updater; this is a liveness backstop.)
|
||||
const anyRunning = useMemo(() => runs.some((r) => r.status === 'running'), [runs]);
|
||||
useEffect(() => {
|
||||
const ms = anyRunning ? 3_000 : 10_000;
|
||||
@@ -121,17 +119,8 @@ export function useOrchestrator(): UseOrchestrator {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const appendEvent = useCallback((runId: string, ev: RunEvent) => {
|
||||
setEventsByRun((prev) => {
|
||||
const cur = prev[runId] ?? [];
|
||||
// Dedup by seq when present (history flush vs. live may overlap).
|
||||
if (typeof ev.seq === 'number' && cur.some((e) => e.seq === ev.seq)) return prev;
|
||||
return { ...prev, [runId]: [...cur, ev] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
async (input: { cardId: string; prompt?: string }) => {
|
||||
async (input: { cardId: string; prompt?: string; refineRunId?: string }) => {
|
||||
const { run } = await orchestratorApi.startRun(input);
|
||||
upsertRun(run);
|
||||
return run;
|
||||
@@ -146,106 +135,30 @@ export function useOrchestrator(): UseOrchestrator {
|
||||
[],
|
||||
);
|
||||
|
||||
const stop = useCallback(
|
||||
async (runId: string) => {
|
||||
await orchestratorApi.stopRun(runId);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const stop = useCallback(async (runId: string) => {
|
||||
await orchestratorApi.stopRun(runId);
|
||||
}, []);
|
||||
|
||||
const remove = useCallback(async (runId: string) => {
|
||||
await orchestratorApi.deleteRun(runId);
|
||||
setRuns((prev) => prev.filter((r) => r.id !== runId));
|
||||
setEventsByRun((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[runId];
|
||||
}, []);
|
||||
|
||||
/** Apply a partial update to a run (status/summary/etc., from stream events). */
|
||||
const patchRun = useCallback((runId: string, patch: Partial<AgentRun>) => {
|
||||
setRuns((prev) => prev.map((r) => (r.id === runId ? { ...r, ...patch } : r)));
|
||||
}, []);
|
||||
|
||||
/** Reflect a Bevy playtest lifecycle change (from stream events). */
|
||||
const reflectBevy = useCallback((runId: string, running: boolean) => {
|
||||
setBevyRunning((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (running) next.add(runId);
|
||||
else next.delete(runId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** (Re)open the SSE stream for a run, replaying persisted history first. */
|
||||
const openStream = useCallback(
|
||||
(runId: string) => {
|
||||
if (sources.current.has(runId)) return;
|
||||
const since = cursors.current.get(runId) ?? 0;
|
||||
const es = new EventSource(`/api/orchestrator/runs/${runId}/stream?since=${since}`);
|
||||
sources.current.set(runId, es);
|
||||
es.addEventListener('event', (msg) => {
|
||||
const ev = JSON.parse((msg as MessageEvent).data) as RunEvent;
|
||||
if (typeof ev.seq === 'number') cursors.current.set(runId, ev.seq);
|
||||
appendEvent(runId, ev);
|
||||
// Reflect status changes onto the run record.
|
||||
if (ev.type === 'status') {
|
||||
const status = ev.data.status as AgentRun['status'];
|
||||
setRuns((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === runId
|
||||
? {
|
||||
...r,
|
||||
status,
|
||||
finishedAt: ['completed', 'failed', 'stopped'].includes(status)
|
||||
? new Date().toISOString()
|
||||
: r.finishedAt,
|
||||
}
|
||||
: r,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (ev.type === 'done' && typeof ev.data.summary === 'string') {
|
||||
setRuns((prev) =>
|
||||
prev.map((r) => (r.id === runId ? { ...r, summary: ev.data.summary as string } : r)),
|
||||
);
|
||||
}
|
||||
// Track Bevy playtest lifecycle from its events.
|
||||
if (ev.type === 'bevy') {
|
||||
const phase = ev.data.phase;
|
||||
setBevyRunning((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (phase === 'start') next.add(runId);
|
||||
else if (phase === 'end') next.delete(runId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
es.onerror = () => {
|
||||
// EventSource auto-reconnects; nothing to do here.
|
||||
};
|
||||
},
|
||||
[appendEvent],
|
||||
);
|
||||
|
||||
const watch = useCallback(
|
||||
(runId: string) => {
|
||||
const n = (refcounts.current.get(runId) ?? 0) + 1;
|
||||
refcounts.current.set(runId, n);
|
||||
if (n === 1) openStream(runId);
|
||||
},
|
||||
[openStream],
|
||||
);
|
||||
|
||||
const unwatch = useCallback((runId: string) => {
|
||||
const n = (refcounts.current.get(runId) ?? 0) - 1;
|
||||
if (n > 0) {
|
||||
refcounts.current.set(runId, n);
|
||||
return;
|
||||
}
|
||||
refcounts.current.delete(runId);
|
||||
const es = sources.current.get(runId);
|
||||
if (es) {
|
||||
es.close();
|
||||
sources.current.delete(runId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Close all streams on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const es of sources.current.values()) es.close();
|
||||
sources.current.clear();
|
||||
refcounts.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const runForCard = useCallback(
|
||||
(cardId: string) => {
|
||||
const forCard = runs.filter((r) => r.cardId === cardId);
|
||||
@@ -260,17 +173,8 @@ export function useOrchestrator(): UseOrchestrator {
|
||||
[runs],
|
||||
);
|
||||
|
||||
const eventsForRun = useCallback((runId: string) => eventsByRun[runId] ?? [], [eventsByRun]);
|
||||
|
||||
const getDiff = useCallback(
|
||||
(runId: string) => orchestratorApi.getDiff(runId),
|
||||
[],
|
||||
);
|
||||
|
||||
const mergeRun = useCallback(
|
||||
(runId: string) => orchestratorApi.mergeRun(runId),
|
||||
[],
|
||||
);
|
||||
const getDiff = useCallback((runId: string) => orchestratorApi.getDiff(runId), []);
|
||||
const mergeRun = useCallback((runId: string) => orchestratorApi.mergeRun(runId), []);
|
||||
|
||||
const startBevy = useCallback(async (runId: string) => {
|
||||
await orchestratorApi.startBevy(runId);
|
||||
@@ -292,7 +196,6 @@ export function useOrchestrator(): UseOrchestrator {
|
||||
/**
|
||||
* Card-id index of active runs. Recomputed only when `runs` or `bevyRunning`
|
||||
* changes — NOT on every streamed event — so memoized consumers stay stable.
|
||||
* Replaces the per-card `.filter().find()` scans the board used to do.
|
||||
*/
|
||||
const activeByCard = useMemo(() => {
|
||||
const m = new Map<string, { running: boolean; bevy: boolean; runId: string }>();
|
||||
@@ -318,26 +221,50 @@ export function useOrchestrator(): UseOrchestrator {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
runForCard,
|
||||
isRunning,
|
||||
runs,
|
||||
eventsForRun,
|
||||
reload,
|
||||
loading,
|
||||
error,
|
||||
start,
|
||||
message,
|
||||
stop,
|
||||
watch,
|
||||
unwatch,
|
||||
remove,
|
||||
getDiff,
|
||||
mergeRun,
|
||||
startBevy,
|
||||
stopBevy,
|
||||
bevyIsRunning,
|
||||
refreshBevyStatus,
|
||||
activeByCard,
|
||||
};
|
||||
// Stable identity: re-created only when registry state changes, so consumers
|
||||
// (and the per-run stream effect) don't churn on unrelated renders.
|
||||
return useMemo<UseOrchestrator>(
|
||||
() => ({
|
||||
runForCard,
|
||||
isRunning,
|
||||
runs,
|
||||
reload,
|
||||
loading,
|
||||
error,
|
||||
start,
|
||||
message,
|
||||
stop,
|
||||
remove,
|
||||
patchRun,
|
||||
reflectBevy,
|
||||
getDiff,
|
||||
mergeRun,
|
||||
startBevy,
|
||||
stopBevy,
|
||||
bevyIsRunning,
|
||||
refreshBevyStatus,
|
||||
activeByCard,
|
||||
}),
|
||||
[
|
||||
runForCard,
|
||||
isRunning,
|
||||
runs,
|
||||
reload,
|
||||
loading,
|
||||
error,
|
||||
start,
|
||||
message,
|
||||
stop,
|
||||
remove,
|
||||
patchRun,
|
||||
reflectBevy,
|
||||
getDiff,
|
||||
mergeRun,
|
||||
startBevy,
|
||||
stopBevy,
|
||||
bevyIsRunning,
|
||||
refreshBevyStatus,
|
||||
activeByCard,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user