Skip to main content

leviath_runtime/
fanout.rs

1//! Fan-out stage handling as ECS systems.
2//!
3//! A `fan_out` stage (see [`leviath_core::blueprint::StageMode::FanOut`]) runs
4//! its single inference as a **split** - its prompt (with the config's
5//! `split_prompt` folded in by [`crate::pipeline`]) asks the model for a JSON
6//! array of work items. [`fan_out_split`] intercepts that response (before the
7//! normal `process_response` routing), parses the items, and parks the parent in
8//! [`FanOutWaiting`]. [`fan_out_collect`] then starts one worker per item -
9//! bounded by `max_workers` concurrent workers - via the daemon-installed
10//! [`FanOutSpawner`], tracks them as the parent's `SubAgentChildren`, and once
11//! every worker is terminal applies the failure policy, injects a consolidated
12//! report into the parent's conversation, and transitions to the `merge_stage`
13//! (or falls through to the stage's normal transition).
14//!
15//! The runtime only **starts and tracks** workers; resolving *which* blueprint a
16//! worker runs (self-at-worker-stage, a named agent, or a capability query) is
17//! the CLI's job, encapsulated behind the [`FanOutSpawner`] it installs.
18
19use std::collections::VecDeque;
20use std::sync::Arc;
21
22use bevy_ecs::prelude::*;
23use leviath_core::blueprint::{FanOutConfig, StageMode, WorkerFailurePolicy};
24
25use crate::components::{
26    AgentState, AgentStatus, ContextWindow, InferenceResult, ParentRef, SubAgentChildren,
27};
28use crate::pipeline::{AgentBlueprint, ProcessResponse, ResolveTransition, StageCursor};
29
30/// Depth cap for fan-out workers when the parent's blueprint doesn't set one.
31const DEFAULT_FANOUT_DEPTH: usize = 3;
32
33/// One unit of work produced by a fan-out split.
34#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
35pub struct WorkItem {
36    /// Stable id (used to label the worker in the consolidated report).
37    #[serde(default)]
38    pub id: String,
39    /// Free-form context handed to the worker (seeded into its pinned context).
40    #[serde(default)]
41    pub context: serde_json::Value,
42}
43
44/// Parse a split response into work items. Tolerates markdown fences and prose by
45/// extracting the outermost `[ … ]`. (Ported from the deleted imperative engine.)
46#[expect(
47    clippy::string_slice,
48    reason = "`s` and `e` come from `find`/`rfind` on the ASCII '[' and ']', so both are char \
49              boundaries and the inclusive range ends on the last byte of ']'"
50)]
51pub fn parse_work_items(content: &str) -> Result<Vec<WorkItem>, String> {
52    let trimmed = content.trim();
53    let slice = match (trimmed.find('['), trimmed.rfind(']')) {
54        (Some(s), Some(e)) if e > s => &trimmed[s..=e],
55        _ => return Err("split output is not a JSON array".to_string()),
56    };
57    serde_json::from_str(slice)
58        .map_err(|e| format!("split output is not a valid JSON array of work items: {e}"))
59}
60
61/// Starts one worker for a fan-out work item. The implementor resolves the
62/// worker's blueprint (per `config`'s `worker_stage` / `worker_agent` /
63/// `worker_query`), spawns it into `world` seeded with the work item, and returns
64/// the child entity. Parent/child linking is done by [`fan_out_collect`], not the
65/// spawner.
66pub trait FanOutSpawner: Send + Sync {
67    /// Spawn one worker under `parent` for the given work item, or `Err` with a
68    /// human-readable reason (recorded as that item's failure).
69    fn spawn_worker(
70        &self,
71        world: &mut World,
72        parent: Entity,
73        config: &FanOutConfig,
74        item_id: &str,
75        item_context: &serde_json::Value,
76    ) -> Result<Entity, String>;
77}
78
79/// The installed [`FanOutSpawner`], as a world resource. Absent in a pure-runtime
80/// world (then every fan-out item fails with "no fan-out spawner installed").
81#[derive(Resource, Clone)]
82pub struct FanOutSpawnerRes(pub Arc<dyn FanOutSpawner>);
83
84/// A currently-running fan-out worker: its work-item id, its live entity, and
85/// its run-id (kept so the waiting state can be persisted/restored without a
86/// cross-entity lookup - see [`FanOutState`]).
87struct ActiveWorker {
88    item_id: String,
89    entity: Entity,
90    run_id: String,
91}
92
93/// A parent parked while its fan-out workers run. Holds the not-yet-started
94/// `pending` items, the currently-`active` workers, and the accumulated results.
95#[derive(Component)]
96pub struct FanOutWaiting {
97    config: FanOutConfig,
98    max_workers: usize,
99    pending: VecDeque<WorkItem>,
100    active: Vec<ActiveWorker>,
101    summaries: Vec<(String, String)>,
102    failures: Vec<(String, String)>,
103}
104
105/// The serializable form of [`FanOutWaiting`], written to `<run_dir>/fanout.json`
106/// so a parent interrupted mid-split resumes its merge after a restart. `active`
107/// carries worker **run-ids** (not entities); recovery maps them back to the
108/// reloaded worker entities.
109#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
110pub struct FanOutState {
111    /// The fan-out configuration.
112    pub config: FanOutConfig,
113    /// The concurrency cap.
114    pub max_workers: usize,
115    /// Work items not yet started.
116    pub pending: Vec<WorkItem>,
117    /// In-flight workers as `(item_id, run_id)`.
118    pub active: Vec<(String, String)>,
119    /// Completed worker results as `(item_id, summary)`.
120    pub summaries: Vec<(String, String)>,
121    /// Failed worker results as `(item_id, message)`.
122    pub failures: Vec<(String, String)>,
123}
124
125impl FanOutWaiting {
126    /// Workers this parent is still parked on: in-flight plus not-yet-started.
127    ///
128    /// Surfaced by `lev ps` so "waiting" on a fan-out parent reads as progress
129    /// against a known denominator rather than an unexplained stall.
130    pub fn outstanding(&self) -> usize {
131        self.active.len() + self.pending.len()
132    }
133
134    /// Project to the serializable [`FanOutState`] (workers by run-id).
135    pub(crate) fn to_state(&self) -> FanOutState {
136        FanOutState {
137            config: self.config.clone(),
138            max_workers: self.max_workers,
139            pending: self.pending.iter().cloned().collect(),
140            active: self
141                .active
142                .iter()
143                .map(|w| (w.item_id.clone(), w.run_id.clone()))
144                .collect(),
145            summaries: self.summaries.clone(),
146            failures: self.failures.clone(),
147        }
148    }
149}
150
151/// Rebuild a parent's [`FanOutWaiting`] from a persisted [`FanOutState`] and
152/// insert it, mapping each active worker's run-id back to its reloaded entity
153/// via `resolve`. Workers whose entity didn't reload are treated as failures so
154/// the merge still completes rather than waiting forever. Used by restart
155/// recovery to resume an interrupted fan-out.
156pub fn restore_fan_out_waiting(
157    world: &mut World,
158    parent: Entity,
159    state: FanOutState,
160    resolve: &dyn Fn(&str) -> Option<Entity>,
161) {
162    let mut active = Vec::new();
163    let mut failures = state.failures;
164    for (item_id, run_id) in state.active {
165        match resolve(&run_id) {
166            Some(entity) => active.push(ActiveWorker {
167                item_id,
168                entity,
169                run_id,
170            }),
171            None => failures.push((item_id, "worker did not reload after restart".to_string())),
172        }
173    }
174    world.entity_mut(parent).insert(FanOutWaiting {
175        config: state.config,
176        max_workers: state.max_workers,
177        pending: state.pending.into_iter().collect(),
178        active,
179        summaries: state.summaries,
180        failures,
181    });
182}
183
184/// Fan-out split system (exclusive): for each `ProcessResponse` agent whose
185/// current stage is a fan-out stage, consume its response as the split output -
186/// parse the work items and park the agent in [`FanOutWaiting`] (or mark it
187/// `Error` if the split output isn't a JSON array). Removing `ProcessResponse`
188/// here keeps the normal `process_response` routing from touching these agents.
189pub fn fan_out_split(world: &mut World) {
190    crate::tick_scope::clear();
191    let mut candidates: Vec<(Entity, String, FanOutConfig)> = Vec::new();
192    {
193        let mut q = world.query_filtered::<(
194            Entity,
195            &AgentState,
196            &AgentBlueprint,
197            &StageCursor,
198            &InferenceResult,
199        ), With<ProcessResponse>>();
200        for (entity, state, bp, cursor, infer) in q.iter(world) {
201            if state.status != AgentStatus::Active {
202                continue;
203            }
204            if let StageMode::FanOut { config } = &bp.0.stages[cursor.index].mode {
205                candidates.push((entity, infer.response.clone(), config.clone()));
206            }
207        }
208    }
209
210    for (parent, response, config) in candidates {
211        crate::tick_scope::enter(parent);
212        world
213            .entity_mut(parent)
214            .remove::<ProcessResponse>()
215            .remove::<InferenceResult>();
216        match parse_work_items(&response) {
217            Ok(items) => {
218                let max_workers = config.max_workers.max(1);
219                world.entity_mut(parent).insert(FanOutWaiting {
220                    config,
221                    max_workers,
222                    pending: items.into_iter().collect(),
223                    active: Vec::new(),
224                    summaries: Vec::new(),
225                    failures: Vec::new(),
226                });
227                set_status(world, parent, AgentStatus::Waiting);
228            }
229            Err(message) => {
230                set_status(
231                    world,
232                    parent,
233                    AgentStatus::Error {
234                        message: format!("fan_out split failed: {message}"),
235                    },
236                );
237            }
238        }
239    }
240}
241
242/// Fan-out collect system (exclusive): drive each [`FanOutWaiting`] parent - reap
243/// finished workers, start pending ones up to `max_workers`, and once none remain
244/// running apply the failure policy, inject the consolidated report, and
245/// transition to the merge stage (or resolve the stage's own transition).
246pub fn fan_out_collect(world: &mut World) {
247    crate::tick_scope::clear();
248    let parents: Vec<Entity> = {
249        let mut q = world.query_filtered::<Entity, With<FanOutWaiting>>();
250        q.iter(world).collect()
251    };
252
253    for parent in parents {
254        crate::tick_scope::enter(parent);
255        // A cancelled/errored parent abandons the fan-out; its workers are reaped
256        // by the host's cascade cancel (which walks SubAgentChildren).
257        if !matches!(agent_status(world, parent), Some(AgentStatus::Waiting)) {
258            world.entity_mut(parent).remove::<FanOutWaiting>();
259            continue;
260        }
261        // A `Waiting` parent from the query above still holds its `FanOutWaiting`
262        // (only this system removes it, and each entity appears once per pass).
263        let mut w = world
264            .entity_mut(parent)
265            .take::<FanOutWaiting>()
266            .expect("a Waiting fan-out parent still holds FanOutWaiting");
267
268        // 1. Reap workers that have reached a terminal state.
269        let mut still_active = Vec::with_capacity(w.active.len());
270        for aw in std::mem::take(&mut w.active) {
271            match worker_terminal_result(world, aw.entity) {
272                Some(Ok(content)) => w.summaries.push((aw.item_id, content)),
273                Some(Err(message)) => w.failures.push((aw.item_id, message)),
274                None => still_active.push(aw),
275            }
276        }
277        w.active = still_active;
278
279        // 2. Start pending workers up to the concurrency cap.
280        while w.active.len() < w.max_workers {
281            let Some(item) = w.pending.pop_front() else {
282                break;
283            };
284            match start_worker(world, parent, &w.config, &item) {
285                Ok(child) => {
286                    // Capture the worker's run-id so the waiting state persists.
287                    let run_id = world
288                        .get::<crate::persistence::RunMetadata>(child)
289                        .map(|m| m.run_id.clone())
290                        .unwrap_or_default();
291                    w.active.push(ActiveWorker {
292                        item_id: item.id,
293                        entity: child,
294                        run_id,
295                    });
296                }
297                Err(message) => w.failures.push((item.id, message)),
298            }
299        }
300
301        // 3. Finished when nothing is running or queued.
302        if w.active.is_empty() && w.pending.is_empty() {
303            finish_fan_out(world, parent, w);
304        } else {
305            world.entity_mut(parent).insert(w);
306        }
307    }
308}
309
310/// Apply the failure policy, inject the consolidated report, and transition.
311fn finish_fan_out(world: &mut World, parent: Entity, w: FanOutWaiting) {
312    if !w.failures.is_empty() && w.config.on_worker_failure == WorkerFailurePolicy::FailAll {
313        set_status(
314            world,
315            parent,
316            AgentStatus::Error {
317                message: format!(
318                    "fan_out: {} worker(s) failed (on_worker_failure = fail_all)",
319                    w.failures.len()
320                ),
321            },
322        );
323        return;
324    }
325
326    let report = build_report(&w.summaries, &w.failures);
327    inject_conversation(world, parent, &report);
328
329    // Ready the parent to run again, then jump to the merge stage (if any) or let
330    // the fan-out stage's own transition resolve.
331    set_status(world, parent, AgentStatus::Active);
332    match w.config.merge_stage.as_deref().and_then(|name| {
333        world
334            .get::<AgentBlueprint>(parent)
335            .and_then(|bp| bp.0.stages.iter().position(|s| s.name == name))
336    }) {
337        Some(idx) => crate::pipeline::force_transition(world, parent, idx),
338        None => {
339            world.entity_mut(parent).insert(ResolveTransition);
340        }
341    }
342}
343
344/// Start one worker and link it to `parent` (`ParentRef` + `SubAgentChildren`),
345/// enforcing the parent blueprint's child-depth cap. Returns the child entity.
346fn start_worker(
347    world: &mut World,
348    parent: Entity,
349    config: &FanOutConfig,
350    item: &WorkItem,
351) -> Result<Entity, String> {
352    let max_depth = world
353        .get::<SubAgentChildren>(parent)
354        .map(|k| k.max_child_depth)
355        .or_else(|| {
356            world
357                .get::<AgentBlueprint>(parent)
358                .and_then(|bp| bp.0.max_child_depth)
359        })
360        .unwrap_or(DEFAULT_FANOUT_DEPTH);
361    let parent_depth = world.get::<ParentRef>(parent).map_or(0, |p| p.depth);
362    let child_depth = parent_depth + 1;
363    if child_depth > max_depth {
364        return Err(format!(
365            "fan-out worker depth limit ({max_depth}) reached; not spawning"
366        ));
367    }
368
369    let spawner = world
370        .get_resource::<FanOutSpawnerRes>()
371        .map(|r| r.0.clone())
372        .ok_or_else(|| "no fan-out spawner installed".to_string())?;
373    let child = spawner.spawn_worker(world, parent, config, &item.id, &item.context)?;
374
375    let parent_agent_id = world
376        .get::<AgentState>(parent)
377        .map(|s| s.agent_id.clone())
378        .unwrap_or_default();
379    world.entity_mut(child).insert(ParentRef {
380        parent_entity: parent,
381        parent_agent_id,
382        depth: child_depth,
383    });
384    match world.get_mut::<SubAgentChildren>(parent) {
385        Some(mut kids) => kids.children.push(child),
386        None => {
387            world.entity_mut(parent).insert(SubAgentChildren {
388                children: vec![child],
389                max_child_depth: max_depth,
390            });
391        }
392    }
393    // Record the worker's run-id on the parent's serializable state so the tree
394    // (fan-out workers included) is persisted for a deterministic restart rebuild.
395    // A freshly spawned worker always has run metadata; its parent always has state.
396    let worker_id = world
397        .get::<crate::persistence::RunMetadata>(child)
398        .expect("a fan-out worker always has run metadata")
399        .run_id
400        .clone();
401    world
402        .get_mut::<AgentState>(parent)
403        .expect("a fan-out parent always has AgentState")
404        .spawned_children_ids
405        .push(worker_id);
406    // Seed the worker's context from the parent per any declared blueprint
407    // context transform (when a fan-out worker runs a different blueprint).
408    crate::context_transform::apply_context_transforms(world, parent, child);
409    Ok(child)
410}
411
412/// A worker's terminal result: `Some(Ok(final_text))` if complete,
413/// `Some(Err(reason))` if it errored/was cancelled/vanished, `None` if still
414/// running.
415fn worker_terminal_result(world: &World, worker: Entity) -> Option<Result<String, String>> {
416    match agent_status(world, worker) {
417        None => Some(Err("worker vanished".to_string())),
418        Some(AgentStatus::Complete) => {
419            let content = world
420                .get::<InferenceResult>(worker)
421                .map(|r| r.response.clone())
422                .unwrap_or_default();
423            Some(Ok(content))
424        }
425        Some(AgentStatus::Error { message }) => Some(Err(message)),
426        Some(AgentStatus::Cancelled) => Some(Err("worker cancelled".to_string())),
427        Some(_) => None,
428    }
429}
430
431/// Build the consolidated `[fan_out results: …]` report from worker outcomes.
432fn build_report(summaries: &[(String, String)], failures: &[(String, String)]) -> String {
433    let mut report = format!(
434        "[fan_out results: {} succeeded, {} failed]\n",
435        summaries.len(),
436        failures.len()
437    );
438    for (id, content) in summaries {
439        report.push_str(&format!("\n## worker {id}\n{content}\n"));
440    }
441    for (id, err) in failures {
442        report.push_str(&format!("\n## worker {id} FAILED\n{err}\n"));
443    }
444    report
445}
446
447/// Add `text` to the parent's `conversation` region (best-effort).
448fn inject_conversation(world: &mut World, parent: Entity, text: &str) {
449    if let Some(mut window) = world.get_mut::<ContextWindow>(parent) {
450        let tokens = leviath_core::estimate_tokens(text);
451        let _ = window.add_typed_entry(
452            "conversation",
453            leviath_core::EntryKind::UserMessage,
454            text.to_string(),
455            tokens,
456        );
457    }
458}
459
460/// An agent's status, if it still exists.
461fn agent_status(world: &World, entity: Entity) -> Option<AgentStatus> {
462    world.get::<AgentState>(entity).map(|s| s.status.clone())
463}
464
465/// Set an agent's status (no-op if it despawned).
466fn set_status(world: &mut World, entity: Entity, status: AgentStatus) {
467    if let Some(mut state) = world.get_mut::<AgentState>(entity) {
468        state.status = status;
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::components::{InferenceConfig, ToolResultRoutingComponent};
476    use crate::pipeline::{
477        ReadyToInfer, StageInference, StageInferences, StageProgress, StageSetup, StageSetups,
478        VisitCounts,
479    };
480    use leviath_core::blueprint::{ModelConfig, Stage};
481    use leviath_core::layout::{ContextLayout, RegionDefinition};
482    use leviath_core::{Blueprint, Region, RegionKind};
483    use std::collections::HashSet;
484
485    /// A spawner that spawns a trivial `Active` worker per item, refusing the ids
486    /// in `fail`.
487    struct TestSpawner {
488        fail: HashSet<String>,
489    }
490
491    impl TestSpawner {
492        fn ok() -> Arc<dyn FanOutSpawner> {
493            Arc::new(TestSpawner {
494                fail: HashSet::new(),
495            })
496        }
497        fn refusing(ids: &[&str]) -> Arc<dyn FanOutSpawner> {
498            Arc::new(TestSpawner {
499                fail: ids.iter().map(|s| s.to_string()).collect(),
500            })
501        }
502    }
503
504    impl FanOutSpawner for TestSpawner {
505        fn spawn_worker(
506            &self,
507            world: &mut World,
508            _parent: Entity,
509            _config: &FanOutConfig,
510            item_id: &str,
511            _item_context: &serde_json::Value,
512        ) -> Result<Entity, String> {
513            if self.fail.contains(item_id) {
514                return Err(format!("spawn refused for '{item_id}'"));
515            }
516            Ok(world
517                .spawn((
518                    AgentState {
519                        agent_id: format!("worker-{item_id}"),
520                        current_stage: "w".to_string(),
521                        iteration: 0,
522                        status: AgentStatus::Active,
523                        spawned_children_ids: vec![],
524                        pending_wait: None,
525                        accepts_messages: true,
526                    },
527                    // A real worker carries run metadata (attached by build_agent);
528                    // mirror that so the parent can record the worker's run-id.
529                    crate::persistence::RunMetadata {
530                        run_id: format!("run-{item_id}"),
531                        agent_name: "worker".to_string(),
532                        agent_path: String::new(),
533                        task: String::new(),
534                        model: None,
535                        workdir: String::new(),
536                        num_stages: 1,
537                        started_at: 0,
538                        parent_run_id: None,
539                        metadata: std::collections::HashMap::new(),
540                        callback_url: None,
541                        callback_secret: None,
542                        title: None,
543                        unattended: false,
544                        read_paths: None,
545                    },
546                ))
547                .id())
548        }
549    }
550
551    fn cfg(merge: Option<&str>, max_workers: usize, policy: WorkerFailurePolicy) -> FanOutConfig {
552        FanOutConfig {
553            worker_agent: None,
554            worker_stage: Some("w".to_string()),
555            worker_query: None,
556            merge_stage: merge.map(String::from),
557            max_workers,
558            on_worker_failure: policy,
559            split_prompt: "split".to_string(),
560        }
561    }
562
563    fn window() -> ContextWindow {
564        let mut w = ContextWindow::new(12_000);
565        w.add_region(Region::new(
566            "conversation".to_string(),
567            RegionKind::Clearable,
568            10_000,
569        ));
570        w
571    }
572
573    fn stage_inf() -> StageInference {
574        StageInference {
575            provider_name: "script".to_string(),
576            model: "m".to_string(),
577            tools: vec![],
578            tool_filter: None,
579            fallbacks: Vec::new(),
580        }
581    }
582
583    fn setup() -> StageSetup {
584        StageSetup {
585            inference_config: InferenceConfig {
586                temperature: None,
587                max_output_tokens: None,
588                extra_params: Default::default(),
589                batch_tool_hint: false,
590                shell_hint: false,
591                request_timeout_secs: None,
592            },
593            routing: None,
594            accepts_messages: true,
595            context_layout: None,
596            system_prompt: None,
597        }
598    }
599
600    /// A blueprint whose stage 0 is a fan-out stage and stage 1 is `merge`.
601    fn fanout_blueprint(config: FanOutConfig) -> Blueprint {
602        let layout = ContextLayout::new(
603            vec![RegionDefinition::new(
604                "conversation".to_string(),
605                RegionKind::Clearable,
606                10_000,
607            )],
608            12_000,
609        );
610        let mut s0 = Stage::new(
611            "fan".to_string(),
612            ModelConfig::new("script".to_string(), "m".to_string()),
613        );
614        s0.mode = StageMode::FanOut { config };
615        let s1 = Stage::new(
616            "merge".to_string(),
617            ModelConfig::new("script".to_string(), "m".to_string()),
618        );
619        Blueprint::new("t".to_string(), "d".to_string(), vec![s0, s1], layout)
620    }
621
622    fn parent_state() -> AgentState {
623        AgentState {
624            agent_id: "parent".to_string(),
625            current_stage: "fan".to_string(),
626            iteration: 0,
627            status: AgentStatus::Active,
628            spawned_children_ids: vec![],
629            pending_wait: None,
630            accepts_messages: true,
631        }
632    }
633
634    /// Spawn a parent sitting on `ProcessResponse` with `response` as its
635    /// (split) inference output.
636    fn spawn_parent(world: &mut World, bp: Blueprint, response: &str) -> Entity {
637        world
638            .spawn((
639                AgentBlueprint(bp),
640                StageCursor { index: 0 },
641                parent_state(),
642                StageProgress::default(),
643                StageInferences(vec![stage_inf(), stage_inf()]),
644                StageSetups(vec![setup(), setup()]),
645                VisitCounts::default(),
646                window(),
647                InferenceResult {
648                    response: response.to_string(),
649                    tool_calls: vec![],
650                    tokens_used: 0,
651                    timestamp: 0,
652                },
653                ProcessResponse,
654            ))
655            .id()
656    }
657
658    fn install(world: &mut World, spawner: Arc<dyn FanOutSpawner>) {
659        world.insert_resource(FanOutSpawnerRes(spawner));
660    }
661
662    fn status_of(world: &World, e: Entity) -> AgentStatus {
663        world.get::<AgentState>(e).unwrap().status.clone()
664    }
665
666    /// Assert an agent is in an `Error` state (by discriminant, so no unmatched
667    /// `matches!` arm is left uncovered).
668    fn assert_errored(world: &World, e: Entity) {
669        assert_eq!(
670            std::mem::discriminant(&status_of(world, e)),
671            std::mem::discriminant(&AgentStatus::Error {
672                message: String::new()
673            })
674        );
675    }
676
677    fn complete_worker(world: &mut World, worker: Entity, content: &str) {
678        set_status(world, worker, AgentStatus::Complete);
679        world.entity_mut(worker).insert(InferenceResult {
680            response: content.to_string(),
681            tool_calls: vec![],
682            tokens_used: 0,
683            timestamp: 0,
684        });
685    }
686
687    // ── parse_work_items ──────────────────────────────────────────────────────
688
689    #[test]
690    fn parse_work_items_handles_array_prose_and_errors() {
691        let ok = parse_work_items(r#"[{"id":"a"},{"id":"b","context":{"k":1}}]"#).unwrap();
692        assert_eq!(ok.len(), 2);
693        assert_eq!(ok[0].id, "a");
694        assert_eq!(ok[1].context["k"], 1);
695        // Missing fields default.
696        assert_eq!(parse_work_items("[{}]").unwrap()[0].id, "");
697        // Prose around the array is tolerated.
698        assert_eq!(
699            parse_work_items("Here you go:\n```json\n[{\"id\":\"x\"}]\n```")
700                .unwrap()
701                .len(),
702            1
703        );
704        // No brackets at all.
705        assert!(parse_work_items("no array here").is_err());
706        // Closing before opening (e <= s).
707        assert!(parse_work_items("]nope[").is_err());
708        // Brackets but not valid JSON.
709        assert!(parse_work_items("[not json]").is_err());
710    }
711
712    // ── fan_out_split ─────────────────────────────────────────────────────────
713
714    #[test]
715    fn split_parks_a_fanout_stage_and_consumes_the_response() {
716        let mut world = World::new();
717        let e = spawn_parent(
718            &mut world,
719            fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::Continue)),
720            r#"[{"id":"a"},{"id":"b"}]"#,
721        );
722        fan_out_split(&mut world);
723        assert!(world.get::<FanOutWaiting>(e).is_some());
724        assert_eq!(status_of(&world, e), AgentStatus::Waiting);
725        // ProcessResponse + InferenceResult were consumed.
726        assert!(world.get::<ProcessResponse>(e).is_none());
727        assert!(world.get::<InferenceResult>(e).is_none());
728        let w = world.get::<FanOutWaiting>(e).unwrap();
729        assert_eq!(w.pending.len(), 2);
730    }
731
732    #[test]
733    fn split_errors_on_non_array_output() {
734        let mut world = World::new();
735        let e = spawn_parent(
736            &mut world,
737            fanout_blueprint(cfg(None, 2, WorkerFailurePolicy::Continue)),
738            "definitely not a json array",
739        );
740        fan_out_split(&mut world);
741        assert!(world.get::<FanOutWaiting>(e).is_none());
742        assert_errored(&world, e);
743    }
744
745    #[test]
746    fn split_skips_non_active_and_non_fanout_agents() {
747        // Non-Active fan-out agent: left untouched.
748        let mut world = World::new();
749        let e = spawn_parent(
750            &mut world,
751            fanout_blueprint(cfg(None, 2, WorkerFailurePolicy::Continue)),
752            "[]",
753        );
754        set_status(&mut world, e, AgentStatus::Idle);
755        fan_out_split(&mut world);
756        assert!(world.get::<ProcessResponse>(e).is_some());
757        assert!(world.get::<FanOutWaiting>(e).is_none());
758
759        // Non-fan-out stage: not a candidate at all.
760        let layout = ContextLayout::new(
761            vec![RegionDefinition::new(
762                "conversation".to_string(),
763                RegionKind::Clearable,
764                10_000,
765            )],
766            12_000,
767        );
768        let s = Stage::new(
769            "plain".to_string(),
770            ModelConfig::new("script".to_string(), "m".to_string()),
771        );
772        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout);
773        let e2 = spawn_parent(&mut world, bp, "[]");
774        fan_out_split(&mut world);
775        assert!(world.get::<ProcessResponse>(e2).is_some());
776    }
777
778    // ── fan_out_collect: worker lifecycle + merge ─────────────────────────────
779
780    #[test]
781    fn collect_starts_workers_then_merges_on_completion() {
782        let mut world = World::new();
783        install(&mut world, TestSpawner::ok());
784        let e = spawn_parent(
785            &mut world,
786            fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::Continue)),
787            r#"[{"id":"a"},{"id":"b"}]"#,
788        );
789        fan_out_split(&mut world);
790        fan_out_collect(&mut world);
791        // Two workers started and tracked.
792        let kids = world.get::<SubAgentChildren>(e).unwrap().children.clone();
793        assert_eq!(kids.len(), 2);
794        assert!(world.get::<FanOutWaiting>(e).is_some());
795        // Each worker got a ParentRef at depth 1.
796        for k in &kids {
797            assert_eq!(world.get::<ParentRef>(*k).unwrap().depth, 1);
798        }
799
800        // Complete both workers, then collect merges to the merge stage.
801        for k in &kids {
802            complete_worker(&mut world, *k, "fixed it");
803        }
804        fan_out_collect(&mut world);
805        assert!(world.get::<FanOutWaiting>(e).is_none());
806        assert_eq!(status_of(&world, e), AgentStatus::Active);
807        assert_eq!(world.get::<StageCursor>(e).unwrap().index, 1);
808        assert!(world.get::<ReadyToInfer>(e).is_some());
809        // The consolidated report landed in the parent's conversation.
810        assert!(
811            world
812                .get::<ContextWindow>(e)
813                .unwrap()
814                .get_region("conversation")
815                .unwrap()
816                .current_tokens
817                > 0
818        );
819    }
820
821    #[test]
822    fn collect_respects_max_workers_and_stages_pending() {
823        let mut world = World::new();
824        install(&mut world, TestSpawner::ok());
825        let e = spawn_parent(
826            &mut world,
827            fanout_blueprint(cfg(Some("merge"), 1, WorkerFailurePolicy::Continue)),
828            r#"[{"id":"a"},{"id":"b"}]"#,
829        );
830        fan_out_split(&mut world);
831        fan_out_collect(&mut world);
832        // Only one worker at a time.
833        assert_eq!(world.get::<SubAgentChildren>(e).unwrap().children.len(), 1);
834        let first = world.get::<SubAgentChildren>(e).unwrap().children[0];
835        // A collect pass while the worker is still running keeps it active and
836        // starts nothing new (worker still counts against max_workers).
837        fan_out_collect(&mut world);
838        assert_eq!(world.get::<SubAgentChildren>(e).unwrap().children.len(), 1);
839        assert!(world.get::<FanOutWaiting>(e).is_some());
840        complete_worker(&mut world, first, "one");
841        fan_out_collect(&mut world);
842        // Second worker started after the first finished.
843        assert_eq!(world.get::<SubAgentChildren>(e).unwrap().children.len(), 2);
844        let second = world.get::<SubAgentChildren>(e).unwrap().children[1];
845        complete_worker(&mut world, second, "two");
846        fan_out_collect(&mut world);
847        assert!(world.get::<FanOutWaiting>(e).is_none());
848        assert_eq!(world.get::<StageCursor>(e).unwrap().index, 1);
849    }
850
851    #[test]
852    fn fan_out_state_roundtrips_and_unresolved_workers_become_failures() {
853        let mut world = World::new();
854        install(&mut world, TestSpawner::ok());
855        let e = spawn_parent(
856            &mut world,
857            fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::Continue)),
858            r#"[{"id":"a"},{"id":"b"}]"#,
859        );
860        fan_out_split(&mut world);
861        fan_out_collect(&mut world); // starts both workers → active
862
863        // Projecting to the serializable state captures each worker's run-id.
864        let state = world.get::<FanOutWaiting>(e).unwrap().to_state();
865        assert_eq!(state.active.len(), 2);
866        assert!(state.active.iter().all(|(_id, run_id)| !run_id.is_empty()));
867
868        // Restore onto a fresh parent, resolving run-ids back to entities.
869        let by_run: std::collections::HashMap<String, Entity> = world
870            .get::<SubAgentChildren>(e)
871            .unwrap()
872            .children
873            .iter()
874            .filter_map(|&c| {
875                world
876                    .get::<crate::persistence::RunMetadata>(c)
877                    .map(|m| (m.run_id.clone(), c))
878            })
879            .collect();
880        let fresh = world.spawn_empty().id();
881        restore_fan_out_waiting(&mut world, fresh, state.clone(), &|rid| {
882            by_run.get(rid).copied()
883        });
884        assert_eq!(
885            world
886                .get::<FanOutWaiting>(fresh)
887                .unwrap()
888                .to_state()
889                .active
890                .len(),
891            2
892        );
893
894        // A resolver that can't map the workers → they become failures, so the
895        // merge still completes rather than waiting forever.
896        let orphaned = world.spawn_empty().id();
897        restore_fan_out_waiting(&mut world, orphaned, state, &|_| None);
898        let s = world.get::<FanOutWaiting>(orphaned).unwrap().to_state();
899        assert!(s.active.is_empty());
900        assert_eq!(s.failures.len(), 2);
901    }
902
903    #[test]
904    fn collect_fail_all_marks_parent_error() {
905        let mut world = World::new();
906        install(&mut world, TestSpawner::ok());
907        let e = spawn_parent(
908            &mut world,
909            fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::FailAll)),
910            r#"[{"id":"a"}]"#,
911        );
912        fan_out_split(&mut world);
913        fan_out_collect(&mut world);
914        let worker = world.get::<SubAgentChildren>(e).unwrap().children[0];
915        set_status(
916            &mut world,
917            worker,
918            AgentStatus::Error {
919                message: "boom".to_string(),
920            },
921        );
922        fan_out_collect(&mut world);
923        assert_errored(&world, e);
924        assert_eq!(world.get::<StageCursor>(e).unwrap().index, 0); // no merge
925    }
926
927    #[test]
928    fn collect_continue_reports_failures_and_proceeds_without_merge() {
929        let mut world = World::new();
930        install(&mut world, TestSpawner::ok());
931        // No merge stage ⇒ ResolveTransition (proceed) rather than force_transition.
932        let e = spawn_parent(
933            &mut world,
934            fanout_blueprint(cfg(None, 2, WorkerFailurePolicy::Continue)),
935            r#"[{"id":"a"},{"id":"b"}]"#,
936        );
937        fan_out_split(&mut world);
938        fan_out_collect(&mut world);
939        let kids = world.get::<SubAgentChildren>(e).unwrap().children.clone();
940        set_status(
941            &mut world,
942            kids[0],
943            AgentStatus::Error {
944                message: "worker a died".to_string(),
945            },
946        );
947        complete_worker(&mut world, kids[1], "b ok");
948        fan_out_collect(&mut world);
949        assert!(world.get::<FanOutWaiting>(e).is_none());
950        assert!(world.get::<crate::pipeline::ResolveTransition>(e).is_some());
951        assert_eq!(world.get::<StageCursor>(e).unwrap().index, 0);
952    }
953
954    #[test]
955    fn collect_finishes_immediately_when_there_are_no_work_items() {
956        let mut world = World::new();
957        install(&mut world, TestSpawner::ok());
958        let e = spawn_parent(
959            &mut world,
960            fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::Continue)),
961            "[]",
962        );
963        fan_out_split(&mut world);
964        fan_out_collect(&mut world);
965        // No workers; straight to merge.
966        assert!(world.get::<SubAgentChildren>(e).is_none());
967        assert!(world.get::<FanOutWaiting>(e).is_none());
968        assert_eq!(world.get::<StageCursor>(e).unwrap().index, 1);
969    }
970
971    #[test]
972    fn collect_merge_stage_not_found_falls_through_to_transition() {
973        let mut world = World::new();
974        install(&mut world, TestSpawner::ok());
975        let e = spawn_parent(
976            &mut world,
977            fanout_blueprint(cfg(Some("ghost"), 2, WorkerFailurePolicy::Continue)),
978            "[]",
979        );
980        fan_out_split(&mut world);
981        fan_out_collect(&mut world);
982        // Unknown merge stage ⇒ ResolveTransition, no stage jump.
983        assert!(world.get::<crate::pipeline::ResolveTransition>(e).is_some());
984        assert_eq!(world.get::<StageCursor>(e).unwrap().index, 0);
985    }
986
987    #[test]
988    fn collect_abandons_a_cancelled_parent() {
989        let mut world = World::new();
990        install(&mut world, TestSpawner::ok());
991        let e = spawn_parent(
992            &mut world,
993            fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::Continue)),
994            r#"[{"id":"a"}]"#,
995        );
996        fan_out_split(&mut world);
997        set_status(&mut world, e, AgentStatus::Cancelled);
998        fan_out_collect(&mut world);
999        assert!(world.get::<FanOutWaiting>(e).is_none());
1000        assert_eq!(status_of(&world, e), AgentStatus::Cancelled);
1001    }
1002
1003    #[test]
1004    fn collect_without_a_spawner_records_failures() {
1005        // No FanOutSpawnerRes installed ⇒ every item fails to start.
1006        let mut world = World::new();
1007        let e = spawn_parent(
1008            &mut world,
1009            fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::Continue)),
1010            r#"[{"id":"a"}]"#,
1011        );
1012        fan_out_split(&mut world);
1013        fan_out_collect(&mut world);
1014        // Item failed to start, Continue policy ⇒ still transitions to merge.
1015        assert!(world.get::<FanOutWaiting>(e).is_none());
1016        assert_eq!(world.get::<StageCursor>(e).unwrap().index, 1);
1017    }
1018
1019    #[test]
1020    fn collect_spawner_error_becomes_a_failure() {
1021        let mut world = World::new();
1022        install(&mut world, TestSpawner::refusing(&["a"]));
1023        let e = spawn_parent(
1024            &mut world,
1025            fanout_blueprint(cfg(None, 2, WorkerFailurePolicy::FailAll)),
1026            r#"[{"id":"a"}]"#,
1027        );
1028        fan_out_split(&mut world);
1029        fan_out_collect(&mut world);
1030        // Spawn refused + FailAll ⇒ parent errors.
1031        assert_errored(&world, e);
1032    }
1033
1034    // ── start_worker: depth cap + existing SubAgentChildren ───────────────────
1035
1036    #[test]
1037    fn start_worker_enforces_depth_cap() {
1038        let mut world = World::new();
1039        install(&mut world, TestSpawner::ok());
1040        let mut bp = fanout_blueprint(cfg(None, 2, WorkerFailurePolicy::Continue));
1041        bp.max_child_depth = Some(3);
1042        let e = spawn_parent(&mut world, bp, r#"[{"id":"deep"}]"#);
1043        // Parent is itself a depth-3 sub-agent ⇒ child would be depth 4 > 3.
1044        world.entity_mut(e).insert(ParentRef {
1045            parent_entity: Entity::from_raw_u32(999)
1046                .expect("a small literal index is always a valid entity id"),
1047            parent_agent_id: "root".to_string(),
1048            depth: 3,
1049        });
1050        fan_out_split(&mut world);
1051        fan_out_collect(&mut world);
1052        // No worker spawned (depth cap hit before any container is created).
1053        assert!(world.get::<SubAgentChildren>(e).is_none());
1054        assert!(world.get::<FanOutWaiting>(e).is_none());
1055    }
1056
1057    #[test]
1058    fn start_worker_uses_existing_subagentchildren_cap_and_appends() {
1059        let mut world = World::new();
1060        install(&mut world, TestSpawner::ok());
1061        let e = spawn_parent(
1062            &mut world,
1063            fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::Continue)),
1064            r#"[{"id":"a"}]"#,
1065        );
1066        // Pre-existing children container with a generous cap.
1067        world.entity_mut(e).insert(SubAgentChildren {
1068            children: vec![
1069                Entity::from_raw_u32(1000)
1070                    .expect("a small literal index is always a valid entity id"),
1071            ],
1072            max_child_depth: 9,
1073        });
1074        fan_out_split(&mut world);
1075        fan_out_collect(&mut world);
1076        let kids = world.get::<SubAgentChildren>(e).unwrap();
1077        assert_eq!(kids.max_child_depth, 9);
1078        assert_eq!(kids.children.len(), 2); // appended to the existing one
1079    }
1080
1081    // ── worker_terminal_result / build_report / inject_conversation ───────────
1082
1083    #[test]
1084    fn worker_terminal_result_covers_every_status() {
1085        let mut world = World::new();
1086        let complete = world
1087            .spawn((
1088                parent_state(),
1089                InferenceResult {
1090                    response: "done text".to_string(),
1091                    tool_calls: vec![],
1092                    tokens_used: 0,
1093                    timestamp: 0,
1094                },
1095            ))
1096            .id();
1097        set_status(&mut world, complete, AgentStatus::Complete);
1098        assert_eq!(
1099            worker_terminal_result(&world, complete),
1100            Some(Ok("done text".to_string()))
1101        );
1102
1103        let complete_no_infer = world.spawn(parent_state()).id();
1104        set_status(&mut world, complete_no_infer, AgentStatus::Complete);
1105        assert_eq!(
1106            worker_terminal_result(&world, complete_no_infer),
1107            Some(Ok(String::new()))
1108        );
1109
1110        let errored = world.spawn(parent_state()).id();
1111        set_status(
1112            &mut world,
1113            errored,
1114            AgentStatus::Error {
1115                message: "x".to_string(),
1116            },
1117        );
1118        assert_eq!(
1119            worker_terminal_result(&world, errored),
1120            Some(Err("x".to_string()))
1121        );
1122
1123        let cancelled = world.spawn(parent_state()).id();
1124        set_status(&mut world, cancelled, AgentStatus::Cancelled);
1125        assert!(worker_terminal_result(&world, cancelled).is_some_and(|r| r.is_err()));
1126
1127        let running = world.spawn(parent_state()).id(); // Active
1128        assert_eq!(worker_terminal_result(&world, running), None);
1129
1130        assert!(
1131            worker_terminal_result(
1132                &world,
1133                Entity::from_raw_u32(4242)
1134                    .expect("a small literal index is always a valid entity id")
1135            )
1136            .is_some_and(|r| r.is_err())
1137        );
1138    }
1139
1140    #[test]
1141    fn build_report_lists_successes_and_failures() {
1142        let report = build_report(
1143            &[("a".to_string(), "ok-a".to_string())],
1144            &[("b".to_string(), "boom".to_string())],
1145        );
1146        assert!(report.contains("1 succeeded, 1 failed"));
1147        assert!(report.contains("## worker a\nok-a"));
1148        assert!(report.contains("## worker b FAILED\nboom"));
1149    }
1150
1151    #[test]
1152    fn inject_conversation_is_a_noop_without_a_window() {
1153        let mut world = World::new();
1154        let has_window = world.spawn(window()).id();
1155        inject_conversation(&mut world, has_window, "hello");
1156        assert!(
1157            world
1158                .get::<ContextWindow>(has_window)
1159                .unwrap()
1160                .get_region("conversation")
1161                .unwrap()
1162                .current_tokens
1163                > 0
1164        );
1165        // Entity without a ContextWindow: silently ignored.
1166        let no_window = world.spawn(parent_state()).id();
1167        inject_conversation(&mut world, no_window, "hello");
1168    }
1169
1170    #[test]
1171    fn set_status_is_a_noop_for_a_missing_agent() {
1172        let mut world = World::new();
1173        set_status(
1174            &mut world,
1175            Entity::from_raw_u32(77).expect("a small literal index is always a valid entity id"),
1176            AgentStatus::Complete,
1177        );
1178        assert_eq!(
1179            agent_status(
1180                &world,
1181                Entity::from_raw_u32(77)
1182                    .expect("a small literal index is always a valid entity id")
1183            ),
1184            None
1185        );
1186    }
1187
1188    // ── force_transition (pipeline helper) edge cases via fan-out ─────────────
1189
1190    #[test]
1191    fn force_transition_applies_routing_and_handles_despawn_and_overflow() {
1192        use crate::pipeline::force_transition;
1193        // Routing present on the target stage ⇒ ToolResultRoutingComponent added.
1194        let mut world = World::new();
1195        let mut setups = vec![setup(), setup()];
1196        setups[1].routing = Some(leviath_core::ToolResultRouting::default());
1197        let e = world
1198            .spawn((
1199                AgentBlueprint(fanout_blueprint(cfg(
1200                    Some("merge"),
1201                    2,
1202                    WorkerFailurePolicy::Continue,
1203                ))),
1204                StageCursor { index: 0 },
1205                parent_state(),
1206                StageProgress::default(),
1207                StageInferences(vec![stage_inf(), stage_inf()]),
1208                StageSetups(setups),
1209                VisitCounts::default(),
1210                window(),
1211            ))
1212            .id();
1213        force_transition(&mut world, e, 1);
1214        assert!(world.get::<ToolResultRoutingComponent>(e).is_some());
1215        assert!(world.get::<ReadyToInfer>(e).is_some());
1216
1217        // Despawned entity: no panic, no effect.
1218        force_transition(
1219            &mut world,
1220            Entity::from_raw_u32(9191).expect("a small literal index is always a valid entity id"),
1221            1,
1222        );
1223    }
1224
1225    #[test]
1226    fn force_transition_marks_error_on_prompt_overflow() {
1227        use crate::pipeline::force_transition;
1228        // A tiny pinned region + a huge stage system prompt ⇒ overflow on entry.
1229        let layout = ContextLayout::new(
1230            vec![RegionDefinition::new(
1231                "task".to_string(),
1232                RegionKind::Pinned,
1233                20,
1234            )],
1235            1000,
1236        );
1237        let mut s0 = Stage::new(
1238            "fan".to_string(),
1239            ModelConfig::new("script".to_string(), "m".to_string()),
1240        );
1241        s0.mode = StageMode::FanOut {
1242            config: cfg(Some("merge"), 2, WorkerFailurePolicy::Continue),
1243        };
1244        let mut s1 = Stage::new(
1245            "merge".to_string(),
1246            ModelConfig::new("script".to_string(), "m".to_string()),
1247        );
1248        s1.config.insert(
1249            "system_prompt".to_string(),
1250            serde_json::Value::String("x".repeat(10_000)),
1251        );
1252        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![s0, s1], layout);
1253
1254        let mut setups = vec![setup(), setup()];
1255        setups[1].system_prompt = Some("x".repeat(10_000));
1256        let mut w = ContextWindow::new(1000);
1257        w.add_region(Region::new("task".to_string(), RegionKind::Pinned, 20));
1258        let (mut world, e) = world_with(bp, setups, w);
1259        force_transition(&mut world, e, 1);
1260        assert_errored(&world, e);
1261    }
1262
1263    /// Build a world with one agent carrying the given blueprint/setups/window.
1264    fn world_with(bp: Blueprint, setups: Vec<StageSetup>, w: ContextWindow) -> (World, Entity) {
1265        let mut world = World::new();
1266        let e = world
1267            .spawn((
1268                AgentBlueprint(bp),
1269                StageCursor { index: 0 },
1270                parent_state(),
1271                StageProgress::default(),
1272                StageInferences(vec![stage_inf(), stage_inf()]),
1273                StageSetups(setups),
1274                VisitCounts::default(),
1275                w,
1276            ))
1277            .id();
1278        (world, e)
1279    }
1280}