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:
@@ -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>();
|
||||
|
||||
75
apps/web/src/hooks/useCoderUserEvents.ts
Normal file
75
apps/web/src/hooks/useCoderUserEvents.ts
Normal 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 {}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user