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

@@ -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 {}
};
}, []);
}