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