gglib_core/domain/council/task_graph.rs
1//! Task graph types for the Director/Worker orchestrator.
2//!
3//! A [`TaskGraph`] is a directed acyclic graph (DAG) of [`TaskNode`]s that the
4//! orchestrator executor drives to completion in topological order. Nodes with
5//! no unsatisfied dependencies run concurrently; each node executes as an
6//! isolated `AgentLoop` worker with its own tool allowlist and context window.
7//!
8//! # Validation
9//!
10//! Call [`TaskGraph::new`] (preferred) or [`TaskGraph::validate_acyclic`]
11//! (on a manually-constructed graph) before handing a graph to the executor.
12//! Both methods check:
13//!
14//! - All `depends_on` ids resolve to existing nodes.
15//! - The dependency edges form a true DAG (no cycles, no self-loops).
16//! - Node count ≤ [`MAX_NODES`].
17//! - Longest path depth ≤ [`MAX_DEPTH`].
18//!
19//! # Example
20//!
21//! ```rust
22//! use std::collections::HashSet;
23//! use gglib_core::domain::council::task_graph::{
24//! TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode,
25//! };
26//!
27//! let nodes = vec![
28//! TaskNode {
29//! id: NodeId("research".into()),
30//! goal: "Research the topic".into(),
31//! depends_on: vec![],
32//! tool_allowlist: vec!["web_search".into()],
33//! kind: TaskNodeKind::Leaf,
34//! role: None,
35//! status: NodeStatus::Pending,
36//! output: None,
37//! compacted_output: None,
38//! error: None,
39//! },
40//! TaskNode {
41//! id: NodeId("draft".into()),
42//! goal: "Write the first draft".into(),
43//! depends_on: vec![NodeId("research".into())],
44//! tool_allowlist: vec![],
45//! kind: TaskNodeKind::Leaf,
46//! role: None,
47//! status: NodeStatus::Pending,
48//! output: None,
49//! compacted_output: None,
50//! error: None,
51//! },
52//! ];
53//! let graph = TaskGraph::new("Write a research doc".into(), HitlMode::None, nodes).unwrap();
54//! let ready = graph.ready_nodes(&HashSet::new());
55//! assert_eq!(ready.len(), 1);
56//! assert_eq!(ready[0], &NodeId("research".into()));
57//! ```
58
59use std::collections::{HashMap, HashSet};
60
61use serde::{Deserialize, Serialize};
62use thiserror::Error;
63
64use crate::domain::agent::ToolDefinition;
65use crate::domain::council::role_catalog::RoleId;
66
67// =============================================================================
68// Size limits
69// =============================================================================
70
71/// Maximum number of nodes a [`TaskGraph`] may contain **per subgraph**.
72///
73/// This limit applies independently to each nested subgraph (including the
74/// top-level graph and every [`TaskNodeKind::Team`] subgraph). It does **not**
75/// constrain the aggregate node count across all subgraphs; see
76/// [`MAX_TOTAL_NODES`] for the soft aggregate budget (Phase J).
77///
78/// Keeping each subgraph small ensures that no single LLM planning call ever
79/// needs to reason about more than ~8 sibling nodes.
80pub const MAX_NODES: usize = 8;
81
82/// Maximum depth (longest root-to-leaf path) a [`TaskGraph`] may have **per subgraph**.
83///
84/// Applied independently inside each subgraph. Keeps total latency bounded
85/// even when nodes are forced to run serially within a department.
86pub const MAX_DEPTH: usize = 3;
87
88/// Soft upper bound on the **total** node count across all subgraphs combined.
89///
90/// This constant is not enforced during validation in Phase G. Starting from
91/// Phase J the executor consults `CouncilConfig::max_total_nodes` (which
92/// defaults to this value) and emits a warn-only cost-estimate event when the
93/// aggregate budget is exceeded.
94pub const MAX_TOTAL_NODES: usize = 32;
95
96// =============================================================================
97// NodeId
98// =============================================================================
99
100/// Opaque node identifier within a [`TaskGraph`].
101///
102/// Short, human-readable strings are recommended (e.g. `"research"`, `"draft"`,
103/// `"review"`). Uniqueness within a graph is enforced by [`TaskGraph::new`].
104#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
105pub struct NodeId(pub String);
106
107impl std::fmt::Display for NodeId {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.write_str(&self.0)
110 }
111}
112
113// =============================================================================
114// NodeStatus
115// =============================================================================
116
117/// Lifecycle state of a single [`TaskNode`].
118///
119/// State transitions:
120/// ```text
121/// Pending → AwaitingApproval → Running → Compacting → Done
122/// ↘ Failed
123/// Pending → Skipped (upstream failure)
124/// ```
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum NodeStatus {
128 /// Not yet eligible to run (one or more predecessors are incomplete).
129 Pending,
130 /// All predecessors are done but human approval is required before
131 /// execution begins ([`HitlMode::ApproveEachNode`] or higher).
132 AwaitingApproval,
133 /// Currently executing (the worker `AgentLoop` is running).
134 Running,
135 /// Execution finished; the output is being compacted for downstream use.
136 Compacting,
137 /// Execution and compaction finished successfully.
138 Done,
139 /// Execution failed with an unrecoverable error.
140 Failed,
141 /// Skipped because an upstream node failed and no path to this node is
142 /// viable.
143 Skipped,
144}
145
146// =============================================================================
147// HitlMode
148// =============================================================================
149
150/// Controls when the orchestrator executor pauses to request human approval.
151///
152/// Variants are ordered from least to most restrictive; each variant implies
153/// all approvals required by lower variants.
154#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
155#[serde(rename_all = "snake_case")]
156pub enum HitlMode {
157 /// Execute the graph without any human-in-the-loop gates.
158 #[default]
159 None,
160 /// Pause once after the plan is produced so the user can review (and
161 /// optionally edit) the full [`TaskGraph`] before execution begins.
162 ApprovePlan,
163 /// Pause before each node executes (implies `ApprovePlan`).
164 ///
165 /// Use this when the goal is sensitive and each step must be vetted
166 /// individually.
167 ApproveEachNode,
168 /// Pause before each individual tool call a worker makes
169 /// (implies `ApproveEachNode`).
170 ///
171 /// This is the most restrictive mode; it is primarily useful for
172 /// debugging or for high-stakes automation contexts.
173 ApproveTools,
174}
175
176// =============================================================================
177// NodeBudget
178// =============================================================================
179
180/// Advisory upper bound on the aggregate node count across all subgraphs.
181///
182/// The Phase J cost estimator checks the total node count against
183/// `upper_bound()` and emits a [`tracing::warn!`] when exceeded, but never
184/// returns an error — the executor always proceeds.
185///
186/// # Default
187///
188/// [`NodeBudget::TaskForce`] (up to 25 nodes).
189///
190/// # Example
191///
192/// ```rust
193/// use gglib_core::domain::council::task_graph::NodeBudget;
194///
195/// let budget = NodeBudget::TaskForce;
196/// assert_eq!(budget.upper_bound(), 25);
197///
198/// let custom = NodeBudget::Custom { value: 100 };
199/// assert_eq!(custom.upper_bound(), 100);
200/// ```
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
202#[serde(tag = "kind", rename_all = "snake_case")]
203pub enum NodeBudget {
204 /// Up to 3 nodes — single-agent equivalent.
205 Solo,
206 /// Up to 8 nodes — small coordination team.
207 SmallTeam,
208 /// Up to 25 nodes — cross-functional task force. **Default**.
209 #[default]
210 TaskForce,
211 /// Up to 60 nodes — full department-scale run.
212 Department,
213 /// Caller-specified upper bound.
214 Custom {
215 /// The maximum number of aggregate nodes allowed before a warning is
216 /// emitted.
217 value: usize,
218 },
219}
220
221impl NodeBudget {
222 /// Returns the advisory upper bound for this budget variant.
223 pub const fn upper_bound(&self) -> usize {
224 match self {
225 Self::Solo => 3,
226 Self::SmallTeam => 8,
227 Self::TaskForce => 25,
228 Self::Department => 60,
229 Self::Custom { value } => *value,
230 }
231 }
232}
233
234// =============================================================================
235// DebateConfig (Phase N)
236// =============================================================================
237
238/// An individual participant in a [`TaskNodeKind::Debate`] node.
239///
240/// Each agent runs in every debate round as an independent LLM call with its
241/// own `persona` and `perspective` injected into the system prompt.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct DebateAgent {
244 /// Short unique id within this debate config (e.g. `"optimist"`).
245 pub id: String,
246 /// Display name shown in the frontend stream and logs.
247 pub name: String,
248 /// Hex colour code (`#rrggbb`) used for this agent's text in the UI.
249 pub color: String,
250 /// System-prompt fragment that establishes the agent's character.
251 pub persona: String,
252 /// The specific position or viewpoint the agent is instructed to argue.
253 pub perspective: String,
254 /// Float in `[0.0, 1.0]`. Mapped to LLM temperature: `0.0` = highly
255 /// focused / convergent, `1.0` = exploratory / contentious.
256 pub contentiousness: f32,
257 /// Optional tool-call filter expression. If `None`, inherits the parent
258 /// node's `tool_allowlist`.
259 #[serde(skip_serializing_if = "Option::is_none", default)]
260 pub tool_filter: Option<String>,
261}
262
263/// Configuration for the optional judge that assesses each debate round.
264///
265/// The judge is a separate LLM call that reads the full round transcript and
266/// decides whether consensus has been reached or whether the debate should
267/// continue for more rounds. When no judge is configured all [`DebateConfig::rounds`]
268/// are always executed.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct DebateJudgeConfig {
271 /// The judge will not attempt early stopping before this round index
272 /// (1-based). `1` means the judge may recommend a stop after the very
273 /// first round. Defaults to `1`.
274 #[serde(default = "default_min_rounds_before_stop")]
275 pub min_rounds_before_stop: u32,
276}
277
278const fn default_min_rounds_before_stop() -> u32 {
279 1
280}
281
282/// Complete configuration for a [`TaskNodeKind::Debate`] node.
283///
284/// Embedded directly in the node, analogous to [`TaskNodeKind::Team`]'s
285/// `subgraph` field. The Director populates this when it chooses the
286/// `debate` kind; the user may edit it in the [`PlanEditor`] before approval.
287///
288/// [`PlanEditor`]: https://github.com/mmogr/gglib/tree/epic/orchestrator/src/components/Council/PlanEditor
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct DebateConfig {
291 /// The agents that participate in each round. The Director caps this at
292 /// 4; the frontend plan editor enforces ≤ 4 via UI constraints.
293 pub agents: Vec<DebateAgent>,
294 /// Number of rounds to run (minimum 1). The Director caps this at 3;
295 /// the frontend plan editor allows up to 5.
296 pub rounds: u32,
297 /// Optional judge configuration. When `None` no early-stop check is
298 /// performed and all `rounds` are always executed.
299 #[serde(skip_serializing_if = "Option::is_none", default)]
300 pub judge: Option<DebateJudgeConfig>,
301 /// Optional additional instruction passed to the synthesis LLM after all
302 /// rounds complete. Useful for domain-specific framing of the verdict.
303 #[serde(skip_serializing_if = "Option::is_none", default)]
304 pub synthesis_guidance: Option<String>,
305}
306
307// =============================================================================
308// TaskNodeKind
309// =============================================================================
310
311/// Execution kind for a [`TaskNode`]: leaf worker, nested sub-team, or multi-agent debate.
312///
313/// A leaf runs a single agent; a team executes its own nested [`TaskGraph`];
314/// a debate node runs multiple agents arguing a contested goal across rounds.
315///
316/// Existing (Phase A–F) nodes all default to `Leaf` when deserialized from
317/// JSON that does not carry a `kind` field.
318///
319/// # Example
320///
321/// ```rust
322/// use gglib_core::domain::council::task_graph::TaskNodeKind;
323///
324/// // Missing `kind` in old JSON → defaults to `Leaf`.
325/// let kind: TaskNodeKind = serde_json::from_str("\"leaf\"").unwrap();
326/// assert!(matches!(kind, TaskNodeKind::Leaf));
327/// ```
328#[derive(Debug, Clone, Serialize, Deserialize, Default)]
329#[serde(rename_all = "snake_case")]
330pub enum TaskNodeKind {
331 /// A standard single-worker node. This is the default for all v1 nodes
332 /// and for any node deserialized from JSON that omits the `kind` field.
333 #[default]
334 Leaf,
335 /// A compound node that encapsulates a nested [`TaskGraph`] (a sub-team).
336 ///
337 /// The executor recurses into `subgraph` and runs it to completion before
338 /// marking this node done and passing its synthesised output downstream.
339 Team {
340 /// The nested task graph executed when this node runs.
341 subgraph: Box<TaskGraph>,
342 },
343 /// A debate node where multiple agents argue a contested goal across
344 /// multiple rounds, with an optional judge and final synthesis.
345 ///
346 /// The executor runs the debate engine to completion before marking this
347 /// node done. The synthesis output becomes the node's `output` and is
348 /// compacted for downstream predecessors exactly like a leaf output.
349 Debate {
350 /// Full debate configuration (participants, rounds, judge).
351 config: DebateConfig,
352 },
353}
354
355// =============================================================================
356// TaskNode
357// =============================================================================
358
359/// A single work unit in a [`TaskGraph`].
360///
361/// Each node is either a [`TaskNodeKind::Leaf`] (executed as an isolated
362/// `AgentLoop` worker) or a [`TaskNodeKind::Team`] (a sub-team with its own
363/// nested [`TaskGraph`] that the executor recurses into).
364///
365/// Leaf node context is assembled from:
366///
367/// 1. Its own `goal` as the system instruction.
368/// 2. Compacted outputs from each `depends_on` predecessor as additional
369/// context messages.
370/// 3. No orchestrator planning history (strict isolation between nodes).
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct TaskNode {
373 /// Short, unique identifier for this node within its graph.
374 pub id: NodeId,
375 /// One-sentence goal that the worker agent is asked to achieve.
376 pub goal: String,
377 /// Nodes whose outputs this node depends on.
378 ///
379 /// Must form a DAG when combined with all other nodes' `depends_on` lists.
380 pub depends_on: Vec<NodeId>,
381 /// Tool names the worker is permitted to call.
382 ///
383 /// An empty list means no tools are available to this worker. Names must
384 /// match entries in the runtime tool catalog.
385 pub tool_allowlist: Vec<String>,
386 /// Whether this node is a leaf worker or a sub-team.
387 ///
388 /// Defaults to [`TaskNodeKind::Leaf`] when absent in serialised JSON so
389 /// that all Phase A–F plans deserialise without modification.
390 #[serde(default)]
391 pub kind: TaskNodeKind,
392 /// Optional specialist role assigned to this node.
393 ///
394 /// When `Some`, the executor will inject the role's
395 /// `system_prompt_fragment` before the node's `goal`. `None` means the
396 /// node runs as a generic worker.
397 #[serde(skip_serializing_if = "Option::is_none", default)]
398 pub role: Option<RoleId>,
399 /// Current lifecycle state (mutated by the executor as the node runs).
400 pub status: NodeStatus,
401 /// The worker's full output text (set after reaching [`NodeStatus::Done`]).
402 #[serde(skip_serializing_if = "Option::is_none")]
403 pub output: Option<String>,
404 /// Compressed summary of `output` passed to downstream nodes as context.
405 ///
406 /// Set by the compaction step immediately after execution finishes.
407 #[serde(skip_serializing_if = "Option::is_none")]
408 pub compacted_output: Option<String>,
409 /// Error message if the node reached [`NodeStatus::Failed`].
410 #[serde(skip_serializing_if = "Option::is_none")]
411 pub error: Option<String>,
412}
413
414// =============================================================================
415// TaskGraphError
416// =============================================================================
417
418/// Error variants produced during [`TaskGraph`] construction or validation.
419#[derive(Debug, Clone, PartialEq, Eq, Error)]
420pub enum TaskGraphError {
421 /// Two nodes share the same [`NodeId`].
422 #[error("duplicate node id: {0}")]
423 DuplicateNodeId(String),
424
425 /// A `depends_on` entry references a [`NodeId`] not present in the graph.
426 #[error("node '{node}' depends on unknown id '{dep}'")]
427 UnknownDependency {
428 /// The node that declared the bad dependency.
429 node: String,
430 /// The referenced id that does not exist.
431 dep: String,
432 },
433
434 /// The dependency edges contain at least one cycle.
435 #[error("cycle detected involving node '{0}'")]
436 Cycle(String),
437
438 /// The graph exceeds [`MAX_NODES`].
439 #[error("graph has {count} nodes; maximum is {max}")]
440 TooManyNodes {
441 /// Actual node count.
442 count: usize,
443 /// The limit ([`MAX_NODES`]).
444 max: usize,
445 },
446
447 /// The longest root-to-leaf path exceeds [`MAX_DEPTH`].
448 #[error("graph depth {depth} exceeds maximum {max}")]
449 DepthExceeded {
450 /// Computed depth.
451 depth: usize,
452 /// The limit ([`MAX_DEPTH`]).
453 max: usize,
454 },
455
456 /// A node lists a tool not present in the provided catalog.
457 #[error("node '{node}' requires unknown tool '{tool}'")]
458 UnknownTool {
459 /// The node with the invalid allowlist entry.
460 node: String,
461 /// The tool name that was not found in the catalog.
462 tool: String,
463 },
464
465 /// A referenced node id was not found in the graph.
466 #[error("node not found: {0}")]
467 NodeNotFound(String),
468
469 /// The diff is structurally invalid (e.g. would leave an empty graph).
470 #[error("invalid diff: {0}")]
471 DiffInvalid(String),
472}
473
474// =============================================================================
475// GraphDiff
476// =============================================================================
477
478/// A single mutation to apply to a [`TaskGraph`] at runtime.
479///
480/// Produced by the steering LLM (`gglib_agent::council::steering`) and
481/// applied via [`TaskGraph::apply_diff`]. Each variant mutates the graph and
482/// then triggers re-validation to ensure the graph stays well-formed.
483///
484/// The `#[non_exhaustive]` attribute allows new variants to be added in future
485/// minor versions without breaking match arms in external crates.
486#[derive(Debug, Clone, Serialize, Deserialize)]
487#[serde(tag = "op", rename_all = "snake_case")]
488#[non_exhaustive]
489pub enum GraphDiff {
490 /// Add a new node.
491 ///
492 /// The node's `depends_on` carries all its incoming edges.
493 AddNode {
494 /// Node to insert.
495 node: TaskNode,
496 },
497
498 /// Remove a node and strip all edges that pointed to it.
499 ///
500 /// Any node whose `depends_on` contains `id` will have that entry removed.
501 RemoveNode {
502 /// Id of the node to remove.
503 id: NodeId,
504 },
505
506 /// Replace one node with multiple nodes.
507 ///
508 /// The original is removed; each entry in `into` is inserted. Any node
509 /// that depended on the original will be updated to depend on all
510 /// replacements.
511 SplitNode {
512 /// Id of the node to replace.
513 id: NodeId,
514 /// Replacement nodes (at least one entry required).
515 into: Vec<TaskNode>,
516 },
517
518 /// Redirect one dependency edge.
519 ///
520 /// In `node_id`'s `depends_on` list, replaces `old_dep` with `new_dep`.
521 RerouteEdge {
522 /// Node whose dependency is being changed.
523 node_id: NodeId,
524 /// Dependency to remove.
525 old_dep: NodeId,
526 /// Dependency to add in its place.
527 new_dep: NodeId,
528 },
529
530 /// Set or clear a node's specialist role.
531 SetRole {
532 /// Target node id.
533 id: NodeId,
534 /// New role (`None` clears the current role).
535 role: Option<RoleId>,
536 },
537
538 /// Replace a node's entire tool allowlist.
539 SetTools {
540 /// Target node id.
541 id: NodeId,
542 /// New tool allowlist (replaces the current list entirely).
543 tool_allowlist: Vec<String>,
544 },
545
546 /// Wrap a set of nodes into a new [`TaskNodeKind::Team`] node.
547 ///
548 /// The wrapped nodes become the team's subgraph. The `team_id` node
549 /// inherits all external in-edges (from nodes NOT in `ids`) and any
550 /// node outside `ids` that depended on a wrapped node will be updated
551 /// to depend on `team_id` instead.
552 WrapInTeam {
553 /// Ids of the nodes to wrap (all must exist in the graph).
554 ids: Vec<NodeId>,
555 /// Unique id for the new `Team` wrapper node.
556 team_id: NodeId,
557 /// High-level goal text for the new `Team` node.
558 team_goal: String,
559 },
560}
561
562// =============================================================================
563// Internal DFS helpers
564// =============================================================================
565
566const WHITE: u8 = 0; // not yet visited
567const GRAY: u8 = 1; // currently on the DFS stack
568const BLACK: u8 = 2; // fully processed
569
570fn dfs_check_cycle<'a>(
571 id: &'a NodeId,
572 nodes: &'a HashMap<NodeId, TaskNode>,
573 color: &mut HashMap<&'a NodeId, u8>,
574) -> Result<(), TaskGraphError> {
575 color.insert(id, GRAY);
576 let node = &nodes[id];
577 for dep in &node.depends_on {
578 let dep_color = color.get(dep).copied().unwrap_or(WHITE);
579 match dep_color {
580 GRAY => return Err(TaskGraphError::Cycle(dep.0.clone())),
581 BLACK => {} // already fully explored
582 _ => dfs_check_cycle(dep, nodes, color)?,
583 }
584 }
585 color.insert(id, BLACK);
586 Ok(())
587}
588
589fn compute_depth(
590 id: &NodeId,
591 nodes: &HashMap<NodeId, TaskNode>,
592 memo: &mut HashMap<NodeId, usize>,
593) -> usize {
594 if let Some(&d) = memo.get(id) {
595 return d;
596 }
597 let depth = nodes[id]
598 .depends_on
599 .iter()
600 .map(|dep| compute_depth(dep, nodes, memo) + 1)
601 .max()
602 .unwrap_or(0);
603 memo.insert(id.clone(), depth);
604 depth
605}
606
607// =============================================================================
608// TaskGraph
609// =============================================================================
610
611/// A validated directed acyclic graph of [`TaskNode`]s.
612///
613/// Produced by the director agent via [`crate::domain::council::events::CouncilEvent::PlanProposed`]
614/// and executed by the orchestrator runner in topological order.
615///
616/// # Construction
617///
618/// Prefer [`TaskGraph::new`] over direct struct construction to get automatic
619/// validation. Deserializing from a director's JSON response should be
620/// followed by [`TaskGraph::validate_acyclic`] to re-check invariants.
621///
622/// # Concurrency model
623///
624/// The executor calls [`TaskGraph::ready_nodes`] after each node completes to
625/// discover newly-eligible nodes, which it launches concurrently.
626///
627/// # Example
628///
629/// ```rust
630/// use gglib_core::domain::council::task_graph::{TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode};
631///
632/// let nodes = vec![TaskNode {
633/// id: NodeId("only".into()),
634/// goal: "Do the thing".into(),
635/// depends_on: vec![],
636/// tool_allowlist: vec![],
637/// kind: TaskNodeKind::Leaf,
638/// role: None,
639/// status: NodeStatus::Pending,
640/// output: None,
641/// compacted_output: None,
642/// error: None,
643/// }];
644/// let g = TaskGraph::new("My goal".into(), HitlMode::None, nodes).unwrap();
645/// assert_eq!(g.roots().len(), 1);
646/// ```
647#[derive(Debug, Clone, Serialize, Deserialize)]
648pub struct TaskGraph {
649 /// The high-level goal the director is trying to achieve.
650 pub goal: String,
651 /// Human-in-the-loop approval policy for this execution run.
652 pub hitl_mode: HitlMode,
653 /// All work units, keyed by their unique [`NodeId`].
654 pub nodes: HashMap<NodeId, TaskNode>,
655}
656
657impl TaskGraph {
658 /// Construct and validate a [`TaskGraph`] from a flat list of nodes.
659 ///
660 /// Returns an error if the node list violates any structural invariant
661 /// (duplicate ids, unknown dependencies, cycles, size/depth limits).
662 ///
663 /// # Example
664 ///
665 /// ```rust
666 /// use gglib_core::domain::council::task_graph::{
667 /// TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode,
668 /// };
669 ///
670 /// let nodes = vec![TaskNode {
671 /// id: NodeId("only".into()),
672 /// goal: "Do the thing".into(),
673 /// depends_on: vec![],
674 /// tool_allowlist: vec![],
675 /// kind: TaskNodeKind::Leaf,
676 /// role: None,
677 /// status: NodeStatus::Pending,
678 /// output: None,
679 /// compacted_output: None,
680 /// error: None,
681 /// }];
682 /// let g = TaskGraph::new("Top goal".into(), HitlMode::None, nodes);
683 /// assert!(g.is_ok());
684 /// ```
685 pub fn new(
686 goal: String,
687 hitl_mode: HitlMode,
688 nodes: Vec<TaskNode>,
689 ) -> Result<Self, TaskGraphError> {
690 if nodes.len() > MAX_NODES {
691 return Err(TaskGraphError::TooManyNodes {
692 count: nodes.len(),
693 max: MAX_NODES,
694 });
695 }
696
697 let mut map: HashMap<NodeId, TaskNode> = HashMap::with_capacity(nodes.len());
698 for node in nodes {
699 if map.contains_key(&node.id) {
700 return Err(TaskGraphError::DuplicateNodeId(node.id.0));
701 }
702 map.insert(node.id.clone(), node);
703 }
704
705 let graph = Self {
706 goal,
707 hitl_mode,
708 nodes: map,
709 };
710 graph.validate_acyclic()?;
711 Ok(graph)
712 }
713
714 /// Validate that the dependency graph contains no cycles.
715 ///
716 /// Uses DFS colouring (white → gray → black). Also validates that all
717 /// `depends_on` ids resolve to existing nodes and that depth ≤ [`MAX_DEPTH`].
718 ///
719 /// # Errors
720 ///
721 /// Returns the first structural violation found.
722 ///
723 /// # Example — detecting a cycle
724 ///
725 /// ```rust
726 /// use std::collections::HashMap;
727 /// use gglib_core::domain::council::task_graph::{
728 /// TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode, TaskGraphError,
729 /// };
730 ///
731 /// let mut nodes = HashMap::new();
732 /// for (id, dep) in [("a", "b"), ("b", "a")] {
733 /// nodes.insert(NodeId(id.into()), TaskNode {
734 /// id: NodeId(id.into()),
735 /// goal: id.into(),
736 /// depends_on: vec![NodeId(dep.into())],
737 /// tool_allowlist: vec![],
738 /// kind: TaskNodeKind::Leaf,
739 /// role: None,
740 /// status: NodeStatus::Pending,
741 /// output: None, compacted_output: None, error: None,
742 /// });
743 /// }
744 /// let g = TaskGraph { goal: "cyclic".into(), hitl_mode: HitlMode::None, nodes };
745 /// assert!(matches!(g.validate_acyclic(), Err(TaskGraphError::Cycle(_))));
746 /// ```
747 pub fn validate_acyclic(&self) -> Result<(), TaskGraphError> {
748 // Check all depends_on ids resolve.
749 for (id, node) in &self.nodes {
750 for dep in &node.depends_on {
751 if !self.nodes.contains_key(dep) {
752 return Err(TaskGraphError::UnknownDependency {
753 node: id.0.clone(),
754 dep: dep.0.clone(),
755 });
756 }
757 }
758 }
759
760 // DFS cycle detection.
761 let mut color: HashMap<&NodeId, u8> = HashMap::new();
762 for id in self.nodes.keys() {
763 if color.get(id).copied().unwrap_or(WHITE) == WHITE {
764 dfs_check_cycle(id, &self.nodes, &mut color)?;
765 }
766 }
767
768 // Depth check.
769 let mut memo: HashMap<NodeId, usize> = HashMap::new();
770 for id in self.nodes.keys() {
771 let depth = compute_depth(id, &self.nodes, &mut memo);
772 if depth > MAX_DEPTH {
773 return Err(TaskGraphError::DepthExceeded {
774 depth,
775 max: MAX_DEPTH,
776 });
777 }
778 }
779
780 // Recurse into Team subgraphs. Per-subgraph caps (MAX_NODES, MAX_DEPTH)
781 // are enforced independently inside each subgraph by the recursive call.
782 // Cycles spanning a team boundary surface here as errors from the
783 // nested validate_acyclic call with the offending node id reported.
784 for node in self.nodes.values() {
785 if let TaskNodeKind::Team { subgraph } = &node.kind {
786 subgraph.validate_acyclic()?;
787 }
788 }
789
790 Ok(())
791 }
792
793 /// Validate that every tool name in every node's `tool_allowlist` exists
794 /// in `catalog`.
795 ///
796 /// Call this after [`TaskGraph::validate_acyclic`] when the runtime tool
797 /// catalog is available.
798 ///
799 /// # Example
800 ///
801 /// ```rust
802 /// use gglib_core::domain::council::task_graph::{
803 /// TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode, TaskGraphError,
804 /// };
805 /// use gglib_core::ToolDefinition;
806 ///
807 /// let catalog = vec![ToolDefinition {
808 /// name: "web_search".into(),
809 /// description: None,
810 /// input_schema: None,
811 /// title: None,
812 /// }];
813 /// let nodes = vec![TaskNode {
814 /// id: NodeId("r".into()),
815 /// goal: "research".into(),
816 /// depends_on: vec![],
817 /// tool_allowlist: vec!["nonexistent".into()],
818 /// kind: TaskNodeKind::Leaf,
819 /// role: None,
820 /// status: NodeStatus::Pending,
821 /// output: None, compacted_output: None, error: None,
822 /// }];
823 /// let g = TaskGraph::new("goal".into(), HitlMode::None, nodes).unwrap();
824 /// assert!(matches!(
825 /// g.validate_tool_allowlist(&catalog),
826 /// Err(TaskGraphError::UnknownTool { .. })
827 /// ));
828 /// ```
829 pub fn validate_tool_allowlist(
830 &self,
831 catalog: &[ToolDefinition],
832 ) -> Result<(), TaskGraphError> {
833 // Build two lookup sets: one for exact qualified names, one for bare
834 // names (stripping any "{server-id}:" or "builtin:" prefix). This
835 // lets the Director emit bare names like "browser_navigate" in
836 // tool_allowlist entries while the catalog contains qualified names
837 // like "2:browser_navigate".
838 let known_exact: HashSet<&str> = catalog.iter().map(|t| t.name.as_str()).collect();
839 let known_bare: HashSet<&str> = catalog
840 .iter()
841 .map(|t| {
842 t.name
843 .find(':')
844 .map_or(t.name.as_str(), |pos| &t.name[pos + 1..])
845 })
846 .collect();
847 for (id, node) in &self.nodes {
848 for tool in &node.tool_allowlist {
849 if !known_exact.contains(tool.as_str()) && !known_bare.contains(tool.as_str()) {
850 return Err(TaskGraphError::UnknownTool {
851 node: id.0.clone(),
852 tool: tool.clone(),
853 });
854 }
855 }
856 }
857 Ok(())
858 }
859
860 /// Return the ids of root nodes — those with an empty `depends_on` list.
861 ///
862 /// # Example
863 ///
864 /// ```rust
865 /// use gglib_core::domain::council::task_graph::{
866 /// TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode,
867 /// };
868 ///
869 /// let nodes = vec![
870 /// TaskNode { id: NodeId("root".into()), goal: "g".into(), depends_on: vec![],
871 /// tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
872 /// status: NodeStatus::Pending,
873 /// output: None, compacted_output: None, error: None },
874 /// TaskNode { id: NodeId("child".into()), goal: "g".into(),
875 /// depends_on: vec![NodeId("root".into())],
876 /// tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
877 /// status: NodeStatus::Pending,
878 /// output: None, compacted_output: None, error: None },
879 /// ];
880 /// let g = TaskGraph::new("goal".into(), HitlMode::None, nodes).unwrap();
881 /// assert_eq!(g.roots().len(), 1);
882 /// assert_eq!(g.roots()[0], &NodeId("root".into()));
883 /// ```
884 pub fn roots(&self) -> Vec<&NodeId> {
885 let mut roots: Vec<&NodeId> = self
886 .nodes
887 .keys()
888 .filter(|id| self.nodes[*id].depends_on.is_empty())
889 .collect();
890 // Sort for deterministic ordering.
891 roots.sort_by(|a, b| a.0.cmp(&b.0));
892 roots
893 }
894
895 /// Return the ids of nodes eligible to run given the set of
896 /// already-completed nodes.
897 ///
898 /// A node is eligible when:
899 /// - It is **not** in `completed`.
900 /// - All of its `depends_on` predecessors **are** in `completed`.
901 ///
902 /// The returned slice is sorted by node id for deterministic ordering.
903 ///
904 /// # Example
905 ///
906 /// ```rust
907 /// use std::collections::HashSet;
908 /// use gglib_core::domain::council::task_graph::{
909 /// TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode,
910 /// };
911 ///
912 /// let nodes = vec![
913 /// TaskNode { id: NodeId("a".into()), goal: "g".into(), depends_on: vec![],
914 /// tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
915 /// status: NodeStatus::Pending,
916 /// output: None, compacted_output: None, error: None },
917 /// TaskNode { id: NodeId("b".into()), goal: "g".into(),
918 /// depends_on: vec![NodeId("a".into())],
919 /// tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
920 /// status: NodeStatus::Pending,
921 /// output: None, compacted_output: None, error: None },
922 /// ];
923 /// let g = TaskGraph::new("goal".into(), HitlMode::None, nodes).unwrap();
924 ///
925 /// // Nothing completed — only the root is ready.
926 /// let ready = g.ready_nodes(&HashSet::new());
927 /// assert_eq!(ready.len(), 1);
928 /// assert_eq!(ready[0], &NodeId("a".into()));
929 ///
930 /// // "a" completed — "b" is now ready.
931 /// let done: HashSet<NodeId> = [NodeId("a".into())].into();
932 /// let ready = g.ready_nodes(&done);
933 /// assert_eq!(ready.len(), 1);
934 /// assert_eq!(ready[0], &NodeId("b".into()));
935 /// ```
936 pub fn ready_nodes(&self, completed: &HashSet<NodeId>) -> Vec<&NodeId> {
937 let mut ready: Vec<&NodeId> = self
938 .nodes
939 .keys()
940 .filter(|id| {
941 !completed.contains(*id)
942 && self.nodes[*id]
943 .depends_on
944 .iter()
945 .all(|dep| completed.contains(dep))
946 })
947 .collect();
948 // Sort for deterministic ordering.
949 ready.sort_by(|a, b| a.0.cmp(&b.0));
950 ready
951 }
952
953 /// Return the total number of nodes across all subgraphs combined.
954 ///
955 /// Each [`TaskNodeKind::Leaf`] contributes 1. Each [`TaskNodeKind::Team`]
956 /// contributes 1 (for itself) plus the recursive count of its subgraph.
957 ///
958 /// This is the aggregate tally consulted by the warn-only token-cost
959 /// estimator introduced in Phase J. Validation never fails on this value
960 /// in Phase G; see [`MAX_TOTAL_NODES`] for the soft budget.
961 ///
962 /// # Example
963 ///
964 /// ```rust
965 /// use gglib_core::domain::council::task_graph::{
966 /// TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode,
967 /// };
968 ///
969 /// let nodes = vec![
970 /// TaskNode { id: NodeId("a".into()), goal: "g".into(), depends_on: vec![],
971 /// tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
972 /// status: NodeStatus::Pending,
973 /// output: None, compacted_output: None, error: None },
974 /// TaskNode { id: NodeId("b".into()), goal: "g".into(),
975 /// depends_on: vec![NodeId("a".into())],
976 /// tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
977 /// status: NodeStatus::Pending,
978 /// output: None, compacted_output: None, error: None },
979 /// ];
980 /// let g = TaskGraph::new("goal".into(), HitlMode::None, nodes).unwrap();
981 /// assert_eq!(g.total_node_count(), 2);
982 /// ```
983 pub fn total_node_count(&self) -> usize {
984 self.nodes
985 .values()
986 .map(|n| match &n.kind {
987 TaskNodeKind::Leaf | TaskNodeKind::Debate { .. } => 1,
988 TaskNodeKind::Team { subgraph } => 1 + subgraph.total_node_count(),
989 })
990 .sum()
991 }
992
993 /// Apply a [`GraphDiff`] mutation in-place, then re-validate.
994 ///
995 /// If either the mutation itself or the subsequent [`validate_acyclic`]
996 /// call fails, the graph is restored to its pre-call state (clone-and-swap
997 /// rollback) and the error is returned.
998 ///
999 /// # Errors
1000 ///
1001 /// Returns the first [`TaskGraphError`] encountered (pre-condition checks
1002 /// or post-mutation validation).
1003 ///
1004 /// # Example
1005 ///
1006 /// ```rust
1007 /// use gglib_core::domain::council::task_graph::{
1008 /// TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode,
1009 /// GraphDiff,
1010 /// };
1011 ///
1012 /// let nodes = vec![TaskNode {
1013 /// id: NodeId("a".into()), goal: "a".into(), depends_on: vec![],
1014 /// tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
1015 /// status: NodeStatus::Pending, output: None,
1016 /// compacted_output: None, error: None,
1017 /// }];
1018 /// let mut g = TaskGraph::new("goal".into(), HitlMode::None, nodes).unwrap();
1019 ///
1020 /// let new_node = TaskNode {
1021 /// id: NodeId("b".into()), goal: "b".into(),
1022 /// depends_on: vec![NodeId("a".into())],
1023 /// tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
1024 /// status: NodeStatus::Pending, output: None,
1025 /// compacted_output: None, error: None,
1026 /// };
1027 /// g.apply_diff(&GraphDiff::AddNode { node: new_node }).unwrap();
1028 /// assert_eq!(g.nodes.len(), 2);
1029 /// ```
1030 pub fn apply_diff(&mut self, diff: &GraphDiff) -> Result<(), TaskGraphError> {
1031 let saved = self.clone();
1032 match self.apply_diff_inner(diff) {
1033 Ok(()) => match self.validate_acyclic() {
1034 Ok(()) => Ok(()),
1035 Err(e) => {
1036 *self = saved;
1037 Err(e)
1038 }
1039 },
1040 Err(e) => {
1041 *self = saved;
1042 Err(e)
1043 }
1044 }
1045 }
1046
1047 #[allow(clippy::too_many_lines)]
1048 fn apply_diff_inner(&mut self, diff: &GraphDiff) -> Result<(), TaskGraphError> {
1049 match diff {
1050 GraphDiff::AddNode { node } => {
1051 if self.nodes.contains_key(&node.id) {
1052 return Err(TaskGraphError::DuplicateNodeId(node.id.0.clone()));
1053 }
1054 if self.nodes.len() >= MAX_NODES {
1055 return Err(TaskGraphError::TooManyNodes {
1056 count: self.nodes.len() + 1,
1057 max: MAX_NODES,
1058 });
1059 }
1060 self.nodes.insert(node.id.clone(), node.clone());
1061 }
1062
1063 GraphDiff::RemoveNode { id } => {
1064 if !self.nodes.contains_key(id) {
1065 return Err(TaskGraphError::NodeNotFound(id.0.clone()));
1066 }
1067 if self.nodes.len() == 1 {
1068 return Err(TaskGraphError::DiffInvalid(
1069 "cannot remove the only node in a graph".into(),
1070 ));
1071 }
1072 self.nodes.remove(id);
1073 for node in self.nodes.values_mut() {
1074 node.depends_on.retain(|dep| dep != id);
1075 }
1076 }
1077
1078 GraphDiff::SplitNode { id, into } => {
1079 if !self.nodes.contains_key(id) {
1080 return Err(TaskGraphError::NodeNotFound(id.0.clone()));
1081 }
1082 if into.is_empty() {
1083 return Err(TaskGraphError::DiffInvalid(
1084 "split_node: replacement list must not be empty".into(),
1085 ));
1086 }
1087 // Check for id conflicts among replacements (excluding the original id).
1088 for n in into {
1089 if &n.id != id && self.nodes.contains_key(&n.id) {
1090 return Err(TaskGraphError::DuplicateNodeId(n.id.0.clone()));
1091 }
1092 }
1093 let new_count = self.nodes.len() - 1 + into.len();
1094 if new_count > MAX_NODES {
1095 return Err(TaskGraphError::TooManyNodes {
1096 count: new_count,
1097 max: MAX_NODES,
1098 });
1099 }
1100 let replacement_ids: Vec<NodeId> = into.iter().map(|n| n.id.clone()).collect();
1101 self.nodes.remove(id);
1102 for n in into {
1103 self.nodes.insert(n.id.clone(), n.clone());
1104 }
1105 // Repoint any node that depended on the split node.
1106 for node in self.nodes.values_mut() {
1107 if node.depends_on.contains(id) {
1108 node.depends_on.retain(|dep| dep != id);
1109 for rid in &replacement_ids {
1110 if !node.depends_on.contains(rid) {
1111 node.depends_on.push(rid.clone());
1112 }
1113 }
1114 }
1115 }
1116 }
1117
1118 GraphDiff::RerouteEdge {
1119 node_id,
1120 old_dep,
1121 new_dep,
1122 } => {
1123 if !self.nodes.contains_key(node_id) {
1124 return Err(TaskGraphError::NodeNotFound(node_id.0.clone()));
1125 }
1126 if !self.nodes.contains_key(new_dep) {
1127 return Err(TaskGraphError::NodeNotFound(new_dep.0.clone()));
1128 }
1129 let node = self.nodes.get_mut(node_id).expect("checked above");
1130 let pos = node
1131 .depends_on
1132 .iter()
1133 .position(|d| d == old_dep)
1134 .ok_or_else(|| {
1135 TaskGraphError::DiffInvalid(format!(
1136 "node '{}' does not depend on '{}'",
1137 node_id.0, old_dep.0
1138 ))
1139 })?;
1140 node.depends_on[pos] = new_dep.clone();
1141 }
1142
1143 GraphDiff::SetRole { id, role } => {
1144 let node = self
1145 .nodes
1146 .get_mut(id)
1147 .ok_or_else(|| TaskGraphError::NodeNotFound(id.0.clone()))?;
1148 node.role.clone_from(role);
1149 }
1150
1151 GraphDiff::SetTools { id, tool_allowlist } => {
1152 let node = self
1153 .nodes
1154 .get_mut(id)
1155 .ok_or_else(|| TaskGraphError::NodeNotFound(id.0.clone()))?;
1156 node.tool_allowlist.clone_from(tool_allowlist);
1157 }
1158
1159 GraphDiff::WrapInTeam {
1160 ids,
1161 team_id,
1162 team_goal,
1163 } => {
1164 if ids.is_empty() {
1165 return Err(TaskGraphError::DiffInvalid(
1166 "wrap_in_team: ids list must not be empty".into(),
1167 ));
1168 }
1169 if self.nodes.contains_key(team_id) {
1170 return Err(TaskGraphError::DuplicateNodeId(team_id.0.clone()));
1171 }
1172 for id in ids {
1173 if !self.nodes.contains_key(id) {
1174 return Err(TaskGraphError::NodeNotFound(id.0.clone()));
1175 }
1176 }
1177
1178 let wrapped_set: HashSet<NodeId> = ids.iter().cloned().collect();
1179
1180 // Team node inherits all external deps of the wrapped nodes.
1181 let mut team_deps: Vec<NodeId> = Vec::new();
1182 for id in ids {
1183 for dep in &self.nodes[id].depends_on {
1184 if !wrapped_set.contains(dep) && !team_deps.contains(dep) {
1185 team_deps.push(dep.clone());
1186 }
1187 }
1188 }
1189
1190 // Extract wrapped nodes into the subgraph (stripping external deps).
1191 let mut sub_nodes: HashMap<NodeId, TaskNode> = HashMap::new();
1192 for id in ids {
1193 let mut node = self.nodes.remove(id).expect("checked above");
1194 node.depends_on.retain(|dep| wrapped_set.contains(dep));
1195 sub_nodes.insert(id.clone(), node);
1196 }
1197
1198 // Repoint external nodes that depended on any wrapped node.
1199 for node in self.nodes.values_mut() {
1200 let had_dep = node.depends_on.iter().any(|d| wrapped_set.contains(d));
1201 if had_dep {
1202 node.depends_on.retain(|d| !wrapped_set.contains(d));
1203 if !node.depends_on.contains(team_id) {
1204 node.depends_on.push(team_id.clone());
1205 }
1206 }
1207 }
1208
1209 // Size guard for the parent graph (after removals, before insertion).
1210 if self.nodes.len() >= MAX_NODES {
1211 return Err(TaskGraphError::TooManyNodes {
1212 count: self.nodes.len() + 1,
1213 max: MAX_NODES,
1214 });
1215 }
1216
1217 let subgraph = Self {
1218 goal: team_goal.clone(),
1219 hitl_mode: self.hitl_mode.clone(),
1220 nodes: sub_nodes,
1221 };
1222
1223 let team_node = TaskNode {
1224 id: team_id.clone(),
1225 goal: team_goal.clone(),
1226 depends_on: team_deps,
1227 tool_allowlist: vec![],
1228 kind: TaskNodeKind::Team {
1229 subgraph: Box::new(subgraph),
1230 },
1231 role: None,
1232 status: NodeStatus::Pending,
1233 output: None,
1234 compacted_output: None,
1235 error: None,
1236 };
1237 self.nodes.insert(team_id.clone(), team_node);
1238 }
1239 }
1240 Ok(())
1241 }
1242}
1243
1244// =============================================================================
1245// Unit tests
1246// =============================================================================
1247
1248#[cfg(test)]
1249mod tests {
1250 use std::collections::HashSet;
1251
1252 use super::*;
1253
1254 // ------------------------------------------------------------------
1255 // Helpers
1256 // ------------------------------------------------------------------
1257
1258 fn leaf(id: &str) -> TaskNode {
1259 TaskNode {
1260 id: NodeId(id.into()),
1261 goal: id.into(),
1262 depends_on: vec![],
1263 tool_allowlist: vec![],
1264 kind: TaskNodeKind::Leaf,
1265 role: None,
1266 status: NodeStatus::Pending,
1267 output: None,
1268 compacted_output: None,
1269 error: None,
1270 }
1271 }
1272
1273 fn node(id: &str, deps: &[&str]) -> TaskNode {
1274 TaskNode {
1275 id: NodeId(id.into()),
1276 goal: id.into(),
1277 depends_on: deps.iter().map(|d| NodeId((*d).to_string())).collect(),
1278 tool_allowlist: vec![],
1279 kind: TaskNodeKind::Leaf,
1280 role: None,
1281 status: NodeStatus::Pending,
1282 output: None,
1283 compacted_output: None,
1284 error: None,
1285 }
1286 }
1287
1288 fn ids(v: Vec<&NodeId>) -> Vec<&str> {
1289 v.into_iter().map(|id| id.0.as_str()).collect()
1290 }
1291
1292 // ------------------------------------------------------------------
1293 // validate_acyclic — valid graphs
1294 // ------------------------------------------------------------------
1295
1296 #[test]
1297 fn empty_graph_is_valid() {
1298 let g = TaskGraph {
1299 goal: "g".into(),
1300 hitl_mode: HitlMode::None,
1301 nodes: HashMap::new(),
1302 };
1303 assert!(g.validate_acyclic().is_ok());
1304 }
1305
1306 #[test]
1307 fn single_node_is_valid() {
1308 let g = TaskGraph::new("g".into(), HitlMode::None, vec![leaf("a")]).unwrap();
1309 assert!(g.validate_acyclic().is_ok());
1310 }
1311
1312 #[test]
1313 fn linear_chain_is_valid() {
1314 // a → b → c
1315 let g = TaskGraph::new(
1316 "g".into(),
1317 HitlMode::None,
1318 vec![leaf("a"), node("b", &["a"]), node("c", &["b"])],
1319 )
1320 .unwrap();
1321 assert!(g.validate_acyclic().is_ok());
1322 }
1323
1324 #[test]
1325 fn diamond_shape_is_valid() {
1326 // root → (left, right) → merge
1327 let g = TaskGraph::new(
1328 "g".into(),
1329 HitlMode::None,
1330 vec![
1331 leaf("root"),
1332 node("left", &["root"]),
1333 node("right", &["root"]),
1334 node("merge", &["left", "right"]),
1335 ],
1336 )
1337 .unwrap();
1338 assert!(g.validate_acyclic().is_ok());
1339 }
1340
1341 // ------------------------------------------------------------------
1342 // validate_acyclic — invalid graphs
1343 // ------------------------------------------------------------------
1344
1345 #[test]
1346 fn simple_cycle_is_rejected() {
1347 // a → b → a
1348 let nodes = vec![node("a", &["b"]), node("b", &["a"])];
1349 let g = TaskGraph {
1350 goal: "g".into(),
1351 hitl_mode: HitlMode::None,
1352 nodes: nodes.into_iter().map(|n| (n.id.clone(), n)).collect(),
1353 };
1354 assert!(matches!(
1355 g.validate_acyclic(),
1356 Err(TaskGraphError::Cycle(_))
1357 ));
1358 }
1359
1360 #[test]
1361 fn self_loop_is_rejected() {
1362 let nodes = vec![node("a", &["a"])];
1363 let g = TaskGraph {
1364 goal: "g".into(),
1365 hitl_mode: HitlMode::None,
1366 nodes: nodes.into_iter().map(|n| (n.id.clone(), n)).collect(),
1367 };
1368 assert!(matches!(
1369 g.validate_acyclic(),
1370 Err(TaskGraphError::Cycle(_))
1371 ));
1372 }
1373
1374 #[test]
1375 fn unknown_dependency_is_rejected() {
1376 let nodes = vec![node("a", &["nonexistent"])];
1377 let g = TaskGraph {
1378 goal: "g".into(),
1379 hitl_mode: HitlMode::None,
1380 nodes: nodes.into_iter().map(|n| (n.id.clone(), n)).collect(),
1381 };
1382 assert!(matches!(
1383 g.validate_acyclic(),
1384 Err(TaskGraphError::UnknownDependency { .. })
1385 ));
1386 }
1387
1388 #[test]
1389 fn too_many_nodes_is_rejected() {
1390 let nodes: Vec<TaskNode> = (0..=MAX_NODES).map(|i| leaf(&i.to_string())).collect();
1391 assert!(matches!(
1392 TaskGraph::new("g".into(), HitlMode::None, nodes),
1393 Err(TaskGraphError::TooManyNodes { .. })
1394 ));
1395 }
1396
1397 #[test]
1398 fn duplicate_node_id_is_rejected() {
1399 let nodes = vec![leaf("a"), leaf("a")];
1400 assert!(matches!(
1401 TaskGraph::new("g".into(), HitlMode::None, nodes),
1402 Err(TaskGraphError::DuplicateNodeId(_))
1403 ));
1404 }
1405
1406 #[test]
1407 fn depth_exceeded_is_rejected() {
1408 // Chain of MAX_DEPTH + 2 nodes creates a path of depth MAX_DEPTH + 1.
1409 let mut nodes = vec![leaf("0")];
1410 for i in 1..=(MAX_DEPTH + 1) {
1411 nodes.push(node(&i.to_string(), &[&(i - 1).to_string()]));
1412 }
1413 assert!(matches!(
1414 TaskGraph::new("g".into(), HitlMode::None, nodes),
1415 Err(TaskGraphError::DepthExceeded { .. })
1416 ));
1417 }
1418
1419 // ------------------------------------------------------------------
1420 // roots
1421 // ------------------------------------------------------------------
1422
1423 #[test]
1424 fn roots_returns_nodes_with_no_deps() {
1425 // Diamond: root → (left, right) → merge. Only root has no deps.
1426 let g = TaskGraph::new(
1427 "g".into(),
1428 HitlMode::None,
1429 vec![
1430 leaf("root"),
1431 node("left", &["root"]),
1432 node("right", &["root"]),
1433 node("merge", &["left", "right"]),
1434 ],
1435 )
1436 .unwrap();
1437 assert_eq!(ids(g.roots()), vec!["root"]);
1438 }
1439
1440 #[test]
1441 fn roots_returns_all_when_no_deps() {
1442 let g = TaskGraph::new("g".into(), HitlMode::None, vec![leaf("a"), leaf("b")]).unwrap();
1443 // Two independent nodes — both are roots (sorted).
1444 assert_eq!(ids(g.roots()), vec!["a", "b"]);
1445 }
1446
1447 // ------------------------------------------------------------------
1448 // ready_nodes
1449 // ------------------------------------------------------------------
1450
1451 #[test]
1452 fn ready_nodes_returns_roots_when_nothing_completed() {
1453 let g = TaskGraph::new(
1454 "g".into(),
1455 HitlMode::None,
1456 vec![leaf("a"), node("b", &["a"])],
1457 )
1458 .unwrap();
1459 let ready = ids(g.ready_nodes(&HashSet::new()));
1460 assert_eq!(ready, vec!["a"]);
1461 }
1462
1463 #[test]
1464 fn ready_nodes_unlocks_children_after_parent_completes() {
1465 // a → b → c
1466 let g = TaskGraph::new(
1467 "g".into(),
1468 HitlMode::None,
1469 vec![leaf("a"), node("b", &["a"]), node("c", &["b"])],
1470 )
1471 .unwrap();
1472
1473 let done: HashSet<NodeId> = [NodeId("a".into())].into();
1474 let ready = ids(g.ready_nodes(&done));
1475 assert_eq!(ready, vec!["b"]);
1476 }
1477
1478 #[test]
1479 fn ready_nodes_requires_all_deps_to_complete() {
1480 // a, b → merge
1481 let g = TaskGraph::new(
1482 "g".into(),
1483 HitlMode::None,
1484 vec![leaf("a"), leaf("b"), node("merge", &["a", "b"])],
1485 )
1486 .unwrap();
1487
1488 // Only "a" done — "merge" still blocked, "b" is newly ready.
1489 let done: HashSet<NodeId> = [NodeId("a".into())].into();
1490 let ready = ids(g.ready_nodes(&done));
1491 assert_eq!(ready, vec!["b"]);
1492
1493 // Both done — "merge" is ready.
1494 let done: HashSet<NodeId> = [NodeId("a".into()), NodeId("b".into())].into();
1495 let ready = ids(g.ready_nodes(&done));
1496 assert_eq!(ready, vec!["merge"]);
1497 }
1498
1499 #[test]
1500 fn ready_nodes_returns_empty_when_all_completed() {
1501 let g = TaskGraph::new(
1502 "g".into(),
1503 HitlMode::None,
1504 vec![leaf("a"), node("b", &["a"])],
1505 )
1506 .unwrap();
1507 let done: HashSet<NodeId> = [NodeId("a".into()), NodeId("b".into())].into();
1508 assert!(g.ready_nodes(&done).is_empty());
1509 }
1510
1511 // ------------------------------------------------------------------
1512 // validate_tool_allowlist
1513 // ------------------------------------------------------------------
1514
1515 #[test]
1516 fn validate_tool_allowlist_passes_when_all_tools_known() {
1517 let catalog = vec![ToolDefinition {
1518 name: "search".into(),
1519 description: None,
1520 input_schema: None,
1521 title: None,
1522 }];
1523 let mut n = leaf("a");
1524 n.tool_allowlist = vec!["search".into()];
1525 let g = TaskGraph::new("g".into(), HitlMode::None, vec![n]).unwrap();
1526 assert!(g.validate_tool_allowlist(&catalog).is_ok());
1527 }
1528
1529 #[test]
1530 fn validate_tool_allowlist_rejects_unknown_tool() {
1531 let catalog = vec![ToolDefinition {
1532 name: "search".into(),
1533 description: None,
1534 input_schema: None,
1535 title: None,
1536 }];
1537 let mut n = leaf("a");
1538 n.tool_allowlist = vec!["unknown_tool".into()];
1539 let g = TaskGraph::new("g".into(), HitlMode::None, vec![n]).unwrap();
1540 assert!(matches!(
1541 g.validate_tool_allowlist(&catalog),
1542 Err(TaskGraphError::UnknownTool { .. })
1543 ));
1544 }
1545
1546 // ------------------------------------------------------------------
1547 // total_node_count
1548 // ------------------------------------------------------------------
1549
1550 #[test]
1551 fn total_node_count_flat_graph() {
1552 let g = TaskGraph::new(
1553 "g".into(),
1554 HitlMode::None,
1555 vec![leaf("a"), leaf("b"), node("c", &["a", "b"])],
1556 )
1557 .unwrap();
1558 assert_eq!(g.total_node_count(), 3);
1559 }
1560
1561 #[test]
1562 fn total_node_count_with_team_subgraph() {
1563 // Build subgraph: researcher → writer (2 nodes).
1564 let subgraph = TaskGraph::new(
1565 "department".into(),
1566 HitlMode::None,
1567 vec![leaf("researcher"), node("writer", &["researcher"])],
1568 )
1569 .unwrap();
1570
1571 // Top-level: team_node + synthesizer (2 nodes at top level).
1572 // total = 2 (top) + 2 (subgraph) = 4.
1573 let team_node = TaskNode {
1574 id: NodeId("dept".into()),
1575 goal: "Run department".into(),
1576 depends_on: vec![],
1577 tool_allowlist: vec![],
1578 kind: TaskNodeKind::Team {
1579 subgraph: Box::new(subgraph),
1580 },
1581 role: None,
1582 status: NodeStatus::Pending,
1583 output: None,
1584 compacted_output: None,
1585 error: None,
1586 };
1587 let synth = leaf("synthesize");
1588 // Build with raw map to bypass node-count limits at this level.
1589 let mut nodes = HashMap::new();
1590 nodes.insert(team_node.id.clone(), team_node);
1591 nodes.insert(synth.id.clone(), synth);
1592 let g = TaskGraph {
1593 goal: "top".into(),
1594 hitl_mode: HitlMode::None,
1595 nodes,
1596 };
1597 assert_eq!(g.total_node_count(), 4);
1598 }
1599
1600 // ------------------------------------------------------------------
1601 // Team subgraph validation
1602 // ------------------------------------------------------------------
1603
1604 #[test]
1605 fn team_subgraph_cycle_is_detected() {
1606 // Build a subgraph with a cycle: x → y → x.
1607 let mut sub_nodes = HashMap::new();
1608 for (id, dep) in [("x", "y"), ("y", "x")] {
1609 sub_nodes.insert(
1610 NodeId(id.into()),
1611 TaskNode {
1612 id: NodeId(id.into()),
1613 goal: id.into(),
1614 depends_on: vec![NodeId(dep.into())],
1615 tool_allowlist: vec![],
1616 kind: TaskNodeKind::Leaf,
1617 role: None,
1618 status: NodeStatus::Pending,
1619 output: None,
1620 compacted_output: None,
1621 error: None,
1622 },
1623 );
1624 }
1625 let cyclic_subgraph = TaskGraph {
1626 goal: "sub".into(),
1627 hitl_mode: HitlMode::None,
1628 nodes: sub_nodes,
1629 };
1630
1631 // Wrap in a team node at the top level.
1632 let team_node = TaskNode {
1633 id: NodeId("team".into()),
1634 goal: "team".into(),
1635 depends_on: vec![],
1636 tool_allowlist: vec![],
1637 kind: TaskNodeKind::Team {
1638 subgraph: Box::new(cyclic_subgraph),
1639 },
1640 role: None,
1641 status: NodeStatus::Pending,
1642 output: None,
1643 compacted_output: None,
1644 error: None,
1645 };
1646 let mut top_nodes = HashMap::new();
1647 top_nodes.insert(team_node.id.clone(), team_node);
1648 let g = TaskGraph {
1649 goal: "top".into(),
1650 hitl_mode: HitlMode::None,
1651 nodes: top_nodes,
1652 };
1653 assert!(matches!(
1654 g.validate_acyclic(),
1655 Err(TaskGraphError::Cycle(_))
1656 ));
1657 }
1658
1659 #[test]
1660 fn team_subgraph_per_subgraph_node_cap_is_independent() {
1661 // A top-level graph with 8 nodes is valid even if a Team node's
1662 // subgraph also has 8 nodes (per-subgraph caps, not global).
1663 let sub_nodes: Vec<TaskNode> = (0..MAX_NODES).map(|i| leaf(&format!("sub_{i}"))).collect();
1664 let subgraph = TaskGraph::new("sub".into(), HitlMode::None, sub_nodes).unwrap();
1665 // Top-level has just 1 Team node — well under MAX_NODES.
1666 let team_node = TaskNode {
1667 id: NodeId("team".into()),
1668 goal: "run dept".into(),
1669 depends_on: vec![],
1670 tool_allowlist: vec![],
1671 kind: TaskNodeKind::Team {
1672 subgraph: Box::new(subgraph),
1673 },
1674 role: None,
1675 status: NodeStatus::Pending,
1676 output: None,
1677 compacted_output: None,
1678 error: None,
1679 };
1680 let result = TaskGraph::new("top".into(), HitlMode::None, vec![team_node]);
1681 assert!(result.is_ok(), "per-subgraph cap must not be global");
1682 }
1683
1684 // ------------------------------------------------------------------
1685 // Phase F regression fixture
1686 // ------------------------------------------------------------------
1687
1688 #[test]
1689 fn phase_f_fixture_deserializes_and_validates() {
1690 let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1691 .join("../../tests/fixtures/orchestrator/phase_f_baseline.json");
1692 let json = std::fs::read_to_string(&fixture_path)
1693 .unwrap_or_else(|e| panic!("could not read fixture {}: {e}", fixture_path.display()));
1694 let graph: TaskGraph =
1695 serde_json::from_str(&json).expect("fixture must deserialize without error");
1696 graph
1697 .validate_acyclic()
1698 .expect("fixture must pass validate_acyclic");
1699 // Flat Phase F plan — all nodes are leaves.
1700 assert!(
1701 graph
1702 .nodes
1703 .values()
1704 .all(|n| matches!(n.kind, TaskNodeKind::Leaf)),
1705 "Phase F fixture must contain only Leaf nodes"
1706 );
1707 // Aggregate count equals top-level count (no subgraphs).
1708 assert_eq!(graph.total_node_count(), graph.nodes.len());
1709 }
1710
1711 // ------------------------------------------------------------------
1712 // TaskNodeKind serde round-trip
1713 // ------------------------------------------------------------------
1714
1715 #[test]
1716 fn task_node_kind_leaf_is_default_when_absent() {
1717 // Old-format JSON node without a `kind` field must deserialise as Leaf.
1718 let json = r#"{
1719 "id": "x",
1720 "goal": "do something",
1721 "depends_on": [],
1722 "tool_allowlist": [],
1723 "status": "pending"
1724 }"#;
1725 let node: TaskNode = serde_json::from_str(json).unwrap();
1726 assert!(matches!(node.kind, TaskNodeKind::Leaf));
1727 assert!(node.role.is_none());
1728 }
1729}