feat: in-app Orchestrator (Phase 2) — multi-agent conductor

Brings the deterministic Han-flow conductor into BooCode: launch any read-only
flow from BooChat or BooCoder, watch each agent stream live in a Paseo-style
run pane, get an evidence-disciplined report — on local Qwen, persisted and
resumable. Read-only enforced hard via qwen --approval-mode plan (orchestrator
tasks fail closed if qwen is unavailable; never fall to write-capable native).

Backend (apps/coder): re-homed conductor defs, flow_runs/flow_steps schema,
flow-runner + dispatcher onTaskTerminal hook, restart-resume, runs routes
(launch/list/get/cancel), user-channel WS. Contracts: two flow_run_* frames.
Web: orchestrator pane kind + OrchestratorPane, Workflow button + slash flows
(BooChat/BooCoder parity), FlowLauncherDialog, "New Orchestrator" in the + and
split menus, runs history + export. Plan: openspec/changes/orchestrator.
This commit is contained in:
2026-06-03 14:59:07 +00:00
parent 7ff99238c9
commit fa8d707923
118 changed files with 15723 additions and 27 deletions

View File

@@ -7,6 +7,7 @@ import type {
ErrorReason,
HtmlArtifactState,
MarkdownArtifactState,
OrchestratorState,
Project,
Session,
} from '@/api/types';
@@ -184,6 +185,52 @@ export interface GitDiffRefreshEvent {
type: 'git_diff_refresh';
}
// Orchestrator: emitted client-side to open an orchestrator pane.
// useWorkspacePanes subscribes and inserts the pane (or focuses an existing one).
// placement carries the surface context: 'new' (+ menu) or 'split' (split-pane
// menu). addOrchestratorPane appends in both cases; the hint is available for
// future positional differentiation.
export interface OpenOrchestratorPaneEvent {
type: 'open_orchestrator_pane';
state: OrchestratorState;
placement?: 'new' | 'split';
}
// Orchestrator: emitted by the Workflow button on ChatInput (or "New Orchestrator"
// menu items) to request the flow launcher dialog. Carries the current pane's
// project and the placement context ('new' from the + menu, 'split' from the
// split-pane menu) so the resulting open_orchestrator_pane can be placed correctly.
export interface OpenFlowLauncherEvent {
type: 'open_flow_launcher';
project_id: string;
placement?: 'new' | 'split';
}
// Orchestrator: run-level frames forwarded from the coder user channel by
// useCoderUserEvents. OrchestratorPane subscribes to update its roster/report.
export interface FlowRunStartedEvent {
type: 'flow_run_started';
run_id: string;
flow_name: string;
band: 'small' | 'medium' | 'large';
steps: Array<{
step_id: string;
agent: string;
kind: 'agent' | 'code';
chat_id: string;
label: string;
}>;
}
export interface FlowRunStepUpdatedEvent {
type: 'flow_run_step_updated';
run_id: string;
step_id: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled';
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
report?: string;
}
export type SessionEvent =
| SessionRenamedEvent
| ProjectCreatedEvent
@@ -211,7 +258,11 @@ export type SessionEvent =
| ProjectUpdatedEvent
| ChatStatusEvent
| RefetchMessagesEvent
| GitDiffRefreshEvent;
| GitDiffRefreshEvent
| OpenOrchestratorPaneEvent
| FlowRunStartedEvent
| FlowRunStepUpdatedEvent
| OpenFlowLauncherEvent;
type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>();

View File

