feat: Task Graph Executor#503
Merged
Merged
Conversation
…kGraph domain model
- Add ResponseFormat enum to gglib-core::ports::llm_completion (JsonSchema + Grammar variants)
- Extend LlmCompletionPort::chat_stream with response_format: Option<&ResponseFormat>
- Update LlmCompletionAdapter in gglib-runtime to inject response_format/grammar into
llama-server request body; all existing callers pass None (no behaviour change)
- Add StructuredOutputError to gglib-core::ports::structured_llm (Stream/Parse/MaxRetries)
- Add get_structured<T>() to gglib-agent::structured_output: JSON-schema-constrained LLM
call with retry-with-feedback loop (collects stream deltas, parses, retries on error)
- Add orchestrator domain model to gglib-core::domain::orchestrator:
- task_graph: NodeId, NodeStatus, HitlMode, TaskNode, TaskGraphError, TaskGraph
with validate_acyclic (DFS cycle detection), validate_tool_allowlist, roots,
ready_nodes; MAX_NODES=8, MAX_DEPTH=3 safety limits
- events: OrchestratorEvent (PlanProposed through OrchestratorComplete),
ApprovalKind (Plan/Node/Tool)
- Re-export all new types from crate root (lib.rs) and module tables regenerated
Closes #490
feat(orchestrator A): ResponseFormat port, StructuredOutputError, TaskGraph domain model
…SSE /orchestrator/plan, Tauri preview page, eval harness - crates/gglib-agent/src/orchestrator/prompts.rs: DIRECTOR_SYSTEM_PROMPT + director_plan_schema() with 3 few-shot examples - crates/gglib-agent/src/orchestrator/director.rs: plan() async fn with PlanError, replan loop (max_replans), OrchestratorEvent SSE emission - crates/gglib-agent/src/orchestrator/mod.rs: pub re-exports - crates/gglib-agent/src/lib.rs: pub mod orchestrator; + cfg_attr unused_crate_dependencies - crates/gglib-agent/Cargo.toml: thiserror dep; gglib-runtime + reqwest dev-deps - crates/gglib-agent/tests/orchestrator_plan_eval.rs: eval harness, 20-prompt corpus, #[ignore]-gated, soft 70% validity floor - crates/gglib-axum/src/handlers/orchestrator/mod.rs: plan_sse handler, PlanRequest, semaphore guard, SSE stream via ReceiverStream - crates/gglib-axum/src/handlers/mod.rs: pub mod orchestrator - crates/gglib-axum/src/routes.rs: POST /api/orchestrator/plan - crates/gglib-cli/src/handlers/plan.rs: execute(), render_tree(), render_mermaid() - crates/gglib-cli/src/handlers/mod.rs: pub mod plan - crates/gglib-cli/src/commands.rs: Commands::Plan variant - crates/gglib-cli/src/dispatch.rs: dispatch arm for Plan - src/types/orchestrator.ts: OrchestratorEvent, TaskGraph, TaskNode TS types - src/services/clients/orchestrator.ts: planOrchestrator() SSE client - src/pages/OrchestratorPlanPreview.tsx: Tauri WebView plan preview page - src/App.tsx: hash-based routing to OrchestratorPlanPreview No execution logic — planning only (Phase B scope). All three surfaces in same commit (GUI Parity Principle). gglib-core has 2 pre-existing clippy issues on epic/orchestrator base (not introduced by this PR).
- events.rs: wrap CoT in backticks in doc comment (doc_markdown) - task_graph.rs:386: remove redundant .clone() on consumed String (redundant_clone) - task_graph.rs:640: dereference &&str before to_string() (inefficient_to_string)
…l harness
gglib-agent/orchestrator/director.rs:
- use Self::ValidationFailed instead of PlanError:: (use_self)
- HashMap → \`HashMap\` in doc comment (doc_markdown)
- allow literal_string_with_formatting_args on intentional template replace
- last_error.clone_from(&err) x3 (assigning_clones)
gglib-agent/orchestrator/prompts.rs:
- HashMap → \`HashMap\` in module doc (doc_markdown)
gglib-agent/structured_output.rs:
- use Self { .. } instead of gglib_core::AssistantContent { .. } (use_self)
gglib-agent/tests/orchestrator_plan_eval.rs:
- #[ignore = "..."] reasons on both test fns (ignore_without_reason)
- split_whitespace().count() instead of collect+len (needless_collect)
- #[allow(cast_precision_loss)] on summarise + eval_full_corpus (usize→f64)
- f64::from(total_replans) instead of as cast (cast_lossless)
feat(orchestrator B): director plan(), CLI gglib plan, Axum SSE /orchestrator/plan, Tauri preview, eval harness
…um/Tauri wiring (#492) - Add executor.rs with topological wave loop and strict context scoping - Add compaction.rs for hard-error post-worker output compaction - Add synthesis.rs for final leaf-node synthesis pass - Wire POST /api/orchestrator/run SSE endpoint in gglib-axum - Wire 'gglib orchestrate <goal>' CLI subcommand with terminal rendering - Update OrchestratorPlanPreview.tsx with Execute button, NodePanel components, and synthesis streaming display (GUI parity) - Add integration tests (5 tests) covering happy path, topological order, fail-fast, PlanApproved, and SynthesisStart/Complete events - All clippy (strict -D warnings) and fmt checks pass - All boundary checks pass
feat(orchestrator): Phase C — executor, compaction, synthesis, CLI/Axum/GUI wiring (#492)
…, run-resume (#493) ## What Implements the three acceptance criteria from issue #493: ### HITL approval gates (ApprovePlan / ApproveEachNode / ApproveTools) - New variants wired into - Executor pauses at gates, emits event with unique , then parks on a oneshot channel until resolved - handled in both plan and node gates - trait + (DashMap-backed) concrete implementation ### SQLite persistence - + tables added to schema - trait (8 methods) - implementing the full trait with unit tests - Executor persists run status transitions and all events - On startup, transitions stale Running → Interrupted ### Run resume - CLI flag: loads run from DB, resets non-Done nodes to Pending, re-invokes executor with + - POST Axum handler (SSE stream) ## GUI parity (CLI + Axum + Tauri) - / CLI flags with stdin approve/reject prompt - route - + routes - hitlMode selector + approval banner - TypeScript types (, , etc.) - TypeScript client (, , etc.) ## Integration tests (orchestrator_hitl.rs — 4 tests) - — approve gate → OrchestratorComplete - — reject gate → PlanRejected + ExecuteError - — mark_interrupted_runs() transitions correctly - — HitlMode::None skips gate, auto-approves ## Quality - clean - applied - 🔍 Checking workspace crate boundaries... 📦 gglib-core (pure domain - no adapters, no sqlx) �[0;32mPASS�[0m: gglib-core 📦 gglib-db (core + sqlx - no adapters) �[0;32mPASS�[0m: gglib-db 📦 gglib-cli (core + db + clap - no web/gui) �[0;32mPASS�[0m: gglib-cli 📦 gglib-axum (core + db + axum - no cli/gui) �[0;32mPASS�[0m: gglib-axum 📦 gglib-tauri (core + db + tauri - no cli/web) �[0;32mPASS�[0m: gglib-tauri 📦 gglib-download (domain/adapter - no UI adapters) �[0;32mPASS�[0m: gglib-download 📦 gglib-gguf (parser - no adapters) �[0;32mPASS�[0m: gglib-gguf 📦 gglib-app-services (service facade - no UI adapters) �[0;32mPASS�[0m: gglib-app-services 📦 gglib-hf (HTTP adapter - no UI adapters) �[0;32mPASS�[0m: gglib-hf 📦 gglib-mcp (domain service - no adapters) �[0;32mPASS�[0m: gglib-mcp 📦 gglib-agent (pure domain agentic loop - no adapters, no infra crates) �[0;32mPASS�[0m: gglib-agent 📦 gglib-runtime (process runner - no UI adapters) �[0;32mPASS�[0m: gglib-runtime 📦 gglib-bootstrap (sole call site for shared infra entry points) �[0;32mPASS�[0m: gglib-bootstrap source guard ✅ �[0;32mAll boundary checks passed�[0m Results written to: boundary-status.json all PASS - Scanning for READMEs with module-table markers... READMEs updated
feat(orchestrator): Phase D — HITL approval gates, SQLite persistence, run-resumption (#493)
Phase E plumbing — all three bootstrap paths (Axum, Tauri bootstrap, Tauri bootstrap_early, and bootstrap_with) now pass OrchestratorDeps to ProxySupervisor::start(), sharing the same approval_registry and orchestrator_repo with the REST API handlers. Changes: - gglib-proxy: add orchestrator_proxy.rs (virtual model routing, auto/ interactive/native modes, OrchestratorEvent→SSE mapping, sentinel logic); wire into server.rs and export from lib.rs - gglib-runtime: add OrchestratorRunnerAdapter (implements OrchestratorRunnerPort over gglib-agent execute()); fix manual Debug impl for McpService field; update supervisor.start() signature; update standalone proxy to build OrchestratorDeps with in-memory backends; update supervisor tests with NoopRunner/Registry/Repo helpers - gglib-app-services: add approval_registry + orchestrator_repo to ProxyDeps/ProxyOps; add gglib-proxy + reqwest deps; build OrchestratorRunnerAdapter in ProxyOps::start() - gglib-axum: hoist orchestrator_repo + approval_registry creation before ProxyDeps so they are shared - gglib-tauri: update all three bootstrap variants to pass new fields - Integration tests: add NoopRunner/Registry/Repo helpers; update gglib_proxy::serve() call sites
- Remove two needless_return statements in orchestrator_proxy.rs (resume_interactive_run match arms) - Replace map_or(true, ...) with is_none_or in InMemoryOrchestratorRepo list_runs() filter (proxy/mod.rs)
Five end-to-end HTTP tests covering the three virtual orchestrator models: - GET /v1/models includes all three virtual model entries - gglib-orchestrator:native returns HTTP 400 with /api/orchestrator/run hint - gglib-orchestrator auto mode streams PlanProposed → NodeStarted → NodeTextDelta → SynthesisStart → SynthesisTextDelta → OrchestratorComplete as markdown SSE chunks - gglib-orchestrator:interactive embeds the gglib-run-id sentinel on AwaitingApproval and terminates the stream - Auto mode rejects requests with no user message with HTTP 400 Uses a ScriptedRunner mock that emits pre-configured OrchestratorEvent sequences over mpsc, exercising the full SSE translation path.
feat(proxy): Phase E — virtual model routing in the OpenAI-compatible proxy
- OrchestratorContext.tsx: reducer for full OrchestratorEvent stream with phases idle/running/awaiting_approval/synthesizing/complete/error, per-node state tracking, HITL pendingApproval, and runs list state. - useOrchestrator hook: bridges SSE events → context dispatch, manages AbortController lifecycle, exposes run/resume/cancel/reset/ approve/loadRuns. - src/pages/Orchestrator/index.tsx: main page with goal input form, HITL mode selector, live phase indicator, DAG tree section, synthesis streaming output, final answer panel, and resumable-runs sidebar. - DagView.tsx: topologically-sorted indented tree of TaskGraph nodes with status badges and dependency labels. No new heavy dependencies. - NodePanel.tsx: collapsible per-node panel showing goal, tool allowlist, streaming text, tool call log with timing, output preview, and error display. - HitlApprovalModal.tsx: plan/node/tool HITL approval modal with plan JSON viewer + optional edit-and-approve flow (approve_with_edits), reject-with-reason path, and node/tool context display. - RunsList.tsx: resumable runs entry with status filter tabs, formatted timestamps, and click-to-resume. - App.tsx: adds #orchestrator hash routing and wraps the new page in <OrchestratorProvider>. - tests/ts/hooks/OrchestratorContext.test.ts: 21 reducer state transition tests covering all major event types. All 606 frontend tests pass. No TypeScript errors. No new backend endpoints — HTTP transport only, no isTauriApp branching. Closes #495
feat(orchestrator): Phase F — native frontend polish, DAG visualization, HITL modals, run management
13 tasks
- Header: GitBranch button in desktop nav and mobile menu to open Orchestrator page - App.tsx: wire onOpenOrchestrator handler; pass hasRunningServers prop - OrchestratorPage: show a warning banner when no model is running and phase is idle
13 tasks
…itor HITL wiring
Add council_engine: 'legacy' | 'v2' preference to AppSettings so users can
opt-in to the new DAG orchestrator engine without touching the legacy Council
flow. Defaults to 'legacy' for full backward compatibility.
Key changes:
types/index.ts
- New CouncilEngine = 'legacy' | 'v2' union type
- council_engine field on AppSettings and UpdateSettingsRequest
types/messages.ts
- orchestratorRunId?: string on GglibMessageCustom — set on assistant
messages produced by a v2 run; drives historical replay via
HistoricalOrchestratorThread
OrchestratorThread
- New hosted-mode props: startRunRef (parent calls startRun via ref) and
onRunComplete (notified on terminal phase: complete | error)
- useEffect fills startRunRef.current with a setGoal + startRun callback
- useEffect fires onRunComplete once per run (guarded by completionFiredRef)
- Idle goal form and 'New run' button hidden when isHosted === true
- Plan-kind HITL approval renders PlanEditor inline instead of the modal;
all other kinds (node, tool, spawn_subteam) keep HitlApprovalModal
CouncilToggle
- engine?: CouncilEngine prop — v2 shows Network icon + 'v2' badge and
different tooltip; legacy keeps existing Users icon (backward-compatible)
ChatMessagesPanel
- Reads council_engine from SettingsContext; derives councilEngine
- orchestratorStartRef + pendingOrchestratorGoalRef added as refs
- councilSubmitRef.current routing branches on councilEngine:
v2 → stores goal in pendingGoalRef, calls orchestratorStartRef.current
legacy → original refine/suggest path unchanged
- OrchestratorThread rendered alongside CouncilThread when councilEngine === 'v2'
- CouncilToggle receives engine={councilEngine}
- New onOrchestratorRunComplete prop (runId, goal, finalAnswer)
MessageBubbles.tsx (AssistantMessageBubble)
- Detects orchestratorRunId on message custom metadata BEFORE the existing
councilSession check; renders HistoricalOrchestratorThread inline for v2
messages loaded from persistence
ChatPage.tsx
- handleOrchestratorRunComplete callback mirrors handleCouncilComplete pattern:
synthesises a user/assistant message pair and appends to the thread
- Passes callback down to ChatMessagesPanel as onOrchestratorRunComplete
…uncil Flip the council_engine default to v2 and completely remove the legacy Council-of-Agents engine from both frontend and backend. Frontend deletions: - src/components/Council/ (14 files) — CouncilThread, CouncilToggle, etc. - src/types/council.ts — SerializableCouncilSession, CouncilEvent types - src/hooks/useCouncil/ — useCouncil hook and its SSE event mapper - src/contexts/CouncilContext.tsx — CouncilProvider, councilReducer - src/services/clients/council.ts — suggestCouncil(), runCouncil() Frontend modifications: - messages.ts: isCouncilMode -> isOrchestratorMode; councilSession removed - types/index.ts: CouncilEngine type and council_engine setting removed - OrchestratorToggle: new minimal toggle component (replaces CouncilToggle) - ChatMessagesPanel: stripped of all council state, effects, and JSX - MessageBubbles: legacy councilSession -> plain-text fallback renderer - ChatPage: CouncilProvider removed; orchestratorSubmitRef replaces councilSubmitRef - useGglibRuntime: onCouncilSubmit -> onOrchestratorSubmit; isOrchestratorMode routing - buildSaveMetadata: persists orchestratorRunId instead of councilSession Backend deletions: - crates/gglib-agent/src/council/ (15 files) — full council orchestrator - crates/gglib-axum/src/handlers/council/ — HTTP handlers - crates/gglib-cli/src/handlers/council/ — CLI handlers Backend modifications: - routes.rs: /council/suggest and /council/run routes removed - commands.rs: Council CLI subcommand removed - dispatch.rs: Council dispatch arm removed - lib.rs files: pub mod council removed Note: compose_council_ports() and CouncilPorts in gglib-runtime are retained as they are used by the orchestrator handlers.
The legacy Council engine was purged in the previous commit (62042e6). The new engine is now universally renamed from "Orchestrator" back to "Council", reclaiming the product name. File renames: - crates/gglib-agent/src/orchestrator/ → council/ - crates/gglib-axum/src/handlers/orchestrator/ → council/ - crates/gglib-core/src/domain/orchestrator/ → council/ - crates/gglib-core/src/ports/orchestrator_{approvals,repository}.rs → council_* - crates/gglib-app-services/src/orchestrator_approvals.rs → council_approvals.rs - crates/gglib-db/src/repositories/sqlite_orchestrator_repository.rs → sqlite_council_repository.rs - crates/gglib-proxy/src/orchestrator_proxy.rs → council_proxy.rs - crates/gglib-runtime/src/orchestrator_runner.rs → council_runner.rs - crates/gglib-cli/src/handlers/orchestrate.rs → council.rs - crates/gglib-agent/tests/orchestrator_*.rs → council_*.rs - src/components/Orchestrator/ → Council/ - src/components/Council/Thread/OrchestratorThread.tsx → CouncilThread.tsx - src/components/Council/Thread/HistoricalOrchestratorThread.tsx → HistoricalCouncilThread.tsx - src/components/Council/Thread/useOrchestratorRunStream.ts → useCouncilRunStream.ts - src/contexts/OrchestratorRegistry.tsx → CouncilRegistry.tsx - src/contexts/OrchestratorContext.tsx → CouncilContext.tsx - src/types/orchestrator.ts → council.ts - src/services/clients/orchestrator.ts → council.ts - src/hooks/useOrchestrator/ → useCouncil/ Symbol renames: - OrchestratorEvent → CouncilEvent - OrchestratorRun → CouncilRun (Rust domain types) - OrchestratorRunStatus → CouncilRunStatus - OrchestratorConfig → CouncilConfig - OrchestratorRunnerPort/Adapter → CouncilRunnerPort/Adapter - OrchestratorDeps → CouncilDeps - OrchestratorProxy → CouncilProxy - OrchestratorRepositoryPort → CouncilRepositoryPort - SqliteOrchestratorRepository → SqliteCouncilRepository - OrchestratorApprovalRegistry → CouncilApprovalRegistry - ORCHESTRATOR_EVENT_CHANNEL_CAPACITY → COUNCIL_EVENT_CHANNEL_CAPACITY - orchestratorRunId → councilRunId (message metadata) - isOrchestratorMode → isCouncilMode (routing flag) - useOrchestrator → useCouncil (hook) - OrchestratorRegistryProvider → CouncilRegistryProvider - OrchestratorToggle → CouncilToggle - CLI: `gglib orchestrate` → `gglib council` - Virtual model: gglib-orchestrator → gglib-council - Routes: /api/orchestrator/* → /api/council/* DB tables preserved (historical names): - orchestrator_runs — historical name, kept for schema compatibility - orchestrator_events — historical name, kept for schema compatibility
… import paths - mv src/pages/Orchestrator → src/pages/Council (6 page components) - mv OrchestratorToggle.tsx → CouncilToggle.tsx - Fix types/orchestrator → types/council across all consumers - Fix ../Council/components/* → ../../pages/Council/components/* (path depth) - Fix HistoricalCouncilThread named→default import in MessageBubbles - Fix CouncilThread import path in pages/Council/index.tsx - Remove stale SerializableCouncilSession import (purged in Phase 4+5) - Remove dead imports in ChatMessagesPanel (useCouncil, stale CouncilThread)
Backend (Phases 1-4): - gglib-core: add DebateAgent, DebateConfig, TaskNodeKind::Debate, 15 Debate* SSE events - gglib-agent: implement full debate engine in council/debate/ (11 files) - agents, judge, synthesis, round manager, streaming turns, stance tracking - gglib-agent: wire run_debate_worker into executor dispatch - gglib-agent: update director schema + prompt for debate node kind - gglib-cli: extend council handler for debate streams - gglib-proxy: route all 15 Debate* event variants (previously suppressed) Frontend (Phases 7-8): - types/council.ts: add DebateAgent, DebateConfig, AgentStance, StanceOutcome, extend TaskNodeKind, add 15 debate SSE event interfaces - types/graph-diff.ts: add SetDebateConfigOp (apply + describe + union) - CouncilContext.tsx: add DebateNodeState hierarchy, 15 DEBATE_* actions + reducer - useCouncil.ts + useCouncilRunStream.ts: map all 15 debate events to actions - DebateRosterEditor.tsx: PlanEditor right-pane for debate nodes (agent roster 2-4, name/perspective/persona/contentiousness, rounds, judge toggle) - PlanEditor.tsx: branch right-pane on node kind (debate vs leaf) - DebateNodeBody.tsx: live debate stream renderer (round blocks, agent colour streams, judge assessment, stance map, synthesis) - NodePanel.tsx: render DebateNodeBody when debateState present
- Extract src/utils/councilEventToAction.ts — single source of truth mapping CouncilEvent → OrchestratorAction, covering all 15 debate events that were missing from HistoricalCouncilThread.tsx (fixing broken historical replay of debate runs) - Update hooks/useCouncil/useCouncil.ts to import from shared util - Update components/Council/Thread/useCouncilRunStream.ts to import from shared util (removes the three-copy duplication) - Update components/Council/Thread/HistoricalCouncilThread.tsx to import from shared util; remove misplaced OrchestratorAction import and stale 'Phase 7 deduplication' comment - Add crates/gglib-agent/tests/unit_debate_node.rs with 3 tests: happy-path event sequence, 1-based round numbers, pre-cancel path
# Conflicts: # Cargo.lock # Cargo.toml # crates/gglib-cli/src/handlers/council/mod.rs # crates/gglib-core/src/lib.rs # package.json # src-tauri/tauri.conf.json
The full orchestrator DAG experience (CompactRunCard, CollapsibleDagView,
NodePanel, HitlApprovalModal, synthesis) already renders inline inside
the chat thread via CouncilThread. The top-bar GitBranch button and the
standalone #council full-page route were redundant.
- Remove onOpenOrchestrator prop + button from Header (desktop + mobile)
- Remove showCouncilPage state, hashchange effect, and #council route
branch from App.tsx; always render ModelControlCenterPage
- Delete pages/Council/index.tsx (full-page orchestrator)
- Delete pages/Council/components/{RunsList,WaveScrubber}.tsx (full-page-only)
- Delete hooks/useCouncil/ (only consumed by the full page)
Entry point is now solely the Network icon toggle next to the chat
composer (CouncilToggle), consistent with the original council UX.
- Add presentation/dag.rs with render_tree(), render_mermaid(), topological_order(), and node_color() — shared by both the plan and council commands - Node-id labels in the tree and all [node_id] streaming prefixes now use a stable per-node ANSI colour derived from the node-id string, so the same node is always the same colour throughout a run - plan.rs delegates to the shared helpers (no behaviour change) - council.rs renders the DAG tree on PlanProposed (stderr) and applies node colours to all 19 worker/debate event variants
- Replace flat `Council { goal, model, … }` variant with
`Council { #[command(subcommand)] cmd: CouncilCmd }`
- Add CouncilCmd enum: Run, List, Show, Resume, Rewind (Phase 5 stub)
- Split handlers/council.rs into handlers/council/:
mod.rs — shared init_session / resolve_port / stop_server / parse_hitl_mode
render.rs — render_event + prompt_and_resolve (pub(crate))
run.rs — council run "<goal>"
resume.rs — council resume <run-id>
list.rs — council list [--status] (new)
show.rs — council show <run-id> (new: metadata + DAG tree + timeline)
rewind.rs — council rewind stub (Phase 5)
- Update dispatch.rs to route CouncilCmd variants
- cargo check + clippy: zero warnings
- Extract prompt_and_resolve into handlers/council/approve.rs
ApproveOpts { timeout_secs, timeout_action }
TimeoutAction { Reject, Approve } + parse_timeout_action()
- Add --approval-timeout <SECS> and --approval-timeout-action <ACTION>
(default: reject) to CouncilCmd::Run and CouncilCmd::Resume
- Implement [e]dit option for Plan gates:
writes graph to $TMPDIR/gglib-plan-<pid>.json
opens $EDITOR (fallback: nano → vim → vi)
parses the saved file → ApprovalDecision::ApproveWithEdits
- Async-correct stdin: use tokio::io::stdin() + AsyncBufReadExt::read_line
wrapped in tokio::time::timeout — NOT spawn_blocking which would block
the executor and silently ignore the timeout
- Update render_event signature: add last_graph + opts params
PlanProposed arm now caches the graph for [e]dit availability
AwaitingApproval arm routes to approve::prompt_and_resolve
- cargo check + clippy: zero warnings
- Add --json (bool) to CouncilCmd::Run and CouncilCmd::Resume in commands.rs; defaults to false - Thread json_mode: bool through dispatch.rs → run::execute / resume::execute → render_event - Guard: if --json is set and --hitl is anything other than "none", bail! with "--json output requires --hitl none" before any I/O - In render_event: when json_mode == true, serialise the CouncilEvent to a JSON line via serde_json::to_string and println! it to stdout, then return immediately — all ASCII art, colors, DAG trees, and interactive prompts are suppressed - Serialisation errors go to stderr (eprintln!) so stdout stays clean JSONL - CouncilEvent already derives Serialize with serde tag="type" / rename_all="snake_case", so no domain changes needed - cargo check + clippy: zero warnings
Part A: Live Steering
- Add presentation/input.rs: spawn_input_router(NoteQueue) starts a
background tokio task that reads stdin lines via tokio::io::stdin()
+ AsyncBufReadExt::lines(); routes /note <text> to NoteQueue,
all other lines to an mpsc::UnboundedReceiver<String>
- Export pub mod input from presentation/mod.rs
- approve.rs: replace direct tokio::io::stdin reads with
mpsc::UnboundedReceiver<String>; prompt_and_resolve,
read_line_with_timeout, read_line_async all accept input_rx as a
&mut reference — tokio::time::timeout wraps recv() (correct; no
executor-blocking risk)
- render.rs: add input_rx: &mut mpsc::UnboundedReceiver<String> to
render_event; thread it into approve::prompt_and_resolve;
improve SteeringApplied rendering (JSON-serialise diff via
serde_json::to_string instead of {:?})
- run.rs + resume.rs: create NoteQueue, spawn_input_router, wire
note_queue into CouncilConfig and input_rx into render_event
Part B: Rewind
- rewind.rs: full implementation (replacing Phase 5 stub)
1. Load run + guard against Running/AwaitingApproval status
2. List events; identify node_ids completed after target wave
3. Reset those nodes to Pending (clear output + error)
4. truncate_events_after_wave on the repository
5. update_graph with the reset graph
6. Pre-seed NoteQueue with optional --note argument
7. spawn_input_router for live steering during re-execution
8. Re-execute engine with graph_override, rewind_to_wave,
run_id, note_queue from the original run's hitl_mode
- cargo check + clippy: zero warnings
…tion
README.md (crates/gglib-cli):
- Add 'Orchestrator (council)' section with full command examples:
council run, list, show, resume, rewind
--hitl modes table (none/plan/node/tools)
approval prompt options (y/n/e/timeout)
Live steering (/note) explanation
--json JSONL output usage + jq example
- Update Commands table: replace stale 'chat council' rows with the
five orchestrator subcommands
- Remove the stale 'Council Command' subsection (debate REPL editor
commands, tools filter expressions, refine command) that predated
the DAG orchestrator
handlers/council/mod.rs:
- Expand module-level doc comment into a full architecture reference:
subcommand → file table
shared helpers table
ASCII data-flow diagram (stdin router → NoteQueue/mpsc → approve)
commands.rs:
- Remove '(Phase 5)' from Rewind variant description
cargo fmt: all formatting normalised across council handler files
- Hoist status_color() from list.rs and show.rs into mod.rs shared helpers (pub(crate)); was byte-for-byte identical in both files - Remove now-unused CouncilRunStatus import from show.rs - Demote topological_order() in dag.rs from pub to private; it is only ever called internally by render_tree() in the same module
…tput Replace the per-token '[node-id]<think> delta' pattern in the NodeReasoningDelta handler with a single collapsible-style header: [node-id] (thinking…) followed by the reasoning content streamed in dim text (no re-prefix). NodeTextDelta closes the thinking block with a blank line before the first output token. State is tracked in a HashSet<String> (thinking_nodes) threaded through render_event into all three callers: run, resume, rewind.
Co-authored-by: mmogr <172192206+mmogr@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Task Graph Executor — Orchestrator Epic
Closes #496
This PR merges the complete Orchestrator Epic into
main. It introduces a local-first Director/Worker DAG execution engine that lets gglib decompose a high-level user goal into a structured task graph, execute each node in parallel with strict resource scoping, checkpoint state to SQLite for resumability, and surface the entire lifecycle through a native Tauri/React UI with optional human-in-the-loop approval gates.All 6 phases were developed on individual feature branches, reviewed, and squash-merged into
epic/orchestrator. Each phase individually passed strict Clippy (-D warnings),cargo fmt, boundary checks, and its own integration test suite before merging.Phase A — Core Types & Planning (
feat/orchestrator-phase-a) #490Defines the foundational domain model that all later phases build on.
TaskGraph/TaskNodewith dependency edges, tool allowlists, and worker concurrency capsOrchestratorEventdiscriminated union (SSE wire format) covering every lifecycle signal: planning, node lifecycle, tool calls, synthesis, HITL approval requests, and completionPlanningClienttrait +LlamaPlanningClientimplementation calling the LLM to produce a structured plan from a natural-language goalHitlModeenum:None | ApprovePlan | ApproveEachNode | ApproveToolsPhase B — Graph Execution Engine (
feat/orchestrator-phase-b) #491The core DAG executor that turns a
TaskGraphinto a live SSE stream.max_worker_concurrencyWorkerAgentexecuting its goal within a strict tool allowlist; cannot call tools or access context outside its node scopeOrchestratorEvents over anmpscchannel — every state change (start, text delta, tool call, compaction, completion, failure) is observable in real timeOrchestratorErrorhierarchy covering planning, execution, and persistence failuresPhase C — SQLite Persistence & Resumability (
feat/orchestrator-phase-c) #492Full checkpoint/resume support backed by
gglib-db.OrchestratorRunpersisted to SQLite: run ID, status, goal, serialized graph snapshot, partial node outputs, timestampsRunRepositorywithcreate,update_status,save_node_output,list,getoperationsresume_run(run_id)replays already-completed node outputs as synthetic events, then continues live execution from the first incomplete nodePOST /api/orchestrator/run,GET /api/orchestrator/runs,GET /api/orchestrator/runs/:id,POST /api/orchestrator/runs/:id/resumePhase D — Human-in-the-Loop Approval Gates (
feat/orchestrator-phase-d) #493Blocking approval checkpoints at configurable granularity.
ApprovalGatemiddleware intercepts execution at plan, node, or tool level depending onHitlModeApprovalStoremaps live approval requests tooneshotchannels; the executor suspends and holds the SSE stream open while waitingPOST /api/orchestrator/approve/:idaccepts{ decision: approve | approve_with_edits | reject, edited_graph?, reason? }approve_with_editsreplaces the live graph mid-run;rejectterminates the run with a user-facing error messageawaiting_approvalin SQLite while suspended; fully resumable after approvalPhase E — Virtual Proxy Routing & OpenWebUI Integration (
feat/orchestrator-phase-e) #494Transparent integration with the existing proxy layer and OpenWebUI.
gglib-orchestratorregistered in the proxy routing table/v1/chat/completionsrequests addressed togglib-orchestratorare intercepted and routed to the orchestrator engine rather than a llama.cpp backendPhase F — Native Tauri/React UI (
feat/orchestrator-phase-f) #495Full native frontend for the orchestrator, following all existing UI conventions.
OrchestratorContext— reducer over the fullOrchestratorEventstream; state includes phase, per-node states, synthesis text, pending approval, and runs listuseOrchestratorhook — bridges SSE → context dispatch, managesAbortControllerlifecycle, exposesrun / resume / cancel / approve / loadRunsDagView— topologically-sorted indented tree ofTaskGraphnodes with live status badges; no new heavy dependenciesNodePanel— collapsible per-node panel: goal, tool allowlist, streaming text, tool call log with timing, output preview, error displayHitlApprovalModal— plan/node/tool approval modals with plan JSON viewer, optionalapprove_with_editsflow with JSON validation, and reject-with-reason pathRunsList— resumable runs entry with status filter tabs and click-to-resume#orchestratorhash route wired inApp.tsx; HTTP transport only — noisTauriAppbranching, no new#[tauri::command]Phase G — Hierarchical Subgraphs, Role Catalog & Team Events (
feat/orchestrator-phase-g) #508Foundation for multi-tier agent hierarchies.
TaskNodeKindenum:Leaf(default) |Team { subgraph: Box<TaskGraph> }— serdesnake_case,#[serde(default)]for backward compat with Phase F runsRoleId/RoleSpec/RoleCatalog— 7 built-in specialist roles (researcher,red-team,fact-checker,writer,editor,critic,synthesizer) with system prompt fragments, default tool allowlists, and temperature hintsTeamStarted { team_id, role }/TeamSynthesized { team_id, compacted_output }SSE eventsvalidate_acyclic()recurses intoTeamsubgraphs;total_node_count()aggregates across depthPhase H — Hierarchical Planner (Chief of Staff + Department Directors) (
feat/orchestrator-phase-h) #506Two-tier planning layer that decomposes a goal into departments before assigning leaf tasks.
chief_of_staff::brief()— single structured-output LLM call returning ≤5DepartmentBriefstructs; validates, deduplicates, and filters roles to catalog entries; graceful fallback on failureplanner::plan()— new canonical entry point; fans out onedirector::planper department in parallel viaJoinSet; assembles results into nestedTaskGraph(Team nodes + synthesizer leaf)director::plan()accepts optionalDepartmentBriefto inject context and round-robin assign roles to leaf nodesPhase I — Executor Recursion &
spawn_subteamDynamic Interrupt (feat/orchestrator-phase-i) #504Workers can dynamically request child sub-teams at runtime.
spawn_subteamtool definition registered in worker tool allowlist;SpawnCapturingExecutorintercepts calls into a per-worker sinkApprovalKind::SpawnSubteam),plan()call,SubteamSpawnedevent, recursiverun_wave_loopmax_team_depthguard (default: 9) prevents unbounded recursion;MaxDepthExceedederror variantPhase J — NodeBudget & warn-only RunCostEstimate (
feat/orchestrator-phase-j) #509Soft advisory budget and token-cost estimator — execution always proceeds.
NodeBudgetenum (Solo/SmallTeam/TaskForce/Department/Custom) withconst upper_bound()RunCostEstimate { node_count, est_tokens, est_wall_seconds }SSE event emitted afterPlanProposed;tracing::warn!fires only when plan exceeds budget — approve is never disabledestimator.rs— pure cost module, 2000 tokens/node at 50 tps, two doc-testscostEstimatesession field inOrchestratorContext; yellow advisory banner inHitlApprovalModaland Orchestrator page whenestWallSeconds > 60or node count > 80% of budgetPhase K — Conversational Steering & GraphDiff Preview (
feat/orchestrator-phase-k) #507Side-channel note injection during live runs and LLM-driven graph mutation with visual preview.
GraphDiffenum (7 variants:AddNode,RemoveNode,SplitNode,RerouteEdge,SetRole,SetTools,WrapInTeam) withapply_diffclone-and-swap rollbackNoteQueue—Arc<Mutex<VecDeque<String>>>injected per run; wave-drain loop applies queued steering notes before each wavePOST /api/orchestrator/steer— accepts{ graph, instruction }, returns{ diff: GraphDiff }via LLM structured outputPOST /api/orchestrator/runs/{run_id}/note— appends instruction to in-flight run queue;202 Acceptedor404SteeringPanelcomponent — instruction textarea, diff preview with colour-codedDiffBadge(green/red/amber), Apply / Discard;applyDiffpure helper also used inHitlApprovalModalapply_difftests (7 happy-path + 2 error); 10 Vitest component testsPhase L — Casting Sheet, Collapsible Team Canvas & NodePanel Quick Actions (
feat/orchestrator-phase-l) #505Frontend polish; no backend changes.
CastingSheet— actor-card grid; recursivecollectLeafNodes()traversal throughTeamsubgraphs; 7-role icon map withUserfallback; tool/dependency chips; full keyboard/ARIA accessibilityDagViewrewrite — team nodes as collapsible group headers witharia-expanded;sessionStoragepersistence of expanded-team set keyed byrunIdNodePanelquick actions — 4 context-aware buttons (Add critic, Split into 3 parallel, Wrap in team, Re-run with feedback); fires/notewhenrunIdpresent,/steerwith inline diff preview otherwisearia-pressedOrchestratorPlanPreview.tsx(legacy Phase C page) deletedCastingSheet,DagViewTeams,NodePanelQuickActions; 651 frontend tests passingPhase M — Time-Travel Rewind to a Previous Wave (
feat/orchestrator-phase-m) #510Rewind a completed run to an earlier wave checkpoint and re-execute from there.
wave_index;WaveCompleted { wave_index, node_count }SSE event emitted at the end of each waveOrchestratorRepositoryPort::truncate_events_after_wave— new port method; SQLite implementation deletes events withwave_index > target;wave_index INTEGER NOT NULL DEFAULT 0column with silent additive migrationOrchestratorConfig::rewind_to_wave: Option<u32>— executor resumes wave numbering fromrewind_to_wave + 1POST /api/orchestrator/runs/{id}/rewind— validates run is not active (409 ifRunning/AwaitingApproval), truncates DB, re-executes via SSE streamWaveScrubbercomponent — horizontal waypoint row (W0, W1, …) from the event log; confirmationalertdialogbefore rewind; hidden when fewer than 2 waves exist;disabledprop blocks interaction during active runrewindOrchestratorRun()client helper;useOrchestratorexposesrewind(runId, waveIndex, steeringNote?)DEFAULT 0handles legacy rows)GUI Cleanup & CLI Orchestrator Refactor (post-merge polish)
After all sub-PRs were merged into
epic/orchestrator, two final workstreams cleaned up loose ends and added a first-class CLI surface for the engine.GUI — Remove redundant top-bar button (
430ddc3)The top-bar "Orchestrator" button became redundant once the DAG renders inline inside the chat thread (Phase K/L). Removed
OrchestratorButtonfromHeaderand the standalone nav route; the in-chatCouncilEventRendereris now the single rendering surface.CLI — 6-phase orchestrator refactor (
9720ef1–d94a5fe)Built a first-class
councilcommand family ingglib-clithat fully surfaces the DAG executor from the terminal:9720ef1presentation/dag.rs— shared DAG tree renderer;node_color(status)stable ANSI colours on everyPlanProposedeventdeb9dc6CouncilCmdenum (Run,List,Show,Resume,Rewind); splitcouncil.rsintohandlers/council/directory module (one file per subcommand + sharedapprove.rs/render.rs)880ae3a--approval-timeout <SECS>+--approval-timeout-action <reject|approve>;[e]ditplan gate opens JSON in$EDITOR; async stdin viatokio::io::stdin()+AsyncBufReadExte52db27--jsonflag — everyCouncilEventserialised as one JSONL line on stdout; all ANSI/progress output suppressed from stdout; requires--hitl none7808241presentation/input.rsbackground stdin router:/note <text>→NoteQueue; other lines →mpsc::UnboundedReceiverfor HITL prompts; fullrewind.rs(truncate events, reset nodes, pre-seed note queue, re-execute)d94a5fecouncil)" section; remove stale debate-REPL docs);mod.rsarchitecture doc;commands.rsdoc cleanup;cargo fmtQuality gates (all phases)
cargo clippy --workspace --all-targets --all-features -- -D warnings✅cargo fmt --check✅./scripts/check_boundaries.sh✅cargo test --workspace✅npm run test:run(606 tests) ✅