# Orchestrator (Phase 2) — design (the HOW) Planning altitude: names files, columns, frames, and decision-bearing values (the plan-mode flag, status sets, frame field names). Every non-obvious choice cites a committed decision in [artifacts/implementation-decision-log.md](artifacts/implementation-decision-log.md). The behavioral spec is [artifacts/design-context.md](artifacts/design-context.md) (decision 5 REVISED here); integration surfaces are in [artifacts/.discovery-notes.md](artifacts/.discovery-notes.md). ## Architecture at a glance ``` ChatInput (shared composer) apps/web ├─ Workflow button → FlowLauncherDialog ─┐ └─ /flow slash (instant defaults) ───────┤ ▼ POST /api/runs ── apps/coder/routes ▼ flow-runner.ts (DB-driven scheduler) · loads flow def from src/conductor/ · step.run(ctx) IN-PROCESS → prompt (contracts injected) · INSERT flow_runs / flow_steps · INSERT each ready agent step as a tasks row (mode_id='plan', synthetic chat_id) ▼ dispatcher.ts (REUSED, unchanged internals) · LISTEN 'tasks_new' → external-agent path · qwen --approval-mode plan (read-only gate) · worktree = read snapshot; AgentEvents → WS frames · onTaskTerminal(taskId,state) ← ONE new hook ▼ flow-runner advances: read full output → run code steps inline → INSERT next ready wave (or finish + report) ▼ flow_run_started / flow_run_step_updated + reused delta/tool_call/ message_complete (keyed by step chat_id) → broker → WS ▼ OrchestratorPane.tsx (run header, report-at-top, collapsed roster, expand-one-at-a-time stream) ``` ## Re-home & DispatchFn seam ([D-1](artifacts/implementation-decision-log.md#d-1--re-home-the-pure-conductor-definitions-into-appscodersrcconductor)) Copy the pure (dispatch-free) conductor files into `apps/coder/src/conductor/`: `spine.ts`, `flows/*`, `contracts.ts`, `types.ts`, `render.ts`. Copy the 23 personas (`conductor/agents/*.md`). Do NOT copy `flow.ts` (in-memory scheduler, replaced by the flow-runner) or `dispatch.ts` (`opencode run` subprocess, replaced by dispatcher reuse). The Phase-1 CLI under `conductor/` stays alive unchanged as a regression oracle. Two seam edits on the copies: - **Sever flow→dispatch coupling.** `flows/code-review.ts:10` imports `dispatchAgent` from `../dispatch.js` and calls it at `:62`. Replace that import with a `DispatchFn` field on `StepContext`, injected by the flow-runner. Every flow then reaches dispatch through the context, not a module import. - **Parameterize the model.** `spine.ts:122` reads `process.env.CONDUCTOR_MODEL` into the report header. Make it read the run's configured `model` (passed through the spine factory / step context) so the header matches the run, not a process env. The evidence/yagni contracts (`contracts.ts`) and the adversarial-validator gate are preserved because the flow-runner calls `step.run(ctx)` **in-process** to build each prompt before it INSERTs the task — the closures execute in the coder process; prompts are never serialized to DB ([D-1] rationale, C11). ## Schema ([D-5](artifacts/implementation-decision-log.md#d-5--flow_runs--flow_steps-schema-in-the-coder-schema), [D-10](artifacts/implementation-decision-log.md#d-10--concurrency-multiple-runs-no-queued-status-single-model-per-run)) Two tables in `apps/coder/src/schema.sql` (coder-owned; applied by the host boocoder service). Explicit CHECK names + the repo's DROP-IF-EXISTS → guarded-ADD discipline (root CLAUDE.md). `flow_runs`: - `id`, `project_id` (NO FK — matches `tasks.project_id`, `schema.sql:19`), `flow_name`, `band` CHECK `(small|medium|large)`, `model`, `status` CHECK-named `(running|completed|failed)`, `input` JSONB CHECK `(input ? 'question')`, `report` TEXT nullable, `error`, `created_at`/`updated_at` (`clock_timestamp()`). - Index `flow_runs(project_id, created_at DESC)` (runs history). `flow_steps`: - `id`, `run_id` UUID → `flow_runs(id)` ON DELETE CASCADE, `step_id`, `kind` CHECK `(agent|code)`, `agent`, `status` CHECK-named `(pending|running|completed|failed|skipped)` — no `queued` status ([D-10]; llama-swap can't populate it, C16), `task_id` UUID → `tasks(id)` ON DELETE SET NULL (nullable; code steps NULL), `chat_id` UUID → `chats(id)` ON DELETE SET NULL, `input` TEXT, `output` TEXT (FULL output — `tasks.output_summary` is ≤500 char, `schema.sql:26`, and can't reconstruct `ctx.results`, C3), `error`, timestamps, UNIQUE `(run_id, step_id)`. - Index `flow_steps(run_id, status)` (ready-wave + resume scans). No `depends_on` column and no skipped-step rows — deps and skips are derivable from the loaded flow def (`flow.ts:28-41`, `types.ts:27`, C6). The FK lives on `flow_steps.task_id`, NOT a new column on `tasks` ([D-5]; keeps `tasks` generic, C4). JSONB writes via `sql.json(value as never)`. ## Flow-runner & onTaskTerminal ([D-2](artifacts/implementation-decision-log.md#d-2--db-driven-flow-runner-with-an-ontaskterminal-dispatcher-hook)) New `apps/coder/src/services/flow-runner.ts` — a DB-backed scheduler that owns `flow_runs`/`flow_steps`. It does NOT run a poll loop; it reacts to ONE new hook. `createDispatcher` gains an `onTaskTerminal(taskId, state)` callback, invoked at the existing external-agent terminal transitions (`dispatcher.ts:642-646` completed, `:659-661` failed). No change to the dispatcher's internal run functions ([D-2]). Run lifecycle: 1. `POST /api/runs` → flow-runner loads the flow def, derives the first ready wave, INSERTs `flow_runs` (`status='running'`) and its `flow_steps` (each `status='pending'`), and a synthetic `chats` row per agent step (stream attribution, [D-6]). 2. For each ready `agent` step: build the prompt via `step.run(ctx)` in-process, then INSERT a `tasks` row `(project_id, input=prompt, agent, model, mode_id='plan', chat_id=)` with `state='pending'`. The dispatcher picks it up via `LISTEN 'tasks_new'` ([D-3]). 3. `code` steps run inline in the flow-runner (no task; `flow_steps.task_id` NULL). 4. `onTaskTerminal` fires → flow-runner reads the **full** task output, writes it to `flow_steps.output`, marks the step completed/failed, derives the next ready wave, and INSERTs it (or, on the last wave, renders the report into `flow_runs.report` and sets `status='completed'`). ## Execution via dispatcher reuse ([D-3](artifacts/implementation-decision-log.md#d-3--reuse-the-existing-dispatcher-insert-pending-task-not-a-direct-pty-bypass)) Steps execute through the **existing** dispatcher external-agent path — not a direct-PTY bypass. The dispatcher creates a git worktree (a stable HEAD read-checkout), runs the agent, and streams AgentEvents → WS frames unchanged. This REVISES design-context decision 5 ("no worktree") to "worktree as a harmless read snapshot" — inert because the agent cannot write under plan mode ([D-4]). Task-as-dispatch precedents the flow-runner mirrors: `routes/skills.ts:94`, `routes/arena.ts:49`, `tools/new_task.ts:54`. ## Read-only via plan mode ([D-4](artifacts/implementation-decision-log.md#d-4--read-only-enforced-hard-by-mode_idplan-qwen---approval-mode-plan)) The flow-runner hardcodes `mode_id='plan'` on every step task; never user-overridable. The PTY dispatcher already passes it to qwen as `--approval-mode plan` (`pty-dispatch.ts:75`), a built-in tool-level gate: reads allowed, writes blocked. This is the SOLE read-only enforcement. Persona prompts and `BOOCODE_TOOLS` are NOT relied upon — they do not govern an external qwen CLI child (R2 security finding, C13). Adding a non-qwen agent to flows requires re-verifying that agent's plan-mode equivalent before allowing it. ## WS frames ([D-6](artifacts/implementation-decision-log.md#d-6--two-new-ws-frames-per-agent-stream-reuses-existing-frames-by-chat_id)) Two new frames in `packages/contracts/src/ws-frames.ts` `WsFrameSchema`: - `flow_run_started`: `{ run_id, flow_name, band, steps: [{ step_id, agent, kind, chat_id, label }] }`. - `flow_run_step_updated`: `{ run_id, step_id, status, run_status?, report? }` (the report rides here — no separate report frame, [D-6]). The per-agent token stream REUSES the existing `delta` / `tool_call` / `message_complete` frames keyed by the step's synthetic `chat_id` — no new streaming frames. Register both new frames in ALL THREE registries: contracts `WsFrameSchema` (rebuild `pnpm -C packages/contracts build`), the server loose `InferenceFrame` union (`services/inference/turn.ts`), and the web strict `WsFrame` union (`apps/web/src/api/types.ts` — the wire-format gate; missing it silently drops the frame at JSON-parse). ## Resume ([D-9](artifacts/implementation-decision-log.md#d-9--resumable-runs-via-initresume-on-coder-startup)) `initResume` runs on coder startup over `flow_runs WHERE status='running'`: - step whose `task_id` task is `completed` → mark step done, advance the run; - step whose task is lost/failed (PTY died on restart) → re-dispatch (re-INSERT a fresh task, again `mode_id='plan'`); - completed steps are kept (no re-run). Reconcile-and-advance, not mark-run-failed — decision 4 commits to resumable and task state is durable under [D-3] (C15). ## Orchestrator pane ([D-7](artifacts/implementation-decision-log.md#d-7--orchestrator-pane-kind--orchestratorpane)) New `orchestrator` pane kind following the `markdown_artifact`/`html_artifact` precedent (`api/types.ts:386` `WorkspacePaneKind`). Touches `WorkspacePaneKind`, `useWorkspacePanes`, `Workspace`, `NewPaneMenu`, `ChatTabBar`, `PaneHeaderActions`. `OrchestratorPane.tsx`: - run header (flow + band); - report-at-top on completion; - collapsed agent roster reusing `AgentStatusDot` (`AgentComposerBar.tsx:204`); - expand-one-at-a-time detail well reusing the CoderPane stream rendering (keyed by the step's `chat_id`); - mobile single-column inline expand; auto-expand-follows-active. The pane subscribes to `flow_run_started` (to build the roster) and `flow_run_step_updated` (status + report), and to the reused delta/tool_call/message_complete frames by `chat_id` for the expanded agent. ## Toolbar button & launcher ([D-8](artifacts/implementation-decision-log.md#d-8--workflow-toolbar-button--slash-launch-boochatboocoder-parity)) A `Workflow` (lucide) button on `ChatInput`'s controls row, between the `SquareSlash` chip and the `Globe` pill (`ChatInput.tsx:648-732`, `:673` — row is ≤5 elements, stays one line, C9). Because `ChatInput` is rendered by both ChatPane and CoderPane, this is BooChat + BooCoder parity from one button. "Flows" label desktop, icon-only mobile. - **Slash** (`/flow `): launches instantly with defaults (band `small`, current pane's project, text-after-command = focus), opening an Orchestrator pane. - **Button** → `FlowLauncherDialog.tsx`: 5 category tabs (Analysis / Discovery / Planning / Authoring / Review) filtering the flow list (`flows/index.ts`), + size + focus + fast toggle; defaults Analysis / Small / off. Same run pane either way. Runs history surfaces in `NewPaneMenu`. Export (copy / save-file / send-to-chat via the existing `sendToChat`, `lib/events.ts`) lives in the pane header `…`, conditional on a completed report. ## Concurrency ([D-10](artifacts/implementation-decision-log.md#d-10--concurrency-multiple-runs-no-queued-status-single-model-per-run)) Multiple runs allowed; each its own pane + `flow_runs` row, no shared state. Step statuses: pending / running / completed / failed / skipped (no `queued` — the dispatcher's `pending` covers a step waiting on deps or on the busy model; llama- swap can't report queue position, C16). Single model per run, default `qwen3.6-35b-a3b-mxfp4`. ## Routes - `POST /api/runs` — `{ project_id, flow_name, band, input:{question,...}, model? }` → creates the run, starts the flow-runner, returns `run_id`. Publishes `flow_run_started`. - `GET /api/runs?project_id=` — runs history (backs `NewPaneMenu`). - `GET /api/runs/:id` — reopen a run (run + steps + report). ## Deploy surface - `apps/coder` changes (conductor defs, flow-runner, dispatcher hook, schema, resume, routes) → `sudo systemctl restart boocoder`. - `packages/contracts` + `apps/web` (frames, pane, button, launcher, history) → `docker compose up --build -d boocode`. Build contracts first (`pnpm -C packages/contracts build`). ## Deferred (YAGNI) Full list with reopen triggers in [artifacts/implementation-decision-log.md](artifacts/implementation-decision-log.md#deferred-yagni): `@boocode/conductor` workspace package (copy-in instead); `flow_steps.depends_on` column (derive from flow def); persisted skipped-step rows (`when()` is pure); a `read_only` flag on `tasks` (superseded by `mode_id='plan'`); an explicit `queued` status (llama-swap can't populate it); a launcher search box (5 category tabs suffice); a separate report WS frame (report rides on `flow_run_step_updated`).