@@ -0,0 +1,75 @@
// Coder user-channel WS — mirrors useUserEvents but connects to the BooCoder
// host service's /api/coder/ws/user endpoint. Forwards flow_run_started and
// flow_run_step_updated frames onto the sessionEvents bus so OrchestratorPane
// can subscribe to run-level lifecycle updates without a per-session WS.
//
// Event-dedup discipline: do NOT additionally emit these frames locally after
// a POST /api/coder/runs call — this hook forwards the authoritative WS frame.
import { useEffect } from 'react';
import { WsFrameSchema } from '@boocode/contracts/ws-frames';
import { sessionEvents } from './sessionEvents';
import type { FlowRunStartedEvent, FlowRunStepUpdatedEvent } from './sessionEvents';
const RECONNECT_INITIAL_MS = 1000;
const RECONNECT_MAX_MS = 30_000;
export function useCoderUserEvents(): void {
useEffect(() => {
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectDelay = RECONNECT_INITIAL_MS;
let unmounted = false;
const connect = () => {
if (unmounted) return;
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${window.location.host}/api/coder/ws/user`);
ws.onopen = () => {
reconnectDelay = RECONNECT_INITIAL_MS;
};
ws.onmessage = (ev) => {
let raw: unknown;
try {
raw = JSON.parse(ev.data as string);
} catch {
return;
}
const validated = WsFrameSchema.safeParse(raw);
if (!validated.success) {
console.error('ws-frame-validation-failed (coder user channel)', {
frame_type: (raw as { type?: unknown })?.type,
errors: validated.error.flatten(),
});
return;
}
const frame = validated.data;
if (frame.type === 'flow_run_started') {
sessionEvents.emit(frame as unknown as FlowRunStartedEvent);
} else if (frame.type === 'flow_run_step_updated') {
sessionEvents.emit(frame as unknown as FlowRunStepUpdatedEvent);
}
};
ws.onclose = () => {
if (unmounted) return;
const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
reconnectTimer = setTimeout(connect, delay);
};
ws.onerror = () => {
try { ws?.close(); } catch {}
};
};
connect();
return () => {
unmounted = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws) try { ws.close(); } catch {}
};
}, []);
}

View File

@@ -198,6 +198,12 @@ function applyFrame(state: State, frame: WsFrame): State {
// TS exhaustiveness satisfied (native sessions never emit it).
return state;
}
case 'flow_run_started':
case 'flow_run_step_updated': {
// Orchestrator frames consumed by OrchestratorPane's own subscription.
// No-op here to keep TS exhaustiveness satisfied.
return state;
}
}
}

View File

@@ -189,6 +189,12 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'git_diff_refresh':
// Consumed by useGitDiff; no sidebar state change needed.
return prev;
case 'open_orchestrator_pane':
case 'open_flow_launcher':
case 'flow_run_started':
case 'flow_run_step_updated':
// Consumed by useWorkspacePanes / OrchestratorPane / FlowLauncherDialog; sidebar has no stake.
return prev;
case 'project_archived': {
const next = prev.projects.filter((p) => p.id !== event.project_id);
if (next.length === prev.projects.length) return prev;

View File

@@ -6,6 +6,7 @@ import type {
ClosedPaneEntry,
HtmlArtifactState,
MarkdownArtifactState,
OrchestratorState,
WorkspacePane,
WorkspaceState,
WorkspaceTabKind,
@@ -176,6 +177,16 @@ function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
};
}
function orchestratorPane(state: OrchestratorState): WorkspacePane {
return {
id: generateId(),
kind: 'orchestrator',
chatIds: [],
activeChatIdx: -1,
orchestrator_state: state,
};
}
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
// page reload always returns to a clean workspace; the user re-opens via the
// sidebar Settings button when needed.
@@ -277,6 +288,8 @@ export interface UseWorkspacePanesResult {
addSplitPane: (kind: 'chat' | 'terminal' | 'coder') => string | null;
/** Mixed tabs: add a tab of any kind to a pane (the "+" menu). */
createTab: (paneIdx: number, kind: WorkspaceTabKind) => Promise<void>;
/** Open an orchestrator run pane (or focus an existing one for the same run_id). */
addOrchestratorPane: (state: OrchestratorState) => string | null;
/** Back-compat alias for createTab(paneIdx, 'coder'). */
createCoderTab: (paneIdx: number) => Promise<void>;
// Open-on-first-click, close-on-second-click. Singleton — settings panes
@@ -831,6 +844,39 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return success ? newPaneId : null;
}, [seedPaneChat]);
const addOrchestratorPane = useCallback((state: OrchestratorState): string | null => {
let openedId: string | null = null;
setPanes((prev) => {
// Dedup: focus an existing pane for the same run.
const existingIdx = prev.findIndex(
(p) => p.kind === 'orchestrator' && p.orchestrator_state?.run_id === state.run_id,
);
if (existingIdx >= 0) {
setActivePaneIdx(existingIdx);
openedId = prev[existingIdx]!.id;
return prev;
}
if (nonSettingsCount(prev) >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const newPane = orchestratorPane(state);
openedId = newPane.id;
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
return next;
});
return openedId;
}, []);
// Orchestrator pane: open via sessionEvents (fired by ChatInput slash/button).
useEffect(() => {
return sessionEvents.subscribe((ev) => {
if (ev.type !== 'open_orchestrator_pane') return;
addOrchestratorPane(ev.state);
});
}, [addOrchestratorPane]);
// Returns the new settings pane id when one is OPENED (so mobile callers can
// push ?pane= atomically — see addPaneAndSwitch), or null when it was closed.
// Id generated outside the updater so a strict-mode double-invoke agrees.
@@ -1074,6 +1120,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
closeSessionHistory,
addSplitPane,
createTab,
addOrchestratorPane,
createCoderTab,
toggleSettingsPane,
removePane,