1use std::collections::{HashMap, HashSet, VecDeque};
18use std::time::Duration;
19
20use bevy_ecs::entity::Entity;
21use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
22use tokio::sync::{broadcast, oneshot};
23
24use crate::components::{
25 AgentMessage, AgentState, AgentStatus, AwaitingInteraction, ContextWindow, ParentRef,
26 SubAgentChildren, WaitReason,
27};
28use crate::interaction_hub::InteractionHub;
29use crate::persistence::{RunMetadata, TokenTotals};
30use crate::world::{LaneSnapshot, PipelineWorld};
31use leviath_core::interaction::{InteractionRequest, InteractionResponse};
32use serde::{Deserialize, Serialize};
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
38pub struct SpawnArgs {
39 pub run_id: String,
41 pub blueprint_path: String,
43 pub task: String,
47 #[serde(default)]
51 pub regions: HashMap<String, String>,
52 #[serde(default)]
54 pub model: Option<String>,
55 pub workdir: String,
57 #[serde(default)]
59 pub metadata: HashMap<String, String>,
60 #[serde(default)]
62 pub callback_url: Option<String>,
63 #[serde(default)]
65 pub callback_secret: Option<String>,
66 #[serde(default)]
77 pub yolo: bool,
78 #[serde(default)]
83 pub no_seed_commands: bool,
84 #[serde(default)]
86 pub allow: Vec<String>,
87 #[serde(default)]
89 pub max_depth: Option<usize>,
90 #[serde(default)]
94 pub parent_run_id: Option<String>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
108pub struct RunListEntry {
109 pub run_id: String,
111 pub status: AgentStatus,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub wait_reason: Option<WaitReason>,
117 pub stage: String,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub stage_index: Option<usize>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub num_stages: Option<usize>,
125 pub iteration: usize,
127 pub tool_calls: usize,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub last_progress_at: Option<i64>,
135 #[serde(default)]
138 pub unattended: bool,
139 #[serde(default)]
148 pub empty_output: bool,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub read_paths: Option<leviath_core::run_meta::ReadPathGrantCounts>,
157}
158
159#[derive(Debug, Clone, Default, PartialEq)]
166pub struct RunListing {
167 pub runs: Vec<RunListEntry>,
169 pub finished: Vec<RunListEntry>,
173 pub health: DaemonHealth,
175}
176
177#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
184pub struct DaemonHealth {
185 pub agents: crate::world::AgentCounts,
187 pub inference: Vec<crate::inference_pool::PoolOccupancy>,
189 pub tools_busy: usize,
191 pub tools_queued: usize,
193 pub tools_parked: usize,
195 pub tools_workers: usize,
197 pub dead_cycles: u32,
200 pub relief_granted: usize,
202 pub redrive_secs: u64,
205 #[serde(default)]
210 pub providers_down: Vec<crate::pipeline::ProviderCircuitState>,
211}
212
213pub type Spawner = Box<dyn FnMut(&mut PipelineWorld, &SpawnArgs) -> Result<Entity, String> + Send>;
218
219pub type Reloader = Box<dyn FnMut(&mut PipelineWorld, &str) -> Option<Entity> + Send>;
227
228pub type ForceTerminator = Box<dyn FnMut(&str) -> bool + Send>;
241
242pub type Reaper = Box<dyn FnMut(&mut PipelineWorld, Entity) + Send>;
248
249pub type SpawnPreprocessor = Box<
257 dyn Fn(&SpawnArgs) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> + Send,
258>;
259
260pub enum SubAgentOp {
266 Spawn {
269 args: Box<SpawnArgs>,
272 parent_run_id: String,
274 max_depth: usize,
276 reply: oneshot::Sender<Result<String, String>>,
278 },
279 Check {
281 run_id: String,
283 reply: oneshot::Sender<Option<AgentStatus>>,
285 },
286 Send {
289 run_id: String,
291 caller_run_id: String,
294 content: String,
296 target_region: Option<String>,
300 reply: oneshot::Sender<bool>,
302 },
303 Kill {
305 run_id: String,
307 caller_run_id: String,
310 reply: oneshot::Sender<bool>,
312 },
313}
314
315pub enum ControlOp {
318 Spawn {
320 args: Box<SpawnArgs>,
323 reply: oneshot::Sender<Result<String, String>>,
325 },
326 Status {
328 run_id: String,
330 reply: oneshot::Sender<Option<AgentStatus>>,
332 },
333 Pause {
335 run_id: String,
337 reply: oneshot::Sender<bool>,
339 },
340 Resume {
342 run_id: String,
344 reply: oneshot::Sender<bool>,
346 },
347 Cancel {
349 run_id: String,
351 reply: oneshot::Sender<bool>,
353 },
354 List {
356 reply: oneshot::Sender<RunListing>,
358 },
359 Message {
362 agent_id: String,
364 content: String,
366 target_region: Option<String>,
368 reply: oneshot::Sender<bool>,
370 },
371 ListInteractions {
373 reply: oneshot::Sender<Vec<(String, InteractionRequest)>>,
375 },
376 AnswerInteraction {
378 response: InteractionResponse,
380 reply: oneshot::Sender<bool>,
382 },
383 CancelInteraction {
386 request_id: String,
388 reply: oneshot::Sender<bool>,
390 },
391 Shutdown {
394 reply: oneshot::Sender<bool>,
396 },
397}
398
399#[non_exhaustive]
410#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
411#[serde(tag = "event", rename_all = "snake_case")]
412pub enum WorldEvent {
413 Spawned {
415 run_id: String,
417 agent_id: String,
419 blueprint: String,
421 },
422 Status {
424 run_id: String,
426 agent_id: String,
428 status: String,
430 stage: String,
432 iteration: usize,
434 tool_calls: usize,
436 accepts_messages: bool,
438 },
439 Tokens {
441 run_id: String,
443 agent_id: String,
445 prompt_tokens: usize,
447 completion_tokens: usize,
449 cached_tokens: usize,
451 cache_write_tokens: usize,
453 },
454 Context {
456 run_id: String,
458 agent_id: String,
460 total_tokens: usize,
462 max_tokens: usize,
464 },
465 Interaction {
467 run_id: String,
469 agent_id: String,
471 request: InteractionRequest,
473 },
474 Completed {
476 run_id: String,
478 agent_id: String,
480 status: String,
482 },
483 StageTransition {
487 run_id: String,
489 agent_id: String,
491 from: String,
493 to: String,
495 iteration: usize,
498 },
499 ToolCallStarted {
503 run_id: String,
505 agent_id: String,
507 call_id: String,
509 tool: String,
511 },
512 ToolCallFinished {
515 run_id: String,
517 agent_id: String,
519 call_id: String,
521 tool: String,
523 ok: bool,
526 summary: String,
528 },
529 Log {
532 run_id: String,
534 agent_id: String,
536 line: String,
538 },
539}
540
541impl WorldEvent {
542 pub fn run_id(&self) -> &str {
546 match self {
547 WorldEvent::Spawned { run_id, .. }
548 | WorldEvent::Status { run_id, .. }
549 | WorldEvent::Tokens { run_id, .. }
550 | WorldEvent::Context { run_id, .. }
551 | WorldEvent::Interaction { run_id, .. }
552 | WorldEvent::Completed { run_id, .. }
553 | WorldEvent::StageTransition { run_id, .. }
554 | WorldEvent::ToolCallStarted { run_id, .. }
555 | WorldEvent::ToolCallFinished { run_id, .. }
556 | WorldEvent::Log { run_id, .. } => run_id,
557 }
558 }
559}
560
561#[derive(bevy_ecs::resource::Resource, Clone)]
568pub struct WorldEventSink(pub broadcast::Sender<WorldEvent>);
569
570fn status_str(status: &AgentStatus) -> &'static str {
574 status.label()
575}
576
577#[derive(Clone, Hash)]
579struct Emitted {
580 status: &'static str,
581 stage: String,
582 iteration: usize,
583 tool_calls: usize,
584 accepts_messages: bool,
585 prompt_tokens: usize,
586 completion_tokens: usize,
587 cached_tokens: usize,
588 cache_write_tokens: usize,
589 context_tokens: usize,
590 terminal: bool,
591}
592
593pub struct WorldHost {
595 world: PipelineWorld,
596 by_run_id: HashMap<String, Entity>,
597 interactions: InteractionHub,
598 spawner: Option<Spawner>,
599 spawn_preprocessor: Option<SpawnPreprocessor>,
600 reloader: Option<Reloader>,
601 force_terminator: Option<ForceTerminator>,
602 reaper: Option<Reaper>,
603 events: broadcast::Sender<WorldEvent>,
604 emitted: HashMap<String, Emitted>,
605 emitted_interactions: HashSet<String>,
606 subagent_tx: UnboundedSender<SubAgentOp>,
609 subagent_rx: UnboundedReceiver<SubAgentOp>,
610 redrive: Duration,
613 dead_cycles: u32,
616 last_progress: Option<u64>,
619 relief_granted: usize,
622 dead_cycles_before_relief: u32,
625 finished: VecDeque<(i64, RunListEntry)>,
629 finished_retention_secs: u64,
632}
633
634const DEFAULT_REDRIVE_INTERVAL: Duration = Duration::from_secs(30);
646
647pub const DEFAULT_DEAD_CYCLES_BEFORE_RELIEF: u32 = 10;
654
655pub const DEFAULT_FINISHED_RETENTION_SECS: u64 = 300;
674
675const MAX_RETAINED_FINISHED: usize = 256;
683
684impl WorldHost {
685 pub fn new(world: PipelineWorld) -> Self {
687 Self::with_interactions(world, InteractionHub::new())
688 }
689
690 pub fn with_interactions(mut world: PipelineWorld, interactions: InteractionHub) -> Self {
693 let (events, _) = broadcast::channel(1024);
694 world
697 .world_mut()
698 .insert_resource(WorldEventSink(events.clone()));
699 let (subagent_tx, subagent_rx) = tokio::sync::mpsc::unbounded_channel();
700 Self {
701 world,
702 by_run_id: HashMap::new(),
703 interactions,
704 spawner: None,
705 spawn_preprocessor: None,
706 reloader: None,
707 force_terminator: None,
708 reaper: None,
709 events,
710 emitted: HashMap::new(),
711 emitted_interactions: HashSet::new(),
712 subagent_tx,
713 subagent_rx,
714 redrive: DEFAULT_REDRIVE_INTERVAL,
715 dead_cycles: 0,
716 last_progress: None,
717 relief_granted: 0,
718 dead_cycles_before_relief: DEFAULT_DEAD_CYCLES_BEFORE_RELIEF,
719 finished: VecDeque::new(),
720 finished_retention_secs: DEFAULT_FINISHED_RETENTION_SECS,
721 }
722 }
723
724 fn observe_redrive(&mut self) {
734 let snapshot = self.world.lane_snapshot();
735 let progress = self.progress_fingerprint();
736 let went_nowhere = snapshot.is_under_pressure() && self.last_progress == Some(progress);
737 self.last_progress = Some(progress);
738 self.dead_cycles = match went_nowhere {
739 true => self.dead_cycles.saturating_add(1),
740 false => 0,
741 };
742 self.log_lane_pressure(&snapshot);
743 let relief = self.relieve_if_wedged(&snapshot);
744 self.observe_lanes(&snapshot, relief);
745 }
746
747 fn relieve_if_wedged(&mut self, snapshot: &LaneSnapshot) -> usize {
764 let threshold = self.dead_cycles_before_relief;
765 if threshold == 0 || self.dead_cycles < threshold || !snapshot.tools_saturated {
766 return 0;
767 }
768 let configured = snapshot.tools_workers.saturating_sub(self.relief_granted);
771 let remaining = configured.saturating_sub(self.relief_granted);
772 let granted = self
773 .world
774 .relieve_tool_lane(remaining.min(snapshot.tools_queued));
775 self.relief_granted += granted;
776 tracing::error!(
777 dead_cycles = self.dead_cycles,
778 granted,
779 relief_granted = self.relief_granted,
780 tools_queued = snapshot.tools_queued,
781 tools_parked = snapshot.tools_parked,
782 "the tool lane has not drained in {} cycles; widening it by {granted}",
783 self.dead_cycles
784 );
785 self.dead_cycles = 0;
788 granted
789 }
790
791 pub fn set_finished_retention_secs(&mut self, secs: u64) {
795 self.finished_retention_secs = secs;
796 }
797
798 fn record_finished(&mut self, mut entry: RunListEntry, at: i64) {
810 if self.finished_retention_secs == 0 {
811 return;
812 }
813 entry.last_progress_at.get_or_insert(at);
814 self.finished
815 .retain(|(_, held)| held.run_id != entry.run_id);
816 self.finished.push_back((at, entry));
817 while self.finished.len() > MAX_RETAINED_FINISHED {
818 self.finished.pop_front();
819 }
820 }
821
822 fn prune_finished(&mut self, now: i64) {
830 let window = self.finished_retention_secs as i64;
831 while let Some(&(at, _)) = self.finished.front() {
832 if now.saturating_sub(at) <= window {
833 break;
834 }
835 self.finished.pop_front();
836 }
837 }
838
839 pub fn set_dead_cycles_before_relief(&mut self, cycles: u32) {
843 self.dead_cycles_before_relief = cycles;
844 }
845
846 fn observe_lanes(&self, snapshot: &LaneSnapshot, relief: usize) {
851 self.world
855 .world()
856 .resource::<crate::telemetry::Telemetry>()
857 .0
858 .observe_lanes(leviath_core::telemetry::LaneHealth {
859 agents_active: snapshot.agents.active,
860 agents_waiting: snapshot.agents.waiting,
861 tools_busy: snapshot.tools_busy,
862 tools_queued: snapshot.tools_queued,
863 tools_parked: snapshot.tools_parked,
864 tools_workers: snapshot.tools_workers,
865 dead_cycles: self.dead_cycles,
866 relief_granted: relief,
867 });
868 let down: Vec<leviath_core::telemetry::ProviderHealth> = self
872 .world
873 .open_circuits()
874 .into_iter()
875 .map(|c| leviath_core::telemetry::ProviderHealth {
876 provider: c.provider,
877 reason: c.reason.label().to_string(),
878 consecutive_failures: c.consecutive_failures,
879 retry_in_secs: c.retry_in_secs,
880 })
881 .collect();
882 self.world
883 .world()
884 .resource::<crate::telemetry::Telemetry>()
885 .0
886 .observe_providers(&down);
887 }
888
889 pub fn health(&self) -> DaemonHealth {
895 let snapshot = self.world.lane_snapshot();
896 DaemonHealth {
897 agents: snapshot.agents,
898 inference: snapshot.inference,
899 tools_busy: snapshot.tools_busy,
900 tools_queued: snapshot.tools_queued,
901 tools_parked: snapshot.tools_parked,
902 tools_workers: snapshot.tools_workers,
903 dead_cycles: self.dead_cycles,
904 relief_granted: self.relief_granted,
905 redrive_secs: self.redrive.as_secs(),
906 providers_down: self.world.open_circuits(),
907 }
908 }
909
910 fn progress_fingerprint(&self) -> u64 {
921 use std::hash::{Hash, Hasher};
922 let mut total = self.emitted.len() as u64;
923 for entry in &self.emitted {
924 let mut hasher = std::collections::hash_map::DefaultHasher::new();
925 entry.hash(&mut hasher);
926 total = total.wrapping_add(hasher.finish());
927 }
928 total
929 }
930
931 fn log_lane_pressure(&self, snapshot: &LaneSnapshot) {
943 let agents = snapshot.agents.to_string();
944 let inference = snapshot.inference_summary();
945 if self.dead_cycles > 0 {
946 tracing::warn!(
947 dead_cycles = self.dead_cycles,
948 agents = %agents,
949 inference = %inference,
950 tools_busy = snapshot.tools_busy,
951 tools_workers = snapshot.tools_workers,
952 tools_queued = snapshot.tools_queued,
953 tools_parked = snapshot.tools_parked,
954 "no progress while the lanes are full"
955 );
956 } else if snapshot.is_under_pressure() {
957 tracing::info!(
958 agents = %agents,
959 inference = %inference,
960 tools_busy = snapshot.tools_busy,
961 tools_workers = snapshot.tools_workers,
962 tools_queued = snapshot.tools_queued,
963 tools_parked = snapshot.tools_parked,
964 "lane heartbeat: at capacity with work queued"
965 );
966 } else {
967 tracing::debug!(
968 agents = %agents,
969 inference = %inference,
970 tools_busy = snapshot.tools_busy,
971 tools_workers = snapshot.tools_workers,
972 tools_queued = snapshot.tools_queued,
973 tools_parked = snapshot.tools_parked,
974 "lane heartbeat"
975 );
976 }
977 }
978
979 pub fn set_redrive_interval(&mut self, every: Duration) {
984 self.redrive = every;
985 }
986
987 pub fn subagent_sender(&self) -> UnboundedSender<SubAgentOp> {
990 self.subagent_tx.clone()
991 }
992
993 pub fn subscribe(&self) -> broadcast::Receiver<WorldEvent> {
996 self.events.subscribe()
997 }
998
999 pub fn event_sender(&self) -> broadcast::Sender<WorldEvent> {
1002 self.events.clone()
1003 }
1004
1005 fn emit_events(&mut self) {
1009 self.adopt_unregistered_runs();
1010 let pairs: Vec<(String, Entity)> = self
1011 .by_run_id
1012 .iter()
1013 .map(|(k, &v)| (k.clone(), v))
1014 .collect();
1015 let mut to_reap: Vec<(String, Entity, RunListEntry)> = Vec::new();
1022 let now = chrono::Utc::now().timestamp();
1023 for (run_id, entity) in pairs {
1024 let Some(state) = self.world.world().get::<AgentState>(entity) else {
1025 continue; };
1027 let agent_id = state.agent_id.clone();
1028 let status = status_str(&state.status);
1029 let terminal = matches!(
1030 state.status,
1031 AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled
1032 );
1033 let cur = {
1034 let totals = self
1035 .world
1036 .world()
1037 .get::<TokenTotals>(entity)
1038 .copied()
1039 .unwrap_or_default();
1040 let (context_tokens, _) = self
1041 .world
1042 .world()
1043 .get::<ContextWindow>(entity)
1044 .map(|w| (w.current_tokens, w.max_tokens))
1045 .unwrap_or((0, 0));
1046 Emitted {
1047 status,
1048 stage: state.current_stage.clone(),
1049 iteration: state.iteration,
1050 tool_calls: totals.tool_calls,
1051 accepts_messages: state.accepts_messages,
1052 prompt_tokens: totals.prompt_tokens,
1053 completion_tokens: totals.completion_tokens,
1054 cached_tokens: totals.cached_tokens,
1055 cache_write_tokens: totals.cache_write_tokens,
1056 context_tokens,
1057 terminal,
1058 }
1059 };
1060 let max_tokens = self
1061 .world
1062 .world()
1063 .get::<ContextWindow>(entity)
1064 .map(|w| w.max_tokens)
1065 .unwrap_or(0);
1066 let prev = self.emitted.get(&run_id).cloned();
1067
1068 if prev.is_none() {
1069 let blueprint = self
1070 .world
1071 .world()
1072 .get::<RunMetadata>(entity)
1073 .map(|m| m.agent_name.clone())
1074 .unwrap_or_default();
1075 let _ = self.events.send(WorldEvent::Spawned {
1076 run_id: run_id.clone(),
1077 agent_id: agent_id.clone(),
1078 blueprint,
1079 });
1080 }
1081
1082 let status_key = |e: &Emitted| {
1083 (
1084 e.status,
1085 e.stage.clone(),
1086 e.iteration,
1087 e.tool_calls,
1088 e.accepts_messages,
1089 )
1090 };
1091 if prev.as_ref().map(status_key) != Some(status_key(&cur)) {
1092 let _ = self.events.send(WorldEvent::Status {
1093 run_id: run_id.clone(),
1094 agent_id: agent_id.clone(),
1095 status: status.to_string(),
1096 stage: cur.stage.clone(),
1097 iteration: cur.iteration,
1098 tool_calls: cur.tool_calls,
1099 accepts_messages: cur.accepts_messages,
1100 });
1101 }
1102
1103 let token_key = |e: &Emitted| {
1104 (
1105 e.prompt_tokens,
1106 e.completion_tokens,
1107 e.cached_tokens,
1108 e.cache_write_tokens,
1109 )
1110 };
1111 if prev.as_ref().map(token_key) != Some(token_key(&cur)) {
1112 let _ = self.events.send(WorldEvent::Tokens {
1113 run_id: run_id.clone(),
1114 agent_id: agent_id.clone(),
1115 prompt_tokens: cur.prompt_tokens,
1116 completion_tokens: cur.completion_tokens,
1117 cached_tokens: cur.cached_tokens,
1118 cache_write_tokens: cur.cache_write_tokens,
1119 });
1120 }
1121
1122 if prev.as_ref().map(|e| e.context_tokens) != Some(cur.context_tokens) {
1123 let _ = self.events.send(WorldEvent::Context {
1124 run_id: run_id.clone(),
1125 agent_id: agent_id.clone(),
1126 total_tokens: cur.context_tokens,
1127 max_tokens,
1128 });
1129 }
1130
1131 let was_terminal = prev.as_ref().map(|e| e.terminal) == Some(true);
1132 if cur.terminal && !was_terminal {
1133 let _ = self.events.send(WorldEvent::Completed {
1134 run_id: run_id.clone(),
1135 agent_id: agent_id.clone(),
1136 status: status.to_string(),
1137 });
1138 }
1139 if cur.terminal && was_terminal && self.no_live_parent(entity) {
1143 let entry = self.entry_for(&run_id, entity, state);
1144 to_reap.push((run_id.clone(), entity, entry));
1145 }
1146 self.emitted.insert(run_id, cur);
1155 }
1156
1157 let mut reaper = self.reaper.take();
1163 for (run_id, entity, entry) in to_reap {
1164 if let Some(reaper) = reaper.as_mut() {
1165 reaper(&mut self.world, entity);
1166 }
1167 self.world.world_mut().despawn(entity);
1168 self.by_run_id.remove(&run_id);
1169 self.emitted.remove(&run_id);
1170 self.record_finished(entry, now);
1173 }
1174 self.reaper = reaper;
1175 self.prune_finished(now);
1176
1177 for (agent_id, request) in self.interactions.pending() {
1178 if self.emitted_interactions.insert(request.id.clone()) {
1179 let _ = self.events.send(WorldEvent::Interaction {
1180 run_id: agent_id.clone(),
1181 agent_id,
1182 request,
1183 });
1184 }
1185 }
1186 }
1187
1188 fn adopt_unregistered_runs(&mut self) {
1201 let live: Vec<(String, Entity)> = self
1202 .world
1203 .world_mut()
1204 .query::<(Entity, &RunMetadata)>()
1205 .iter(self.world.world())
1206 .map(|(entity, md)| (md.run_id.clone(), entity))
1207 .collect();
1208 for (run_id, entity) in live {
1209 if self.live_entity(&run_id) != Some(entity) {
1210 self.by_run_id.insert(run_id, entity);
1211 }
1212 }
1213 }
1214
1215 fn no_live_parent(&self, entity: Entity) -> bool {
1220 let world = self.world.world();
1221 match world.get::<crate::components::ParentRef>(entity) {
1222 None => true,
1223 Some(parent_ref) => match world.get::<AgentState>(parent_ref.parent_entity) {
1224 None => true,
1225 Some(state) => matches!(
1226 state.status,
1227 AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled
1228 ),
1229 },
1230 }
1231 }
1232
1233 pub fn set_spawner(&mut self, spawner: Spawner) {
1236 self.spawner = Some(spawner);
1237 }
1238
1239 pub fn set_spawn_preprocessor(&mut self, pp: SpawnPreprocessor) {
1242 self.spawn_preprocessor = Some(pp);
1243 }
1244
1245 pub fn set_reloader(&mut self, reloader: Reloader) {
1248 self.reloader = Some(reloader);
1249 }
1250
1251 pub fn set_force_terminator(&mut self, force_terminator: ForceTerminator) {
1255 self.force_terminator = Some(force_terminator);
1256 }
1257
1258 pub fn set_reaper(&mut self, reaper: Reaper) {
1262 self.reaper = Some(reaper);
1263 }
1264
1265 fn resolve_or_reload(&mut self, run_id: &str) -> Option<Entity> {
1269 if let Some(entity) = self.live_entity(run_id) {
1270 return Some(entity);
1271 }
1272 let entity = (self.reloader.as_mut()?)(&mut self.world, run_id)?;
1273 self.by_run_id.insert(run_id.to_string(), entity);
1274 Some(entity)
1275 }
1276
1277 pub fn interactions(&self) -> InteractionHub {
1279 self.interactions.clone()
1280 }
1281
1282 pub fn world_mut(&mut self) -> &mut PipelineWorld {
1284 &mut self.world
1285 }
1286
1287 pub fn register(&mut self, run_id: impl Into<String>, entity: Entity) {
1289 self.by_run_id.insert(run_id.into(), entity);
1290 }
1291
1292 fn live_entity(&self, run_id: &str) -> Option<Entity> {
1294 let entity = *self.by_run_id.get(run_id)?;
1295 self.world.world().get::<AgentState>(entity).map(|_| entity)
1296 }
1297
1298 fn handle_subagent(&mut self, op: SubAgentOp) {
1300 match op {
1301 SubAgentOp::Spawn {
1302 args,
1303 parent_run_id,
1304 max_depth,
1305 reply,
1306 } => {
1307 let _ = reply.send(self.spawn_child(*args, &parent_run_id, max_depth));
1308 }
1309 SubAgentOp::Check { run_id, reply } => {
1310 let status = self
1311 .live_entity(&run_id)
1312 .and_then(|e| self.world.agent_status(e));
1313 let _ = reply.send(status);
1314 }
1315 SubAgentOp::Send {
1316 run_id,
1317 caller_run_id,
1318 content,
1319 target_region,
1320 reply,
1321 } => {
1322 if !self.is_within_tree(&run_id, &caller_run_id) {
1323 let _ = reply.send(false);
1324 return;
1325 }
1326 self.resolve_or_reload(&run_id);
1328 let ok = self
1329 .world
1330 .send_message(AgentMessage {
1331 agent_id: run_id,
1332 content,
1333 target_region,
1334 })
1335 .is_ok();
1336 let _ = reply.send(ok);
1337 }
1338 SubAgentOp::Kill {
1339 run_id,
1340 caller_run_id,
1341 reply,
1342 } => {
1343 let within = self.is_within_tree(&run_id, &caller_run_id);
1344 let _ = reply.send(within && self.cancel_tree(&run_id));
1345 }
1346 }
1347 }
1348
1349 fn spawn_child(
1353 &mut self,
1354 mut args: SpawnArgs,
1355 parent_run_id: &str,
1356 max_depth: usize,
1357 ) -> Result<String, String> {
1358 args.parent_run_id = Some(parent_run_id.to_string());
1360 let parent = self
1361 .live_entity(parent_run_id)
1362 .ok_or_else(|| format!("parent run '{parent_run_id}' is not live"))?;
1363 let parent_depth = self
1364 .world
1365 .world()
1366 .get::<ParentRef>(parent)
1367 .map_or(0, |p| p.depth);
1368 let child_depth = parent_depth + 1;
1369 if child_depth > max_depth {
1370 return Err(format!(
1371 "sub-agent depth limit ({max_depth}) reached; not spawning deeper"
1372 ));
1373 }
1374 let run_id = args.run_id.clone();
1375 let child = match self.spawner.as_mut() {
1376 Some(spawner) => spawner(&mut self.world, &args)?,
1377 None => return Err("this daemon cannot spawn agents".to_string()),
1378 };
1379 let world = self.world.world_mut();
1380 world.entity_mut(child).insert(ParentRef {
1381 parent_entity: parent,
1382 parent_agent_id: parent_run_id.to_string(),
1383 depth: child_depth,
1384 });
1385 match world.get_mut::<SubAgentChildren>(parent) {
1386 Some(mut kids) => kids.children.push(child),
1387 None => {
1388 world.entity_mut(parent).insert(SubAgentChildren {
1389 children: vec![child],
1390 max_child_depth: max_depth,
1391 });
1392 }
1393 }
1394 world
1398 .get_mut::<crate::components::AgentState>(parent)
1399 .expect("a spawning parent always has AgentState")
1400 .spawned_children_ids
1401 .push(run_id.clone());
1402 crate::context_transform::apply_context_transforms(world, parent, child);
1405 self.by_run_id.insert(run_id.clone(), child);
1406 Ok(run_id)
1407 }
1408
1409 fn is_within_tree(&mut self, run_id: &str, ancestor: &str) -> bool {
1433 if run_id == ancestor {
1434 return true;
1435 }
1436 let (Some(target), Some(root)) = (
1439 self.resolve_or_reload(run_id),
1440 self.resolve_or_reload(ancestor),
1441 ) else {
1442 return false;
1443 };
1444 let mut stack = vec![root];
1445 while let Some(e) = stack.pop() {
1446 if e == target {
1447 return true;
1448 }
1449 if let Some(kids) = self.world.world().get::<SubAgentChildren>(e) {
1450 stack.extend(kids.children.iter().copied());
1451 }
1452 }
1453 false
1454 }
1455
1456 fn cancel_tree(&mut self, run_id: &str) -> bool {
1457 let Some(root) = self.resolve_or_reload(run_id) else {
1458 return false;
1459 };
1460 let mut subtree = Vec::new();
1462 let mut stack = vec![root];
1463 while let Some(e) = stack.pop() {
1464 subtree.push(e);
1465 if let Some(kids) = self.world.world().get::<SubAgentChildren>(e) {
1466 stack.extend(kids.children.iter().copied());
1467 }
1468 }
1469 let mut cancelled = false;
1470 for e in subtree {
1471 let agent_id = self
1474 .world
1475 .world()
1476 .get::<AgentState>(e)
1477 .map(|s| s.agent_id.clone());
1478 cancelled |= self.world.cancel(e);
1479 if let Some(agent_id) = agent_id {
1480 self.interactions.cancel_for_agent(&agent_id);
1481 let still_open: HashSet<String> = self
1484 .interactions
1485 .pending()
1486 .into_iter()
1487 .map(|(_, req)| req.id)
1488 .collect();
1489 self.emitted_interactions
1490 .retain(|id| still_open.contains(id));
1491 }
1492 }
1493 cancelled
1494 }
1495
1496 pub fn wait_reason(&self, entity: Entity) -> Option<WaitReason> {
1505 let world = self.world.world();
1506 let state = world.get::<AgentState>(entity)?;
1507 if state.status != AgentStatus::Waiting {
1508 return None;
1509 }
1510 if world
1511 .get::<crate::gate_prompt::AwaitingGatePrompt>(entity)
1512 .is_some()
1513 {
1514 return Some(WaitReason::TaintGate);
1515 }
1516 if world
1517 .get::<crate::interaction_points::AwaitingInteractionPoint>(entity)
1518 .is_some()
1519 {
1520 return Some(WaitReason::InteractionPoint);
1521 }
1522 if let Some(fanout) = world.get::<crate::fanout::FanOutWaiting>(entity) {
1523 return Some(WaitReason::FanOutWorkers {
1524 outstanding: fanout.outstanding(),
1525 });
1526 }
1527 if world
1528 .get::<crate::pipeline::WaitingForChildren>(entity)
1529 .is_some()
1530 {
1531 let outstanding = world
1532 .get::<SubAgentChildren>(entity)
1533 .map(|c| {
1534 c.children
1535 .iter()
1536 .filter(|&&child| {
1537 world
1538 .get::<AgentState>(child)
1539 .is_some_and(|s| !crate::pipeline::is_terminal_status(&s.status))
1540 })
1541 .count()
1542 })
1543 .unwrap_or(0);
1544 return Some(WaitReason::Children { outstanding });
1545 }
1546 if world.get::<AwaitingInteraction>(entity).is_some() {
1547 let kind = self
1550 .interactions
1551 .pending()
1552 .into_iter()
1553 .find(|(agent_id, _)| *agent_id == state.agent_id)
1554 .map(|(_, req)| req.kind);
1555 return Some(match kind {
1556 Some(leviath_core::interaction::InteractionKind::ToolApproval) => {
1557 WaitReason::ToolApproval
1558 }
1559 _ => WaitReason::UserPrompt,
1560 });
1561 }
1562 None
1563 }
1564
1565 fn entry_for(&self, run_id: &str, entity: Entity, state: &AgentState) -> RunListEntry {
1573 let world = self.world.world();
1574 let metadata = world.get::<RunMetadata>(entity);
1575 RunListEntry {
1576 run_id: run_id.to_string(),
1577 status: state.status.clone(),
1578 wait_reason: self.wait_reason(entity),
1579 stage: state.current_stage.clone(),
1580 stage_index: world
1581 .get::<crate::pipeline::StageCursor>(entity)
1582 .map(|c| c.index),
1583 num_stages: metadata.map(|m| m.num_stages),
1584 iteration: state.iteration,
1585 tool_calls: world.get::<TokenTotals>(entity).map_or(0, |t| t.tool_calls),
1586 last_progress_at: world
1587 .get::<crate::pipeline::PersistWatermark>(entity)
1588 .and_then(|w| w.last_progress_at()),
1589 unattended: metadata.is_some_and(|m| m.unattended),
1590 empty_output: world
1591 .get::<crate::persistence::RunOutcomeFlags>(entity)
1592 .is_some_and(|f| crate::persistence::is_empty_output(&state.status, &f.0)),
1593 read_paths: metadata.and_then(|m| m.read_paths),
1594 }
1595 }
1596
1597 fn list(&self) -> Vec<RunListEntry> {
1600 let world = self.world.world();
1601 self.by_run_id
1602 .iter()
1603 .filter_map(|(run_id, &entity)| {
1604 let state = world.get::<AgentState>(entity)?;
1605 Some(self.entry_for(run_id, entity, state))
1606 })
1607 .collect()
1608 }
1609
1610 fn finished(&self) -> Vec<RunListEntry> {
1618 self.finished
1619 .iter()
1620 .map(|(_, entry)| entry.clone())
1621 .collect()
1622 }
1623
1624 pub fn handle(&mut self, op: ControlOp) {
1627 match op {
1628 ControlOp::Spawn { args, reply } => {
1629 let result = match self.spawner.as_mut() {
1630 Some(spawner) => {
1637 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1638 spawner(&mut self.world, &args)
1639 })) {
1640 Ok(Ok(entity)) => {
1641 self.by_run_id.insert(args.run_id.clone(), entity);
1642 Ok(args.run_id.clone())
1643 }
1644 Ok(Err(e)) => Err(e),
1645 Err(_) => Err("agent spawn panicked".to_string()),
1646 }
1647 }
1648 None => Err("this daemon cannot spawn agents".to_string()),
1649 };
1650 if let Err(error) = &result {
1655 tracing::error!(
1656 run_id = %args.run_id,
1657 blueprint = %args.blueprint_path,
1658 workdir = %args.workdir,
1659 error = %error,
1660 "agent spawn failed"
1661 );
1662 }
1663 let _ = reply.send(result);
1664 }
1665 ControlOp::Status { run_id, reply } => {
1666 let status = self
1670 .live_entity(&run_id)
1671 .and_then(|e| self.world.agent_status(e))
1672 .or_else(|| {
1673 self.finished
1674 .iter()
1675 .find(|(_, e)| e.run_id == run_id)
1676 .map(|(_, e)| e.status.clone())
1677 });
1678 let _ = reply.send(status);
1679 }
1680 ControlOp::Pause { run_id, reply } => {
1681 let ok = self
1682 .resolve_or_reload(&run_id)
1683 .is_some_and(|e| self.world.pause(e));
1684 let _ = reply.send(ok);
1685 }
1686 ControlOp::Resume { run_id, reply } => {
1687 let ok = self
1688 .resolve_or_reload(&run_id)
1689 .is_some_and(|e| self.world.resume(e));
1690 let _ = reply.send(ok);
1691 }
1692 ControlOp::Cancel { run_id, reply } => {
1693 let ok = self.cancel_tree(&run_id)
1700 || self
1701 .force_terminator
1702 .as_mut()
1703 .is_some_and(|terminate| terminate(&run_id));
1704 let _ = reply.send(ok);
1705 }
1706 ControlOp::List { reply } => {
1707 let _ = reply.send(RunListing {
1708 runs: self.list(),
1709 finished: self.finished(),
1710 health: self.health(),
1711 });
1712 }
1713 ControlOp::Message {
1714 agent_id,
1715 content,
1716 target_region,
1717 reply,
1718 } => {
1719 self.resolve_or_reload(&agent_id);
1721 let ok = self
1722 .world
1723 .send_message(AgentMessage {
1724 agent_id,
1725 content,
1726 target_region,
1727 })
1728 .is_ok();
1729 let _ = reply.send(ok);
1730 }
1731 ControlOp::ListInteractions { reply } => {
1732 let _ = reply.send(self.interactions.pending());
1733 }
1734 ControlOp::AnswerInteraction { response, reply } => {
1735 let _ = reply.send(self.interactions.answer(response));
1736 }
1737 ControlOp::CancelInteraction { request_id, reply } => {
1738 let _ = reply.send(self.interactions.cancel(&request_id));
1739 }
1740 ControlOp::Shutdown { reply } => {
1741 let _ = reply.send(true);
1744 self.world.shutdown();
1745 }
1746 }
1747 }
1748
1749 pub async fn flush_and_stop(&mut self) {
1754 self.world.flush_and_stop().await;
1755 }
1756
1757 pub async fn serve(&mut self, mut control_rx: UnboundedReceiver<ControlOp>) {
1763 let wake = self.world.wake_handle();
1764 let shutdown = self.world.shutdown_handle();
1765 let mut redrive =
1769 tokio::time::interval_at(tokio::time::Instant::now() + self.redrive, self.redrive);
1770 redrive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1771 'serve: loop {
1772 self.world.run_to_fixed_point();
1773 self.emit_events();
1774 tokio::select! {
1775 _ = wake.notified() => {}
1776 _ = shutdown.notified() => break 'serve,
1777 _ = redrive.tick() => self.observe_redrive(),
1783 op = control_rx.recv() => {
1784 match op {
1785 Some(op) => {
1789 let pre = match &op {
1790 ControlOp::Spawn { args, .. } => {
1791 self.spawn_preprocessor.as_ref().map(|pp| pp(args))
1792 }
1793 _ => None,
1794 };
1795 if let Some(fut) = pre {
1796 fut.await;
1797 }
1798 self.handle(op);
1799 }
1800 None => break 'serve, }
1802 }
1803 Some(sub) = self.subagent_rx.recv() => {
1805 let pre = match &sub {
1808 SubAgentOp::Spawn { args, .. } => {
1809 self.spawn_preprocessor.as_ref().map(|pp| pp(args))
1810 }
1811 _ => None,
1812 };
1813 if let Some(fut) = pre {
1814 fut.await;
1815 }
1816 self.handle_subagent(sub);
1817 }
1818 }
1819 }
1820 self.flush_and_stop().await;
1822 }
1823}
1824
1825#[cfg(test)]
1826mod tests {
1827 use super::*;
1828 use crate::dynamic_interaction::InteractionBackend;
1829 use crate::inference_pool::InferencePoolConfig;
1830 use crate::pipeline::{
1831 AgentBlueprint, ReadyToInfer, StageCursor, StageInference, StageInferences, StageProgress,
1832 StageSetup, StageSetups, ToolService, VisitCounts, WaitingForChildren,
1833 };
1834 use crate::tool_bridge::BoxedToolExec;
1835 use leviath_core::{Region, RegionKind};
1836 use leviath_providers::{
1837 FinishReason, InferenceRequest, InferenceResponse, ModelCapabilities, Provider,
1838 ProviderError, TokenUsage,
1839 };
1840 use std::sync::Arc;
1841 use std::sync::Mutex;
1842 use tokio::runtime::Handle;
1843 use tokio::sync::mpsc;
1844
1845 struct Script {
1846 responses: Mutex<std::collections::VecDeque<InferenceResponse>>,
1847 }
1848 #[async_trait::async_trait]
1849 impl Provider for Script {
1850 async fn infer(
1851 &self,
1852 _req: InferenceRequest,
1853 ) -> leviath_providers::Result<InferenceResponse> {
1854 self.responses
1855 .lock()
1856 .unwrap()
1857 .pop_front()
1858 .ok_or_else(|| ProviderError::Other("exhausted".to_string()))
1859 }
1860 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
1861 1
1862 }
1863 fn max_context_tokens(&self, _m: &str) -> usize {
1864 100_000
1865 }
1866 fn name(&self) -> &str {
1867 "script"
1868 }
1869 fn capabilities(&self, _m: &str) -> ModelCapabilities {
1870 ModelCapabilities::default()
1871 }
1872 }
1873
1874 struct NoTools;
1875 impl ToolService for NoTools {
1876 fn exec_for(
1877 &self,
1878 _e: Entity,
1879 calls: Vec<leviath_providers::ToolCall>,
1880 _progress: crate::pipeline::ToolProgress,
1881 ) -> BoxedToolExec {
1882 Box::new(move || {
1883 Box::pin(async move { calls.into_iter().map(|c| (c.id, String::new())).collect() })
1884 })
1885 }
1886 }
1887
1888 fn text(content: &str) -> InferenceResponse {
1889 InferenceResponse {
1890 content: content.to_string(),
1891 tool_calls: vec![],
1892 tokens_used: TokenUsage {
1893 prompt_tokens: 1,
1894 completion_tokens: 1,
1895 total_tokens: 2,
1896 cached_tokens: 0,
1897 cache_write_tokens: 0,
1898 },
1899 finish_reason: FinishReason::Complete,
1900 }
1901 }
1902
1903 fn host_with(responses: Vec<InferenceResponse>) -> WorldHost {
1904 let mut registry = crate::providers::ProviderRegistry::new();
1905 registry.register(
1906 "script".to_string(),
1907 Arc::new(Script {
1908 responses: Mutex::new(responses.into_iter().collect()),
1909 }),
1910 );
1911 let world = PipelineWorld::new(
1912 registry,
1913 Arc::new(NoTools),
1914 InferencePoolConfig::new(),
1915 1,
1916 None,
1917 Handle::current(),
1918 );
1919 WorldHost::new(world)
1920 }
1921
1922 fn blueprint() -> leviath_core::Blueprint {
1923 let layout = leviath_core::layout::ContextLayout::new(
1924 vec![leviath_core::layout::RegionDefinition::new(
1925 "conversation".to_string(),
1926 RegionKind::Clearable,
1927 10_000,
1928 )],
1929 12_000,
1930 );
1931 let s = leviath_core::Stage::new(
1932 "s".to_string(),
1933 leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string()),
1934 );
1935 leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout)
1936 }
1937
1938 fn window() -> crate::components::ContextWindow {
1939 let mut w = crate::components::ContextWindow::new(10_000);
1940 w.add_region(Region::new(
1941 "conversation".to_string(),
1942 RegionKind::Clearable,
1943 10_000,
1944 ));
1945 w
1946 }
1947
1948 fn agent_state(agent_id: &str) -> AgentState {
1949 AgentState {
1950 agent_id: agent_id.to_string(),
1951 current_stage: "s".to_string(),
1952 iteration: 0,
1953 status: AgentStatus::Active,
1954 spawned_children_ids: vec![],
1955 pending_wait: None,
1956 accepts_messages: true,
1957 }
1958 }
1959
1960 fn si() -> StageInference {
1961 StageInference {
1962 provider_name: "script".to_string(),
1963 model: "m".to_string(),
1964 tools: vec![],
1965 tool_filter: None,
1966 fallbacks: Vec::new(),
1967 }
1968 }
1969
1970 fn setup() -> StageSetup {
1971 StageSetup {
1972 inference_config: crate::components::InferenceConfig {
1973 temperature: None,
1974 max_output_tokens: None,
1975 extra_params: Default::default(),
1976 batch_tool_hint: false,
1977 shell_hint: false,
1978 request_timeout_secs: None,
1979 },
1980 routing: None,
1981 accepts_messages: true,
1982 context_layout: None,
1983 system_prompt: None,
1984 }
1985 }
1986
1987 fn spawn(host: &mut WorldHost, run_id: &str, agent_id: &str) -> Entity {
1989 let e = host.world_mut().spawn_agent((
1990 AgentBlueprint(blueprint()),
1991 StageCursor { index: 0 },
1992 agent_state(agent_id),
1993 crate::components::MessageInbox::default(),
1994 StageProgress::default(),
1995 StageInferences(vec![si()]),
1996 StageSetups(vec![setup()]),
1997 VisitCounts::default(),
1998 window(),
1999 si(),
2000 setup().inference_config,
2001 ReadyToInfer,
2002 ));
2003 host.register(run_id, e);
2004 e
2005 }
2006
2007 fn recording_terminator(seen: Arc<Mutex<Vec<String>>>) -> ForceTerminator {
2012 Box::new(move |run_id| {
2013 seen.lock().unwrap().push(run_id.to_string());
2014 run_id != "never-existed"
2015 })
2016 }
2017
2018 fn paging_reloader() -> Reloader {
2020 Box::new(|world, run_id| Some(world.spawn_agent((agent_state(run_id),))))
2021 }
2022
2023 async fn ask<T>(host: &mut WorldHost, make: impl FnOnce(oneshot::Sender<T>) -> ControlOp) -> T {
2024 let (tx, rx) = oneshot::channel();
2025 host.handle(make(tx));
2026 rx.await.unwrap()
2027 }
2028
2029 struct Hangs {
2036 hang: bool,
2037 }
2038 #[async_trait::async_trait]
2039 impl Provider for Hangs {
2040 async fn infer(
2041 &self,
2042 _req: InferenceRequest,
2043 ) -> leviath_providers::Result<InferenceResponse> {
2044 if self.hang {
2045 std::future::pending().await
2046 } else {
2047 Err(ProviderError::Other("not hanging".to_string()))
2048 }
2049 }
2050 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
2051 1
2052 }
2053 fn max_context_tokens(&self, _m: &str) -> usize {
2054 100_000
2055 }
2056 fn name(&self) -> &str {
2057 "hangs"
2058 }
2059 fn capabilities(&self, _m: &str) -> ModelCapabilities {
2060 ModelCapabilities::default()
2061 }
2062 }
2063
2064 #[tokio::test]
2068 async fn the_hanging_provider_answers_everything_except_a_hanging_infer() {
2069 fn request() -> InferenceRequest {
2070 InferenceRequest {
2071 system: vec![],
2072 messages: vec![],
2073 model: "m".to_string(),
2074 max_tokens: 1,
2075 temperature: 0.0,
2076 tools: vec![],
2077 extra: serde_json::Value::Null,
2078 request_timeout_secs: None,
2079 }
2080 }
2081 let p = Hangs { hang: true };
2082 assert_eq!(p.name(), "hangs");
2083 assert_eq!(p.count_tokens("t", "m").await, 1);
2084 assert_eq!(p.max_context_tokens("m"), 100_000);
2085 let _ = p.capabilities("m");
2086 assert!(
2087 tokio::time::timeout(std::time::Duration::from_millis(20), p.infer(request()))
2088 .await
2089 .is_err(),
2090 "hanging: the whole point is that the call never lands"
2091 );
2092 assert!(Hangs { hang: false }.infer(request()).await.is_err());
2094 }
2095
2096 fn host_with_full_pool(limit: usize) -> WorldHost {
2100 let mut registry = crate::providers::ProviderRegistry::new();
2101 registry.register("script".to_string(), Arc::new(Hangs { hang: true }));
2102 let mut pools = InferencePoolConfig::new();
2103 pools.set_limit("m", limit);
2104 WorldHost::new(PipelineWorld::new(
2105 registry,
2106 Arc::new(NoTools),
2107 pools,
2108 1,
2109 None,
2110 Handle::current(),
2111 ))
2112 }
2113
2114 const PARK: std::time::Duration = std::time::Duration::from_millis(250);
2118
2119 async fn serve_until_inferring(
2128 host: &mut WorldHost,
2129 rounds: usize,
2130 park: std::time::Duration,
2131 entity: Entity,
2132 ) -> bool {
2133 let wake = host.world_mut().wake_handle();
2134 for _ in 0..rounds {
2135 host.world_mut().run_to_fixed_point();
2136 if is_inferring(host, entity) {
2137 return true;
2138 }
2139 if tokio::time::timeout(park, wake.notified()).await.is_err() {
2140 break; }
2142 }
2143 false
2144 }
2145
2146 fn is_inferring(host: &mut WorldHost, entity: Entity) -> bool {
2148 host.world_mut()
2149 .world()
2150 .get::<crate::pipeline::AwaitingInference>(entity)
2151 .is_some()
2152 }
2153
2154 #[tokio::test]
2165 async fn releasing_a_cancelled_runs_permit_wakes_the_starved_agent_behind_it() {
2166 let mut host = host_with_full_pool(1);
2167
2168 let holder = spawn(&mut host, "run-a", "agent-a");
2171 host.world_mut().run_to_fixed_point();
2172 assert!(is_inferring(&mut host, holder), "the holder takes the slot");
2173
2174 let starved = spawn(&mut host, "run-b", "agent-b");
2175 host.world_mut().run_to_fixed_point();
2176 assert!(
2177 !is_inferring(&mut host, starved),
2178 "the second agent is starved on the full pool"
2179 );
2180 assert!(
2185 !serve_until_inferring(&mut host, 3, PARK, starved).await,
2186 "no slot, no dispatch"
2187 );
2188
2189 assert!(
2192 ask(&mut host, |reply| ControlOp::Cancel {
2193 run_id: "run-a".to_string(),
2194 reply,
2195 })
2196 .await
2197 );
2198
2199 assert!(
2200 serve_until_inferring(&mut host, 8, PARK, starved).await,
2201 "the freed slot must wake the loop so the starved agent can take it; \
2202 without that wake the daemon parks with capacity it cannot see"
2203 );
2204 }
2205
2206 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2211 async fn serve_redrives_the_world_on_its_own_timer_with_no_wake() {
2212 use std::sync::atomic::{AtomicUsize, Ordering};
2213
2214 static TICKS: AtomicUsize = AtomicUsize::new(0);
2218 TICKS.store(0, Ordering::SeqCst);
2219 fn count_ticks() {
2220 TICKS.fetch_add(1, Ordering::SeqCst);
2221 }
2222
2223 let mut host = host_with(vec![]);
2224 host.world_mut().add_test_system(count_ticks);
2225 host.set_redrive_interval(std::time::Duration::from_millis(20));
2226 let shutdown = host.world_mut().shutdown_handle();
2227
2228 let (op_tx, op_rx) = mpsc::unbounded_channel();
2229 let handle = tokio::spawn(async move {
2230 host.serve(op_rx).await;
2231 });
2232
2233 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
2237 let ticks = TICKS.load(Ordering::SeqCst);
2238 shutdown.notify_one();
2239 drop(op_tx);
2240 handle.await.unwrap();
2241
2242 assert!(
2243 ticks > 3,
2244 "the timer must keep driving the world with nothing waking it; saw {ticks} ticks"
2245 );
2246 }
2247
2248 fn two_stage_blueprint() -> leviath_core::Blueprint {
2252 let layout = leviath_core::layout::ContextLayout::new(
2253 vec![leviath_core::layout::RegionDefinition::new(
2254 "conversation".to_string(),
2255 RegionKind::Clearable,
2256 10_000,
2257 )],
2258 12_000,
2259 );
2260 let model =
2261 leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string());
2262 let mut one = leviath_core::Stage::new("one".to_string(), model.clone());
2268 one.max_iterations = Some(1);
2269 let mut two = leviath_core::Stage::new("two".to_string(), model);
2270 two.max_iterations = Some(1);
2271 let stages = vec![one, two];
2272 leviath_core::Blueprint::new("t".to_string(), "d".to_string(), stages, layout)
2273 }
2274
2275 fn spawn_two_stage(host: &mut WorldHost, run_id: &str, agent_id: &str) -> Entity {
2277 let mut state = agent_state(agent_id);
2278 state.current_stage = "one".to_string();
2279 let e = host.world_mut().spawn_agent((
2280 AgentBlueprint(two_stage_blueprint()),
2281 StageCursor { index: 0 },
2282 state,
2283 crate::components::MessageInbox::default(),
2284 StageProgress::default(),
2285 StageInferences(vec![si(), si()]),
2286 StageSetups(vec![setup(), setup()]),
2287 VisitCounts::default(),
2288 window(),
2289 si(),
2290 setup().inference_config,
2291 ReadyToInfer,
2292 ));
2293 host.register(run_id, e);
2294 e
2295 }
2296
2297 fn tool_call(id: &str) -> InferenceResponse {
2300 InferenceResponse {
2301 tool_calls: vec![leviath_providers::ToolCall {
2302 id: id.to_string(),
2303 name: "noop".to_string(),
2304 arguments: serde_json::Value::Null,
2305 thought_signature: None,
2306 }],
2307 ..text("working")
2308 }
2309 }
2310
2311 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2322 async fn a_stage_boundary_is_crossed_without_waiting_for_the_redrive() {
2323 let mut host = host_with(vec![tool_call("c1"), tool_call("c2")]);
2324 host.set_redrive_interval(std::time::Duration::from_secs(3600));
2325 spawn_two_stage(&mut host, "run-a", "agent-a");
2326
2327 let mut events = host.subscribe();
2328 let shutdown = host.world_mut().shutdown_handle();
2329 let (op_tx, op_rx) = mpsc::unbounded_channel();
2330 let handle = tokio::spawn(async move { host.serve(op_rx).await });
2331
2332 let completed = tokio::time::timeout(std::time::Duration::from_secs(5), async {
2336 loop {
2337 let event = events
2338 .recv()
2339 .await
2340 .expect("the event stream must outlive the run");
2341 if let WorldEvent::Completed { status, .. } = event {
2342 break status;
2343 }
2344 }
2345 })
2346 .await;
2347
2348 shutdown.notify_one();
2349 drop(op_tx);
2350 handle.await.unwrap();
2351
2352 assert_eq!(
2353 completed.expect("the run must reach stage two and finish on wakes alone"),
2354 "complete"
2355 );
2356 }
2357
2358 #[tokio::test]
2361 async fn the_lane_heartbeat_distinguishes_pressure_from_idle() {
2362 leviath_testkit::with_tracing(|| async {
2363 let mut host = host_with_full_pool(1);
2365 let idle = host.world_mut().lane_snapshot();
2366 assert!(!idle.is_under_pressure(), "an empty world is not pressured");
2367 assert_eq!(idle.inference_summary(), "none");
2368 host.log_lane_pressure(&idle); spawn(&mut host, "run-a", "agent-a");
2372 spawn(&mut host, "run-b", "agent-b");
2373 host.world_mut().run_to_fixed_point();
2374
2375 let busy = host.world_mut().lane_snapshot();
2376 assert_eq!(busy.agents.active, 2);
2377 assert_eq!(busy.inference_summary(), "m=1/1");
2378 assert!(
2379 busy.is_under_pressure(),
2380 "a full pool with active agents is exactly the state worth reporting"
2381 );
2382 host.log_lane_pressure(&busy); })
2384 .await;
2385 }
2386
2387 #[tokio::test]
2391 async fn re_drives_that_go_nowhere_under_pressure_count_as_dead_cycles() {
2392 leviath_testkit::with_tracing(|| async {
2393 let mut host = host_with_full_pool(1);
2396 spawn(&mut host, "run-a", "agent-a");
2397 spawn(&mut host, "run-b", "agent-b");
2398 host.world_mut().run_to_fixed_point();
2399 host.emit_events();
2400
2401 host.observe_redrive();
2403 assert_eq!(host.dead_cycles, 0, "the first cycle sets the baseline");
2404
2405 host.observe_redrive();
2406 assert_eq!(host.dead_cycles, 1, "a whole interval, nothing moved");
2407 host.observe_redrive();
2408 assert_eq!(host.dead_cycles, 2, "and another - this is the `warn` arm");
2409 })
2410 .await;
2411 }
2412
2413 #[tokio::test]
2416 async fn a_run_that_moves_clears_the_dead_cycle_count() {
2417 let mut host = host_with_full_pool(1);
2418 let entity = spawn(&mut host, "run-a", "agent-a");
2419 spawn(&mut host, "run-b", "agent-b");
2420 host.world_mut().run_to_fixed_point();
2421 host.emit_events();
2422 host.observe_redrive();
2423 host.observe_redrive();
2424 assert_eq!(host.dead_cycles, 1, "wedged to begin with");
2425
2426 host.world_mut()
2429 .world_mut()
2430 .get_mut::<AgentState>(entity)
2431 .expect("the agent is loaded")
2432 .iteration += 1;
2433 host.emit_events();
2434
2435 host.observe_redrive();
2436 assert_eq!(host.dead_cycles, 0, "something moved");
2437 }
2438
2439 async fn wedge_the_tool_lane(host: &mut WorldHost) -> crate::cancel::CancelToken {
2445 let snapshot = host.world_mut().lane_snapshot();
2446 let stage = host
2447 .world_mut()
2448 .world()
2449 .resource::<crate::pipeline::ToolStage>()
2450 .clone();
2451 let release = crate::cancel::CancelToken::new();
2455 let submit = |exec: crate::tool_bridge::BoxedToolExec| {
2456 stage.stats.enqueued();
2457 stage
2458 .jobs
2459 .send(crate::tool_bridge::ToolJob {
2460 entity: Entity::from_raw_u32(9_001).expect("a small index is a valid id"),
2463 exec,
2464 cancel: crate::cancel::CancelToken::new(),
2465 })
2466 .expect("the lane is serving");
2467 };
2468 let blocker = || {
2473 let held = release.clone();
2474 submit(Box::new(move || {
2475 Box::pin(async move {
2476 held.cancelled().await;
2477 Vec::new()
2478 })
2479 }));
2480 };
2481 for _ in 0..snapshot.tools_workers.saturating_sub(snapshot.tools_busy) {
2484 blocker();
2485 }
2486 await_full_lane(host).await;
2490 blocker(); await_saturation(host).await;
2492 release
2493 }
2494
2495 async fn await_full_lane(host: &mut WorldHost) {
2497 await_lane(host, "the lane filled up", |snapshot| {
2498 snapshot.tools_busy >= snapshot.tools_workers
2499 })
2500 .await;
2501 }
2502
2503 async fn await_saturation(host: &mut WorldHost) {
2505 await_lane(host, "the lane saturated", |snapshot| {
2506 snapshot.tools_saturated
2507 })
2508 .await;
2509 }
2510
2511 async fn await_drained_queue(host: &mut WorldHost) {
2513 await_lane(host, "the queued batch got in", |snapshot| {
2514 snapshot.tools_queued == 0
2515 })
2516 .await;
2517 }
2518
2519 async fn await_lane(
2522 host: &mut WorldHost,
2523 context: &str,
2524 done: fn(&crate::world::LaneSnapshot) -> bool,
2525 ) {
2526 tokio::time::timeout(std::time::Duration::from_secs(30), async {
2527 while !done(&host.world_mut().lane_snapshot()) {
2528 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2529 }
2530 })
2531 .await
2532 .expect(context);
2533 }
2534
2535 async fn release_the_lane(host: &mut WorldHost, releases: &[crate::cancel::CancelToken]) {
2542 for release in releases {
2543 release.cancel();
2544 }
2545 await_lane(host, "the lane emptied", |snapshot| {
2546 snapshot.tools_busy == 0 && snapshot.tools_queued == 0
2547 })
2548 .await;
2549 }
2550
2551 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2559 async fn a_lane_that_never_drains_is_widened_rather_than_emptied() {
2560 leviath_testkit::with_tracing(|| async {
2561 let mut host = host_with_full_pool(1);
2562 host.set_dead_cycles_before_relief(2);
2563 let release = wedge_the_tool_lane(&mut host).await;
2564
2565 host.observe_redrive(); host.observe_redrive(); assert_eq!(host.relief_granted, 0, "still inside the grace period");
2568 host.observe_redrive(); assert_eq!(host.relief_granted, 1, "the lane got wider");
2570 assert_eq!(
2571 host.dead_cycles, 0,
2572 "the streak restarts so relief is not granted again immediately"
2573 );
2574 assert_eq!(host.health().tools_workers, 2);
2575
2576 await_drained_queue(&mut host).await;
2579 assert_eq!(host.world_mut().lane_snapshot().tools_busy, 2);
2580 release_the_lane(&mut host, &[release]).await;
2581 })
2582 .await;
2583 }
2584
2585 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2588 async fn relief_stops_after_one_extra_lane_s_worth() {
2589 leviath_testkit::with_tracing(|| async {
2590 let mut host = host_with_full_pool(1);
2591 host.set_dead_cycles_before_relief(1);
2592 let release = wedge_the_tool_lane(&mut host).await;
2593
2594 host.observe_redrive();
2595 host.observe_redrive();
2596 assert_eq!(host.relief_granted, 1);
2597
2598 let release_two = wedge_the_tool_lane(&mut host).await;
2601 for _ in 0..4 {
2602 host.observe_redrive();
2603 }
2604 assert_eq!(host.relief_granted, 1, "the budget was already spent");
2605 release_the_lane(&mut host, &[release, release_two]).await;
2606 })
2607 .await;
2608 }
2609
2610 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2613 async fn relief_can_be_turned_off_without_turning_off_detection() {
2614 leviath_testkit::with_tracing(|| async {
2615 let mut host = host_with_full_pool(1);
2616 host.set_dead_cycles_before_relief(0);
2617 let release = wedge_the_tool_lane(&mut host).await;
2618
2619 for _ in 0..4 {
2620 host.observe_redrive();
2621 }
2622 assert_eq!(host.relief_granted, 0, "relief is disabled");
2623 assert_eq!(host.dead_cycles, 3, "but the streak is still counted");
2624 release_the_lane(&mut host, &[release]).await;
2625 })
2626 .await;
2627 }
2628
2629 #[tokio::test]
2634 async fn each_re_drive_reports_lane_health_to_the_telemetry_sink() {
2635 let sink = Arc::new(leviath_core::telemetry::MemorySink::default());
2636 let mut host = host_with_full_pool(1);
2637 host.world_mut()
2638 .world_mut()
2639 .insert_resource(crate::telemetry::Telemetry(sink.clone()));
2640 spawn(&mut host, "run-a", "agent-a");
2641 spawn(&mut host, "run-b", "agent-b");
2642 host.world_mut().run_to_fixed_point();
2643 host.emit_events();
2644
2645 host.observe_redrive();
2646 host.observe_redrive();
2647
2648 let samples = sink.lane_samples();
2649 assert_eq!(samples.len(), 2, "one per re-drive");
2650 assert_eq!(samples[0].dead_cycles, 0);
2651 assert_eq!(samples[1].dead_cycles, 1, "the streak is carried through");
2652 assert_eq!(samples[1].agents_active, 2);
2653 }
2654
2655 #[tokio::test]
2659 async fn each_re_drive_reports_providers_out_of_service() {
2660 let sink = Arc::new(leviath_core::telemetry::MemorySink::default());
2661 let mut host = host_with(vec![]);
2662 host.world_mut()
2663 .world_mut()
2664 .insert_resource(crate::telemetry::Telemetry(sink.clone()));
2665 let policy = crate::pipeline::CircuitPolicy {
2666 failures_before_open: 1,
2667 cooldown_secs: 300,
2668 };
2669 let mut circuits = crate::pipeline::ProviderCircuits::default();
2670 circuits.record_failure(
2671 "openrouter",
2672 leviath_providers::UnavailableReason::CreditsExhausted,
2673 chrono::Utc::now().timestamp(),
2674 &policy,
2675 );
2676 host.world_mut().world_mut().insert_resource(circuits);
2677 host.world_mut().world_mut().insert_resource(policy);
2678
2679 host.observe_redrive();
2680
2681 let samples = sink.provider_samples();
2682 assert_eq!(samples.len(), 1);
2683 assert_eq!(samples[0].len(), 1);
2684 assert_eq!(samples[0][0].provider, "openrouter");
2685 assert_eq!(samples[0][0].reason, "credits-exhausted");
2686 assert_eq!(samples[0][0].consecutive_failures, 1);
2687 assert!(samples[0][0].retry_in_secs > 0);
2688 assert_eq!(host.health().providers_down.len(), 1);
2690
2691 host.world_mut()
2693 .world_mut()
2694 .resource_mut::<crate::pipeline::ProviderCircuits>()
2695 .record_success("openrouter");
2696 host.observe_redrive();
2697 assert!(sink.provider_samples()[1].is_empty());
2698 assert!(host.health().providers_down.is_empty());
2699 }
2700
2701 #[tokio::test]
2705 async fn an_idle_daemon_never_counts_a_dead_cycle() {
2706 let mut host = host_with_full_pool(1);
2707 host.emit_events();
2708 for _ in 0..3 {
2709 host.observe_redrive();
2710 }
2711 assert_eq!(host.dead_cycles, 0, "no pressure, no dead cycles");
2712 }
2713
2714 #[tokio::test]
2718 async fn the_lane_snapshot_counts_agents_by_status() {
2719 let mut host = host_with(vec![]);
2720 let active = spawn(&mut host, "run-active", "a");
2721 let paused = spawn(&mut host, "run-paused", "b");
2722 let waiting = spawn(&mut host, "run-waiting", "c");
2723 let done = spawn(&mut host, "run-done", "d");
2724 let idle = spawn(&mut host, "run-idle", "e");
2725 host.world_mut().set_status(paused, AgentStatus::Paused);
2726 host.world_mut().set_status(waiting, AgentStatus::Waiting);
2727 host.world_mut().set_status(done, AgentStatus::Complete);
2728 host.world_mut().set_status(idle, AgentStatus::Idle);
2729
2730 let counts = host.world_mut().lane_snapshot().agents;
2731 assert_eq!(counts.active, 1);
2732 assert_eq!(counts.paused, 1);
2733 assert_eq!(counts.waiting, 1);
2734 assert_eq!(counts.terminal, 1);
2735 assert_eq!(counts.idle, 1);
2736 assert_eq!(
2737 counts.to_string(),
2738 "active=1 waiting=1 paused=1 idle=1 terminal=1"
2739 );
2740 host.world_mut().set_status(active, AgentStatus::Cancelled);
2742 host.world_mut().set_status(
2743 paused,
2744 AgentStatus::Error {
2745 message: "boom".to_string(),
2746 },
2747 );
2748 assert_eq!(host.world_mut().lane_snapshot().agents.terminal, 3);
2749 }
2750
2751 #[tokio::test]
2752 async fn status_and_list_reflect_registered_runs() {
2753 let mut host = host_with(vec![]);
2754 spawn(&mut host, "run-a", "agent-a");
2755
2756 let status = ask(&mut host, |reply| ControlOp::Status {
2757 run_id: "run-a".to_string(),
2758 reply,
2759 })
2760 .await;
2761 assert_eq!(status, Some(AgentStatus::Active));
2762
2763 let list = ask(&mut host, |reply| ControlOp::List { reply }).await.runs;
2764 assert_eq!(list.len(), 1);
2765 assert_eq!(list[0].run_id, "run-a");
2766 assert_eq!(list[0].status, AgentStatus::Active);
2767 assert_eq!(list[0].wait_reason, None);
2769
2770 let none = ask(&mut host, |reply| ControlOp::Status {
2772 run_id: "ghost".to_string(),
2773 reply,
2774 })
2775 .await;
2776 assert_eq!(none, None);
2777 }
2778
2779 #[tokio::test]
2780 async fn pause_resume_cancel_by_run_id() {
2781 let mut host = host_with(vec![]);
2782 spawn(&mut host, "run-a", "agent-a");
2783
2784 assert!(
2785 ask(&mut host, |reply| ControlOp::Pause {
2786 run_id: "run-a".to_string(),
2787 reply
2788 })
2789 .await
2790 );
2791 assert_eq!(
2792 host.world.agent_status(host.by_run_id["run-a"]),
2793 Some(AgentStatus::Paused)
2794 );
2795
2796 assert!(
2798 !ask(&mut host, |reply| ControlOp::Pause {
2799 run_id: "run-a".to_string(),
2800 reply
2801 })
2802 .await
2803 );
2804
2805 assert!(
2806 ask(&mut host, |reply| ControlOp::Resume {
2807 run_id: "run-a".to_string(),
2808 reply
2809 })
2810 .await
2811 );
2812 assert_eq!(
2813 host.world.agent_status(host.by_run_id["run-a"]),
2814 Some(AgentStatus::Active)
2815 );
2816 assert!(
2817 ask(&mut host, |reply| ControlOp::Cancel {
2818 run_id: "run-a".to_string(),
2819 reply
2820 })
2821 .await
2822 );
2823 assert_eq!(
2824 host.world.agent_status(host.by_run_id["run-a"]),
2825 Some(AgentStatus::Cancelled)
2826 );
2827
2828 assert!(
2830 !ask(&mut host, |reply| ControlOp::Pause {
2831 run_id: "ghost".to_string(),
2832 reply
2833 })
2834 .await
2835 );
2836 assert!(
2837 !ask(&mut host, |reply| ControlOp::Resume {
2838 run_id: "ghost".to_string(),
2839 reply
2840 })
2841 .await
2842 );
2843 assert!(
2844 !ask(&mut host, |reply| ControlOp::Cancel {
2845 run_id: "ghost".to_string(),
2846 reply
2847 })
2848 .await
2849 );
2850 }
2851
2852 #[tokio::test]
2853 async fn spawn_op_uses_installed_spawner_and_registers() {
2854 let mut host = host_with(vec![]);
2855 host.set_spawner(Box::new(|world, args| {
2856 Ok(world.spawn_agent((agent_state(&args.run_id),)))
2857 }));
2858
2859 let result = ask(&mut host, |reply| ControlOp::Spawn {
2860 args: Box::new(SpawnArgs {
2861 run_id: "r1".to_string(),
2862 ..Default::default()
2863 }),
2864 reply,
2865 })
2866 .await;
2867 assert_eq!(result, Ok("r1".to_string()));
2868
2869 let status = ask(&mut host, |reply| ControlOp::Status {
2871 run_id: "r1".to_string(),
2872 reply,
2873 })
2874 .await;
2875 assert_eq!(status, Some(AgentStatus::Active));
2876 }
2877
2878 #[tokio::test]
2879 async fn spawn_op_propagates_spawner_error() {
2880 let mut host = host_with(vec![]);
2881 host.set_spawner(Box::new(|_world, _args| Err("bad blueprint".to_string())));
2882 let result = ask(&mut host, |reply| ControlOp::Spawn {
2883 args: Box::new(SpawnArgs::default()),
2884 reply,
2885 })
2886 .await;
2887 assert_eq!(result, Err("bad blueprint".to_string()));
2888 }
2889
2890 #[tokio::test]
2891 async fn spawn_op_contains_a_panicking_spawner() {
2892 let mut host = host_with(vec![]);
2895 host.set_spawner(Box::new(|_world, _args| panic!("simulated spawn panic")));
2896 let (tx, rx) = oneshot::channel();
2897 crate::test_support::with_silenced_panics(|| {
2898 host.handle(ControlOp::Spawn {
2899 args: Box::new(SpawnArgs::default()),
2900 reply: tx,
2901 });
2902 });
2903 assert_eq!(rx.await.unwrap(), Err("agent spawn panicked".to_string()));
2904 let status = ask(&mut host, |reply| ControlOp::Status {
2906 run_id: SpawnArgs::default().run_id,
2907 reply,
2908 })
2909 .await;
2910 assert!(status.is_none());
2911 }
2912
2913 #[tokio::test]
2914 async fn spawn_op_errors_without_a_spawner() {
2915 let mut host = host_with(vec![]);
2916 let result = ask(&mut host, |reply| ControlOp::Spawn {
2917 args: Box::new(SpawnArgs::default()),
2918 reply,
2919 })
2920 .await;
2921 assert!(result.unwrap_err().contains("cannot spawn"));
2922 }
2923
2924 async fn ask_sub<T>(
2927 host: &mut WorldHost,
2928 make: impl FnOnce(oneshot::Sender<T>) -> SubAgentOp,
2929 ) -> T {
2930 let (tx, rx) = oneshot::channel();
2931 host.handle_subagent(make(tx));
2932 rx.await.unwrap()
2933 }
2934
2935 fn child_spawner() -> Spawner {
2937 Box::new(|world, args| Ok(world.spawn_agent((agent_state(&args.run_id),))))
2938 }
2939
2940 #[tokio::test]
2941 async fn subagent_spawn_links_child_and_registers() {
2942 let mut host = host_with(vec![]);
2943 host.set_spawner(child_spawner());
2944 let parent = spawn(&mut host, "parent", "parent");
2945
2946 let result = ask_sub(&mut host, |reply| SubAgentOp::Spawn {
2947 args: Box::new(SpawnArgs {
2948 run_id: "child".to_string(),
2949 ..Default::default()
2950 }),
2951 parent_run_id: "parent".to_string(),
2952 max_depth: 3,
2953 reply,
2954 })
2955 .await;
2956 assert_eq!(result, Ok("child".to_string()));
2957
2958 let child = host.by_run_id["child"];
2959 let pref = host.world.world().get::<ParentRef>(child).unwrap();
2961 assert_eq!(pref.parent_entity, parent);
2962 assert_eq!(pref.depth, 1);
2963 let kids = host.world.world().get::<SubAgentChildren>(parent).unwrap();
2965 assert_eq!(kids.children, vec![child]);
2966 }
2967
2968 #[tokio::test]
2969 async fn subagent_spawn_appends_to_existing_children() {
2970 let mut host = host_with(vec![]);
2971 host.set_spawner(child_spawner());
2972 spawn(&mut host, "parent", "parent");
2973 for id in ["c1", "c2"] {
2974 let r = ask_sub(&mut host, |reply| SubAgentOp::Spawn {
2975 args: Box::new(SpawnArgs {
2976 run_id: id.to_string(),
2977 ..Default::default()
2978 }),
2979 parent_run_id: "parent".to_string(),
2980 max_depth: 3,
2981 reply,
2982 })
2983 .await;
2984 assert!(r.is_ok());
2985 }
2986 let parent = host.by_run_id["parent"];
2987 let kids = host.world.world().get::<SubAgentChildren>(parent).unwrap();
2988 assert_eq!(kids.children.len(), 2);
2989 }
2990
2991 #[tokio::test]
2992 async fn subagent_spawn_rejects_beyond_max_depth() {
2993 let mut host = host_with(vec![]);
2994 host.set_spawner(child_spawner());
2995 spawn(&mut host, "parent", "parent");
2996 let result = ask_sub(&mut host, |reply| SubAgentOp::Spawn {
2997 args: Box::new(SpawnArgs {
2998 run_id: "child".to_string(),
2999 ..Default::default()
3000 }),
3001 parent_run_id: "parent".to_string(),
3002 max_depth: 0, reply,
3004 })
3005 .await;
3006 assert!(result.unwrap_err().contains("depth limit"));
3007 assert!(!host.by_run_id.contains_key("child"));
3008 }
3009
3010 #[tokio::test]
3011 async fn subagent_spawn_unknown_parent_and_no_spawner_and_spawner_error() {
3012 let mut host = host_with(vec![]);
3014 host.set_spawner(child_spawner());
3015 let r = ask_sub(&mut host, |reply| SubAgentOp::Spawn {
3016 args: Box::new(SpawnArgs::default()),
3017 parent_run_id: "ghost".to_string(),
3018 max_depth: 3,
3019 reply,
3020 })
3021 .await;
3022 assert!(r.unwrap_err().contains("not live"));
3023
3024 let mut host2 = host_with(vec![]);
3026 spawn(&mut host2, "parent", "parent");
3027 let r = ask_sub(&mut host2, |reply| SubAgentOp::Spawn {
3028 args: Box::new(SpawnArgs::default()),
3029 parent_run_id: "parent".to_string(),
3030 max_depth: 3,
3031 reply,
3032 })
3033 .await;
3034 assert!(r.unwrap_err().contains("cannot spawn"));
3035
3036 let mut host3 = host_with(vec![]);
3038 host3.set_spawner(Box::new(|_w, _a| Err("bad blueprint".to_string())));
3039 spawn(&mut host3, "parent", "parent");
3040 let r = ask_sub(&mut host3, |reply| SubAgentOp::Spawn {
3041 args: Box::new(SpawnArgs::default()),
3042 parent_run_id: "parent".to_string(),
3043 max_depth: 3,
3044 reply,
3045 })
3046 .await;
3047 assert_eq!(r, Err("bad blueprint".to_string()));
3048 }
3049
3050 #[tokio::test]
3051 async fn subagent_check_reports_status_or_none() {
3052 let mut host = host_with(vec![]);
3053 spawn(&mut host, "run-a", "run-a");
3054 let status = ask_sub(&mut host, |reply| SubAgentOp::Check {
3055 run_id: "run-a".to_string(),
3056 reply,
3057 })
3058 .await;
3059 assert_eq!(status, Some(AgentStatus::Active));
3060
3061 let none = ask_sub(&mut host, |reply| SubAgentOp::Check {
3062 run_id: "ghost".to_string(),
3063 reply,
3064 })
3065 .await;
3066 assert_eq!(none, None);
3067 }
3068
3069 #[tokio::test]
3077 async fn subagent_ops_reach_a_run_the_caller_spawned() {
3078 let mut host = host_with(vec![]);
3079 let parent = spawn(&mut host, "parent", "parent");
3080 let child = spawn(&mut host, "child", "child");
3081 host.world_mut()
3082 .world_mut()
3083 .entity_mut(parent)
3084 .insert(SubAgentChildren {
3085 children: vec![child],
3086 max_child_depth: 3,
3087 });
3088
3089 let delivered = ask_sub(&mut host, |reply| SubAgentOp::Send {
3090 run_id: "child".to_string(),
3091 caller_run_id: "parent".to_string(),
3092 content: "carry on".to_string(),
3093 target_region: None,
3094 reply,
3095 })
3096 .await;
3097 assert!(delivered, "a run we spawned is ours to message");
3098 }
3099
3100 #[tokio::test]
3101 async fn subagent_ops_refuse_a_run_outside_the_callers_tree() {
3102 let mut host = host_with(vec![]);
3103 spawn(&mut host, "run-a", "run-a");
3104 spawn(&mut host, "outsider", "outsider");
3105
3106 let delivered = ask_sub(&mut host, |reply| SubAgentOp::Send {
3107 run_id: "outsider".to_string(),
3108 caller_run_id: "run-a".to_string(),
3109 content: "take this".to_string(),
3110 target_region: None,
3111 reply,
3112 })
3113 .await;
3114 assert!(!delivered, "a run we did not spawn is not ours to message");
3115
3116 let killed = ask_sub(&mut host, |reply| SubAgentOp::Kill {
3117 run_id: "outsider".to_string(),
3118 caller_run_id: "run-a".to_string(),
3119 reply,
3120 })
3121 .await;
3122 assert!(!killed, "nor ours to cancel");
3123
3124 let phantom = ask_sub(&mut host, |reply| SubAgentOp::Send {
3127 run_id: "no-such-run".to_string(),
3128 caller_run_id: "run-a".to_string(),
3129 content: "hello?".to_string(),
3130 target_region: None,
3131 reply,
3132 })
3133 .await;
3134 assert!(!phantom, "an unknown run id is in nobody's tree");
3135 }
3136
3137 #[tokio::test]
3138 async fn subagent_send_delivers_to_inbox() {
3139 let mut host = host_with(vec![]);
3140 spawn(&mut host, "run-a", "run-a");
3141 let ok = ask_sub(&mut host, |reply| SubAgentOp::Send {
3142 run_id: "run-a".to_string(),
3143 caller_run_id: "run-a".to_string(),
3144 content: "hello child".to_string(),
3145 target_region: None,
3146 reply,
3147 })
3148 .await;
3149 assert!(ok);
3150 }
3151
3152 #[tokio::test]
3157 async fn subagent_send_delivers_into_the_target_region() {
3158 let mut host = host_with(vec![]);
3159 let e = spawn(&mut host, "run-a", "run-a");
3160 host.world
3161 .world_mut()
3162 .get_mut::<crate::components::ContextWindow>(e)
3163 .unwrap()
3164 .add_region(Region::new(
3165 "notes".to_string(),
3166 RegionKind::Clearable,
3167 5000,
3168 ));
3169
3170 let ok = ask_sub(&mut host, |reply| SubAgentOp::Send {
3171 run_id: "run-a".to_string(),
3172 caller_run_id: "run-a".to_string(),
3173 content: "filed under notes".to_string(),
3174 target_region: Some("notes".to_string()),
3175 reply,
3176 })
3177 .await;
3178 assert!(ok);
3179
3180 host.world.tick(); let window = host
3182 .world
3183 .world()
3184 .get::<crate::components::ContextWindow>(e)
3185 .unwrap();
3186 assert!(window.get_region("notes").unwrap().current_tokens > 0);
3187 assert_eq!(window.get_region("conversation").unwrap().current_tokens, 0);
3188 }
3189
3190 #[tokio::test]
3191 async fn subagent_kill_cancels_the_whole_tree() {
3192 let mut host = host_with(vec![]);
3193 host.set_spawner(child_spawner());
3194 spawn(&mut host, "parent", "parent");
3195 ask_sub(&mut host, |reply| SubAgentOp::Spawn {
3196 args: Box::new(SpawnArgs {
3197 run_id: "child".to_string(),
3198 ..Default::default()
3199 }),
3200 parent_run_id: "parent".to_string(),
3201 max_depth: 3,
3202 reply,
3203 })
3204 .await
3205 .unwrap();
3206
3207 let ok = ask_sub(&mut host, |reply| SubAgentOp::Kill {
3208 run_id: "parent".to_string(),
3209 caller_run_id: "parent".to_string(),
3210 reply,
3211 })
3212 .await;
3213 assert!(ok);
3214 assert_eq!(
3215 host.world.agent_status(host.by_run_id["parent"]),
3216 Some(AgentStatus::Cancelled)
3217 );
3218 assert_eq!(
3219 host.world.agent_status(host.by_run_id["child"]),
3220 Some(AgentStatus::Cancelled)
3221 );
3222
3223 let miss = ask_sub(&mut host, |reply| SubAgentOp::Kill {
3225 run_id: "ghost".to_string(),
3226 caller_run_id: "ghost".to_string(),
3227 reply,
3228 })
3229 .await;
3230 assert!(!miss);
3231 }
3232
3233 #[tokio::test]
3237 async fn cancel_cascades_to_the_whole_tree() {
3238 let mut host = host_with(vec![]);
3239 host.set_spawner(child_spawner());
3240 spawn(&mut host, "parent", "parent");
3241 ask_sub(&mut host, |reply| SubAgentOp::Spawn {
3242 args: Box::new(SpawnArgs {
3243 run_id: "child".to_string(),
3244 ..Default::default()
3245 }),
3246 parent_run_id: "parent".to_string(),
3247 max_depth: 3,
3248 reply,
3249 })
3250 .await
3251 .unwrap();
3252
3253 assert!(
3254 ask(&mut host, |reply| ControlOp::Cancel {
3255 run_id: "parent".to_string(),
3256 reply
3257 })
3258 .await
3259 );
3260 assert_eq!(
3261 host.world.agent_status(host.by_run_id["child"]),
3262 Some(AgentStatus::Cancelled),
3263 "cancelling the parent cancels its children"
3264 );
3265 }
3266
3267 #[tokio::test]
3271 async fn cancel_tolerates_a_child_that_has_already_been_reaped() {
3272 let mut host = host_with(vec![]);
3273 let parent = spawn(&mut host, "parent", "parent");
3274 let ghost = host.world_mut().spawn_agent((agent_state("ghost"),));
3275 host.world_mut()
3276 .world_mut()
3277 .entity_mut(parent)
3278 .insert(SubAgentChildren {
3279 children: vec![ghost],
3280 max_child_depth: 3,
3281 });
3282 host.world_mut().world_mut().despawn(ghost);
3283
3284 assert!(
3285 ask(&mut host, |reply| ControlOp::Cancel {
3286 run_id: "parent".to_string(),
3287 reply
3288 })
3289 .await,
3290 "the parent is still cancelled"
3291 );
3292 assert_eq!(
3293 host.world.agent_status(parent),
3294 Some(AgentStatus::Cancelled)
3295 );
3296 }
3297
3298 #[tokio::test]
3302 async fn cancel_closes_the_runs_open_interactions() {
3303 let mut host = host_with(vec![]);
3304 let hub = host.interactions();
3305 spawn(&mut host, "run-a", "agent-a");
3306
3307 let backend = hub.backend_for("agent-a");
3308 let asking = tokio::spawn(async move {
3309 backend
3310 .ask(InteractionRequest::free_text("q", "ask", "stage", true))
3311 .await
3312 });
3313 while hub.pending().is_empty() {
3317 tokio::task::yield_now().await;
3318 }
3319 host.emit_events();
3320 assert!(
3321 !host.emitted_interactions.is_empty(),
3322 "the open request was emitted"
3323 );
3324
3325 ask(&mut host, |reply| ControlOp::Cancel {
3326 run_id: "run-a".to_string(),
3327 reply,
3328 })
3329 .await;
3330
3331 tokio::time::timeout(std::time::Duration::from_secs(5), asking)
3336 .await
3337 .expect("cancelling the run releases its blocked ask")
3338 .expect("the ask task did not panic");
3339 assert!(hub.pending().is_empty(), "no orphaned prompt is left open");
3342 assert!(
3343 host.emitted_interactions.is_empty(),
3344 "and it is pruned from the emitted set, not re-announced forever"
3345 );
3346 }
3347
3348 #[tokio::test]
3352 async fn cancel_falls_back_to_the_force_terminator_when_the_world_cannot_hold_the_run() {
3353 let mut host = host_with(vec![]);
3354 host.set_reloader(Box::new(|_world, _run_id| None));
3356 let terminated = Arc::new(Mutex::new(Vec::new()));
3357 host.set_force_terminator(recording_terminator(terminated.clone()));
3358
3359 assert!(
3360 ask(&mut host, |reply| ControlOp::Cancel {
3361 run_id: "unreloadable".to_string(),
3362 reply
3363 })
3364 .await,
3365 "a run that can't be reloaded is still terminated"
3366 );
3367 assert!(
3368 !ask(&mut host, |reply| ControlOp::Cancel {
3369 run_id: "never-existed".to_string(),
3370 reply
3371 })
3372 .await,
3373 "`false` is reserved for a run that exists nowhere"
3374 );
3375 assert_eq!(
3376 *terminated.lock().unwrap(),
3377 vec!["unreloadable".to_string(), "never-existed".to_string()]
3378 );
3379 }
3380
3381 #[tokio::test]
3384 async fn cancel_does_not_force_terminate_a_run_it_could_cancel() {
3385 let mut host = host_with(vec![]);
3386 spawn(&mut host, "run-a", "agent-a");
3387 let terminated = Arc::new(Mutex::new(Vec::new()));
3388 host.set_force_terminator(recording_terminator(terminated.clone()));
3389
3390 assert!(
3391 ask(&mut host, |reply| ControlOp::Cancel {
3392 run_id: "run-a".to_string(),
3393 reply
3394 })
3395 .await
3396 );
3397 assert_eq!(
3398 host.world.agent_status(host.by_run_id["run-a"]),
3399 Some(AgentStatus::Cancelled)
3400 );
3401 assert!(
3402 terminated.lock().unwrap().is_empty(),
3403 "the disk fallback stayed unused"
3404 );
3405 }
3406
3407 #[tokio::test]
3413 async fn unregistered_world_agents_are_adopted_and_become_cancellable() {
3414 let mut host = host_with(vec![]);
3415 let entity = host.world_mut().spawn_agent((
3416 agent_state("worker"),
3417 RunMetadata {
3418 run_id: "worker-run".to_string(),
3419 agent_name: "w".to_string(),
3420 agent_path: String::new(),
3421 task: String::new(),
3422 model: None,
3423 workdir: String::new(),
3424 num_stages: 1,
3425 started_at: 0,
3426 parent_run_id: None,
3427 metadata: Default::default(),
3428 callback_url: None,
3429 callback_secret: None,
3430 title: None,
3431 unattended: false,
3432 read_paths: None,
3433 },
3434 ));
3435 assert!(
3436 !host.by_run_id.contains_key("worker-run"),
3437 "not registered by the spawn itself"
3438 );
3439
3440 host.emit_events();
3441
3442 assert_eq!(host.live_entity("worker-run"), Some(entity), "adopted");
3443 host.set_reloader(paging_reloader());
3445 assert!(
3446 ask(&mut host, |reply| ControlOp::Cancel {
3447 run_id: "worker-run".to_string(),
3448 reply
3449 })
3450 .await
3451 );
3452 assert_eq!(
3453 host.world.agent_status(entity),
3454 Some(AgentStatus::Cancelled),
3455 "the original entity is cancelled, not a reloaded copy"
3456 );
3457 }
3458
3459 #[tokio::test]
3460 async fn interaction_ops_list_answer_and_cancel() {
3461 let mut host = host_with(vec![]);
3462 let hub = host.interactions();
3463 let backend = hub.backend_for("agent-a");
3464
3465 let asking = tokio::spawn(async move {
3467 backend
3468 .ask(leviath_core::interaction::InteractionRequest::free_text(
3469 "q1", "prompt?", "stage", true,
3470 ))
3471 .await
3472 });
3473 for _ in 0..8 {
3474 tokio::task::yield_now().await;
3475 }
3476
3477 let list = ask(&mut host, |reply| ControlOp::ListInteractions { reply }).await;
3479 assert_eq!(list.len(), 1);
3480 assert_eq!(list[0].0, "agent-a");
3481
3482 let ok = ask(&mut host, |reply| ControlOp::AnswerInteraction {
3484 response: leviath_core::interaction::InteractionResponse::text("q1", "hi"),
3485 reply,
3486 })
3487 .await;
3488 assert!(ok);
3489 assert_eq!(asking.await.unwrap().value.as_deref(), Some("hi"));
3490
3491 let cancelled = ask(&mut host, |reply| ControlOp::CancelInteraction {
3493 request_id: "gone".to_string(),
3494 reply,
3495 })
3496 .await;
3497 assert!(!cancelled);
3498 }
3499
3500 #[tokio::test]
3501 async fn cancel_interaction_op_wakes_asker() {
3502 let mut host = host_with(vec![]);
3503 let backend = host.interactions().backend_for("agent-a");
3504 let asking = tokio::spawn(async move {
3505 backend
3506 .ask(leviath_core::interaction::InteractionRequest::free_text(
3507 "q2", "p", "s", true,
3508 ))
3509 .await
3510 });
3511 for _ in 0..8 {
3512 tokio::task::yield_now().await;
3513 }
3514
3515 let ok = ask(&mut host, |reply| ControlOp::CancelInteraction {
3516 request_id: "q2".to_string(),
3517 reply,
3518 })
3519 .await;
3520 assert!(ok);
3521 assert_eq!(asking.await.unwrap().request_id, "q2");
3522 }
3523
3524 #[tokio::test]
3525 async fn message_op_is_delivered() {
3526 let mut host = host_with(vec![]);
3527 let e = spawn(&mut host, "run-a", "agent-a");
3528
3529 let ok = ask(&mut host, |reply| ControlOp::Message {
3530 agent_id: "agent-a".to_string(),
3531 content: "hi".to_string(),
3532 target_region: Some("conversation".to_string()),
3533 reply,
3534 })
3535 .await;
3536 assert!(ok);
3537
3538 host.world_mut().tick();
3540 assert!(
3541 host.world
3542 .world()
3543 .get::<crate::components::ContextWindow>(e)
3544 .unwrap()
3545 .get_region("conversation")
3546 .unwrap()
3547 .current_tokens
3548 > 0
3549 );
3550 }
3551
3552 #[tokio::test]
3553 async fn serve_drives_agents_and_handles_ops_until_shutdown() {
3554 let mut host = host_with(vec![text("t1"), text("t2"), text("t3"), text("t4")]);
3555 spawn(&mut host, "run-a", "agent-a");
3556 let shutdown = host.world_mut().shutdown_handle();
3557 let mut events = host.subscribe();
3561 let (op_tx, op_rx) = mpsc::unbounded_channel();
3562
3563 let handle = tokio::spawn(async move {
3564 host.serve(op_rx).await;
3565 });
3566
3567 let (tx, rx) = oneshot::channel();
3569 op_tx
3570 .send(ControlOp::Status {
3571 run_id: "run-a".to_string(),
3572 reply: tx,
3573 })
3574 .unwrap();
3575 let _ = rx.await.unwrap();
3576
3577 let completed = tokio::time::timeout(std::time::Duration::from_secs(5), async {
3579 loop {
3580 if let Ok(WorldEvent::Completed { run_id, status, .. }) = events.recv().await {
3581 return (run_id, status);
3582 }
3583 }
3584 })
3585 .await
3586 .expect("the serve loop must drive the agent to a terminal status");
3587 assert_eq!(completed, ("run-a".to_string(), "complete".to_string()));
3588
3589 shutdown.notify_one();
3590 handle.await.unwrap();
3591 }
3592
3593 #[tokio::test]
3594 async fn serve_awaits_spawn_preprocessor_before_spawning() {
3595 use std::sync::atomic::{AtomicBool, Ordering};
3596 let mut host = host_with(vec![]);
3597 let ran = Arc::new(AtomicBool::new(false));
3598 let ran_pp = ran.clone();
3599 host.set_spawn_preprocessor(Box::new(move |_args| {
3600 let ran = ran_pp.clone();
3601 Box::pin(async move {
3602 ran.store(true, Ordering::SeqCst);
3603 })
3604 }));
3605 let ran_spawn = ran.clone();
3606 host.set_spawner(Box::new(move |world, args| {
3607 assert!(ran_spawn.load(Ordering::SeqCst));
3609 Ok(world.spawn_agent((agent_state(&args.run_id),)))
3610 }));
3611 let (op_tx, op_rx) = mpsc::unbounded_channel();
3612 let handle = tokio::spawn(async move {
3613 host.serve(op_rx).await;
3614 });
3615 let (tx, rx) = oneshot::channel();
3616 op_tx
3617 .send(ControlOp::Spawn {
3618 args: Box::new(SpawnArgs {
3619 run_id: "rp".to_string(),
3620 ..Default::default()
3621 }),
3622 reply: tx,
3623 })
3624 .unwrap();
3625 let result = rx.await.unwrap();
3626 drop(op_tx); handle.await.unwrap();
3628 assert_eq!(result, Ok("rp".to_string()));
3629 assert!(ran.load(Ordering::SeqCst), "preprocessor ran");
3630 }
3631
3632 #[tokio::test]
3633 async fn serve_awaits_preprocessor_for_subagent_spawn() {
3634 use std::sync::atomic::{AtomicUsize, Ordering};
3635 let mut host = host_with(vec![]);
3636 host.set_spawner(child_spawner());
3637 let parent = host.world_mut().spawn_agent((agent_state("parent"),));
3640 host.register("parent", parent);
3641 let calls = Arc::new(AtomicUsize::new(0));
3644 let calls_pp = calls.clone();
3645 host.set_spawn_preprocessor(Box::new(move |_args| {
3646 let calls = calls_pp.clone();
3647 Box::pin(async move {
3648 calls.fetch_add(1, Ordering::SeqCst);
3649 })
3650 }));
3651 let sub_tx = host.subagent_sender();
3652 let shutdown = host.world_mut().shutdown_handle();
3653 let (op_tx, op_rx) = mpsc::unbounded_channel();
3654 let handle = tokio::spawn(async move {
3655 host.serve(op_rx).await;
3656 });
3657
3658 let (ctx, crx) = oneshot::channel();
3660 sub_tx
3661 .send(SubAgentOp::Check {
3662 run_id: "parent".to_string(),
3663 reply: ctx,
3664 })
3665 .unwrap();
3666 let _ = crx.await.unwrap();
3667
3668 let (stx, srx) = oneshot::channel();
3670 sub_tx
3671 .send(SubAgentOp::Spawn {
3672 args: Box::new(SpawnArgs {
3673 run_id: "child".to_string(),
3674 ..Default::default()
3675 }),
3676 parent_run_id: "parent".to_string(),
3677 max_depth: 3,
3678 reply: stx,
3679 })
3680 .unwrap();
3681 assert_eq!(srx.await.unwrap(), Ok("child".to_string()));
3682
3683 shutdown.notify_one();
3684 drop(op_tx);
3685 handle.await.unwrap();
3686 assert_eq!(
3687 calls.load(Ordering::SeqCst),
3688 1,
3689 "only the Spawn preprocessed"
3690 );
3691 }
3692
3693 #[tokio::test]
3694 async fn serve_spawns_without_a_preprocessor() {
3695 let mut host = host_with(vec![]);
3698 host.set_spawner(Box::new(|world, args| {
3699 Ok(world.spawn_agent((agent_state(&args.run_id),)))
3700 }));
3701 let (op_tx, op_rx) = mpsc::unbounded_channel();
3702 let handle = tokio::spawn(async move {
3703 host.serve(op_rx).await;
3704 });
3705 let (tx, rx) = oneshot::channel();
3706 op_tx
3707 .send(ControlOp::Spawn {
3708 args: Box::new(SpawnArgs {
3709 run_id: "np".to_string(),
3710 ..Default::default()
3711 }),
3712 reply: tx,
3713 })
3714 .unwrap();
3715 let result = rx.await.unwrap();
3716 drop(op_tx);
3717 handle.await.unwrap();
3718 assert_eq!(result, Ok("np".to_string()));
3719 }
3720
3721 #[tokio::test]
3722 async fn shutdown_op_stops_the_serve_loop() {
3723 let mut host = host_with(vec![]);
3724 let (op_tx, op_rx) = mpsc::unbounded_channel();
3725 let handle = tokio::spawn(async move { host.serve(op_rx).await });
3726
3727 let (tx, rx) = oneshot::channel();
3728 op_tx.send(ControlOp::Shutdown { reply: tx }).unwrap();
3729 assert!(rx.await.unwrap());
3730 handle.await.unwrap();
3732 }
3733
3734 #[tokio::test]
3735 async fn flush_and_stop_delegates_to_the_world() {
3736 let mut host = host_with(vec![]);
3739 host.flush_and_stop().await;
3740 host.flush_and_stop().await; }
3742
3743 #[tokio::test]
3744 async fn serve_loop_services_subagent_ops_via_the_sender() {
3745 let mut host = host_with(vec![]);
3746 spawn(&mut host, "run-a", "run-a");
3747 let sub_tx = host.subagent_sender();
3748 let (op_tx, op_rx) = mpsc::unbounded_channel();
3749 let handle = tokio::spawn(async move { host.serve(op_rx).await });
3750
3751 let (tx, rx) = oneshot::channel();
3753 sub_tx
3754 .send(SubAgentOp::Check {
3755 run_id: "run-a".to_string(),
3756 reply: tx,
3757 })
3758 .unwrap();
3759 assert!(rx.await.unwrap().is_some());
3760
3761 let (stx, srx) = oneshot::channel();
3762 op_tx.send(ControlOp::Shutdown { reply: stx }).unwrap();
3763 assert!(srx.await.unwrap());
3764 handle.await.unwrap();
3765 }
3766
3767 #[test]
3768 fn status_str_covers_all_variants() {
3769 assert_eq!(status_str(&AgentStatus::Idle), "idle");
3770 assert_eq!(status_str(&AgentStatus::Active), "active");
3771 assert_eq!(status_str(&AgentStatus::Paused), "paused");
3772 assert_eq!(status_str(&AgentStatus::Waiting), "waiting");
3773 assert_eq!(status_str(&AgentStatus::Complete), "complete");
3774 assert_eq!(
3775 status_str(&AgentStatus::Error {
3776 message: "x".to_string()
3777 }),
3778 "error"
3779 );
3780 assert_eq!(status_str(&AgentStatus::Cancelled), "cancelled");
3781 }
3782
3783 #[tokio::test]
3784 async fn emit_events_broadcasts_agent_changes() {
3785 let mut host = host_with(vec![text("done")]);
3786 let mut rx = host.subscribe();
3787 let entity = spawn(&mut host, "run-a", "agent-a");
3788 host.world_mut()
3790 .world_mut()
3791 .entity_mut(entity)
3792 .insert(RunMetadata {
3793 run_id: "run-a".to_string(),
3794 agent_name: "coder".to_string(),
3795 agent_path: "/a".to_string(),
3796 task: "t".to_string(),
3797 model: None,
3798 workdir: "/w".to_string(),
3799 num_stages: 1,
3800 started_at: 0,
3801 parent_run_id: None,
3802 metadata: std::collections::HashMap::new(),
3803 callback_url: None,
3804 callback_secret: None,
3805 title: None,
3806 unattended: false,
3807 read_paths: None,
3808 });
3809
3810 host.emit_events();
3812 let first: Vec<WorldEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
3813 assert!(
3814 first
3815 .iter()
3816 .any(|e| matches!(e, WorldEvent::Spawned { .. }))
3817 );
3818 assert!(first.iter().any(|e| matches!(e, WorldEvent::Status { .. })));
3819 assert!(first.iter().any(|e| matches!(e, WorldEvent::Tokens { .. })));
3820 assert!(
3821 first
3822 .iter()
3823 .any(|e| matches!(e, WorldEvent::Context { .. }))
3824 );
3825
3826 host.emit_events();
3828 assert!(rx.try_recv().is_err());
3829
3830 host.world_mut().run_until_idle(20).await;
3832 host.emit_events();
3833 let done: Vec<WorldEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
3834 assert!(
3835 done.iter()
3836 .any(|e| matches!(e, WorldEvent::Completed { .. }))
3837 );
3838
3839 host.emit_events();
3841 assert!(
3842 std::iter::from_fn(|| rx.try_recv().ok())
3843 .collect::<Vec<_>>()
3844 .is_empty()
3845 );
3846 }
3847
3848 #[tokio::test]
3849 async fn emit_events_unloads_terminal_agents_when_safe() {
3850 let mut host = host_with(vec![]);
3851
3852 let root = {
3854 let mut s = agent_state("root");
3855 s.status = AgentStatus::Complete;
3856 host.world.world_mut().spawn(s).id()
3857 };
3858 host.register("root", root);
3859 host.emit_events();
3860 assert!(
3861 host.live_entity("root").is_some(),
3862 "not reaped on the first terminal pass (event must go out first)"
3863 );
3864 host.emit_events();
3865 assert!(host.live_entity("root").is_none(), "reaped after emit");
3866 assert!(
3867 host.world.world().get::<AgentState>(root).is_none(),
3868 "entity despawned"
3869 );
3870
3871 let parent = host.world.world_mut().spawn(agent_state("parent")).id();
3873 host.register("parent", parent);
3874 let child = {
3875 let mut s = agent_state("child");
3876 s.status = AgentStatus::Complete;
3877 host.world
3878 .world_mut()
3879 .spawn((
3880 s,
3881 ParentRef {
3882 parent_entity: parent,
3883 parent_agent_id: "parent".to_string(),
3884 depth: 1,
3885 },
3886 ))
3887 .id()
3888 };
3889 host.register("child", child);
3890 host.emit_events();
3891 host.emit_events();
3892 assert!(
3893 host.live_entity("child").is_some(),
3894 "not reaped while its parent is live"
3895 );
3896
3897 host.world
3899 .world_mut()
3900 .get_mut::<AgentState>(parent)
3901 .unwrap()
3902 .status = AgentStatus::Complete;
3903 host.emit_events();
3904 host.emit_events();
3905 assert!(
3906 host.live_entity("child").is_none(),
3907 "reaped once its parent is terminal"
3908 );
3909
3910 let ghost = host.world.world_mut().spawn_empty().id();
3912 host.world.world_mut().despawn(ghost);
3913 let orphan = {
3914 let mut s = agent_state("orphan");
3915 s.status = AgentStatus::Complete;
3916 host.world
3917 .world_mut()
3918 .spawn((
3919 s,
3920 ParentRef {
3921 parent_entity: ghost,
3922 parent_agent_id: "gone".to_string(),
3923 depth: 1,
3924 },
3925 ))
3926 .id()
3927 };
3928 host.register("orphan", orphan);
3929 host.emit_events();
3930 host.emit_events();
3931 assert!(
3932 host.live_entity("orphan").is_none(),
3933 "reaped: parent entity despawned"
3934 );
3935 }
3936
3937 #[tokio::test]
3938 async fn emit_events_does_not_reap_non_terminal_agents() {
3939 let mut host = host_with(vec![]);
3940 let active = host.world.world_mut().spawn(agent_state("active")).id();
3941 host.register("active", active);
3942 host.emit_events();
3943 host.emit_events();
3944 assert!(host.live_entity("active").is_some());
3945 }
3946
3947 #[tokio::test]
3948 async fn reaper_runs_once_per_agent_before_despawn() {
3949 use std::sync::atomic::{AtomicUsize, Ordering};
3950 let mut host = host_with(vec![]);
3951
3952 static SEEN_LIVE: AtomicUsize = AtomicUsize::new(0);
3955 SEEN_LIVE.store(0, Ordering::SeqCst);
3956 host.set_reaper(Box::new(|world, entity| {
3957 let live = world.world().get::<AgentState>(entity).is_some();
3960 SEEN_LIVE.fetch_add(live as usize, Ordering::SeqCst);
3961 }));
3962
3963 let root = {
3964 let mut s = agent_state("root");
3965 s.status = AgentStatus::Complete;
3966 host.world.world_mut().spawn(s).id()
3967 };
3968 host.register("root", root);
3969 host.emit_events(); assert_eq!(SEEN_LIVE.load(Ordering::SeqCst), 0);
3971 host.emit_events(); assert!(host.live_entity("root").is_none(), "reaped after emit");
3973 assert_eq!(
3974 SEEN_LIVE.load(Ordering::SeqCst),
3975 1,
3976 "reaper ran exactly once, while the entity was still live"
3977 );
3978 }
3979
3980 fn unload_with(host: &mut WorldHost, run_id: &str, status: AgentStatus) {
3985 let mut s = agent_state(run_id);
3986 s.status = status;
3987 let e = host.world.world_mut().spawn(s).id();
3988 host.register(run_id, e);
3989 host.emit_events();
3990 host.emit_events();
3991 }
3992
3993 #[tokio::test]
4000 async fn an_unloaded_run_stays_in_the_listing_with_the_reason_it_ended() {
4001 let mut host = host_with(vec![]);
4002 let died = AgentStatus::Error {
4003 message: "HTTP 402 Payment Required".to_string(),
4004 };
4005 unload_with(&mut host, "worker-1", died.clone());
4006
4007 assert!(host.live_entity("worker-1").is_none(), "unloaded");
4008 let listing = ask(&mut host, |reply| ControlOp::List { reply }).await;
4009 assert!(listing.runs.is_empty(), "nothing is running");
4010 assert_eq!(listing.finished.len(), 1);
4011 assert_eq!(listing.finished[0].run_id, "worker-1");
4012 assert_eq!(listing.finished[0].status, died);
4013 assert!(listing.finished[0].last_progress_at.is_some());
4016 }
4017
4018 #[tokio::test]
4020 async fn an_unloaded_run_leaves_the_listing_once_it_is_stale() {
4021 let mut host = host_with(vec![]);
4022 unload_with(&mut host, "worker-1", AgentStatus::Complete);
4023 let window = DEFAULT_FINISHED_RETENTION_SECS as i64;
4024 let at = host.finished.front().expect("just unloaded").0;
4025
4026 host.prune_finished(at + window);
4028 assert_eq!(host.finished().len(), 1);
4029 host.prune_finished(at + window + 1);
4031 assert!(host.finished().is_empty());
4032 }
4033
4034 #[tokio::test]
4036 async fn a_zero_window_keeps_nothing() {
4037 let mut host = host_with(vec![]);
4038 host.set_finished_retention_secs(0);
4039 unload_with(&mut host, "worker-1", AgentStatus::Complete);
4040
4041 assert!(host.live_entity("worker-1").is_none(), "still unloaded");
4042 assert!(host.finished().is_empty());
4043 }
4044
4045 #[tokio::test]
4047 async fn a_run_is_listed_once_however_often_it_is_recorded() {
4048 let mut host = host_with(vec![]);
4049 let entry = |status| RunListEntry {
4050 run_id: "worker-1".to_string(),
4051 status,
4052 wait_reason: None,
4053 stage: "work".to_string(),
4054 stage_index: None,
4055 num_stages: None,
4056 iteration: 0,
4057 tool_calls: 0,
4058 last_progress_at: None,
4059 unattended: false,
4060 empty_output: false,
4061 read_paths: None,
4062 };
4063 host.record_finished(entry(AgentStatus::Cancelled), 100);
4064 host.record_finished(entry(AgentStatus::Complete), 200);
4065
4066 let finished = host.finished();
4067 assert_eq!(finished.len(), 1);
4068 assert_eq!(finished[0].status, AgentStatus::Complete);
4069 }
4070
4071 #[tokio::test]
4074 async fn the_listing_of_finished_runs_is_capped() {
4075 let mut host = host_with(vec![]);
4076 for i in 0..=MAX_RETAINED_FINISHED {
4077 host.record_finished(
4078 RunListEntry {
4079 run_id: format!("worker-{i}"),
4080 status: AgentStatus::Complete,
4081 wait_reason: None,
4082 stage: "work".to_string(),
4083 stage_index: None,
4084 num_stages: None,
4085 iteration: 0,
4086 tool_calls: 0,
4087 last_progress_at: None,
4088 unattended: false,
4089 empty_output: false,
4090 read_paths: None,
4091 },
4092 100,
4093 );
4094 }
4095
4096 let finished = host.finished();
4097 assert_eq!(finished.len(), MAX_RETAINED_FINISHED);
4098 assert_eq!(
4099 finished[0].run_id, "worker-1",
4100 "the oldest is the one dropped"
4101 );
4102 }
4103
4104 #[tokio::test]
4107 async fn the_status_of_an_unloaded_run_is_still_answerable() {
4108 let mut host = host_with(vec![]);
4109 unload_with(&mut host, "worker-1", AgentStatus::Complete);
4110
4111 let status = ask(&mut host, |reply| ControlOp::Status {
4112 run_id: "worker-1".to_string(),
4113 reply,
4114 })
4115 .await;
4116 assert_eq!(status, Some(AgentStatus::Complete));
4117 }
4118
4119 fn register_waiting(host: &mut WorldHost, run_id: &str) -> Entity {
4122 let mut s = agent_state(run_id);
4123 s.status = AgentStatus::Waiting;
4124 let e = host.world.world_mut().spawn(s).id();
4125 host.register(run_id, e);
4126 e
4127 }
4128
4129 #[tokio::test]
4135 async fn emit_events_never_unloads_waiting_agents() {
4136 use crate::components::AwaitingInteraction;
4137
4138 let mut host = host_with(vec![]);
4139
4140 let asking = register_waiting(&mut host, "asking");
4143 host.world
4144 .world_mut()
4145 .entity_mut(asking)
4146 .insert(AwaitingInteraction);
4147 let gated = register_waiting(&mut host, "gated");
4149 host.world
4150 .world_mut()
4151 .entity_mut(gated)
4152 .insert(WaitingForChildren);
4153 register_waiting(&mut host, "parked");
4154
4155 for _ in 0..5 {
4157 host.emit_events();
4158 }
4159 for run_id in ["asking", "gated", "parked"] {
4160 assert!(
4161 host.live_entity(run_id).is_some(),
4162 "a Waiting agent was unloaded and can no longer be resumed"
4163 );
4164 }
4165 }
4166
4167 #[tokio::test]
4168 async fn resolve_or_reload_pages_in_and_registers() {
4169 let mut host = host_with(vec![]);
4170 assert!(host.resolve_or_reload("ghost").is_none());
4172
4173 host.set_reloader(Box::new(|_world, _run_id| None));
4176 assert!(host.resolve_or_reload("gone").is_none());
4177 assert!(
4178 host.live_entity("gone").is_none(),
4179 "a declined reload registers nothing"
4180 );
4181
4182 host.set_reloader(Box::new(|world, run_id| {
4184 Some(world.spawn_agent((agent_state(run_id),)))
4185 }));
4186 let paged = host.resolve_or_reload("paged").expect("reloaded");
4187 assert_eq!(
4188 host.live_entity("paged"),
4189 Some(paged),
4190 "registered after reload"
4191 );
4192
4193 assert_eq!(host.resolve_or_reload("paged"), Some(paged));
4195 }
4196
4197 #[tokio::test]
4198 async fn cancel_pages_in_an_unloaded_run() {
4199 let mut host = host_with(vec![]);
4200 host.set_reloader(paging_reloader());
4201 let cancelled = ask(&mut host, |reply| ControlOp::Cancel {
4203 run_id: "unloaded".to_string(),
4204 reply,
4205 })
4206 .await;
4207 assert!(cancelled, "reloaded then cancelled");
4208 assert_eq!(
4209 host.world
4210 .agent_status(host.live_entity("unloaded").unwrap()),
4211 Some(AgentStatus::Cancelled)
4212 );
4213 }
4214
4215 #[tokio::test]
4216 async fn emit_events_broadcasts_new_interactions_once() {
4217 let mut host = host_with(vec![]);
4218 let mut rx = host.subscribe();
4219 let backend = host.interactions().backend_for("agent-a");
4220 let asking = tokio::spawn(async move {
4221 backend
4222 .ask(leviath_core::interaction::InteractionRequest::free_text(
4223 "q1", "p", "s", true,
4224 ))
4225 .await
4226 });
4227 for _ in 0..8 {
4228 tokio::task::yield_now().await;
4229 }
4230
4231 host.emit_events();
4232 let evs: Vec<WorldEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
4233 assert!(
4234 evs.iter()
4235 .any(|e| matches!(e, WorldEvent::Interaction { .. }))
4236 );
4237 host.emit_events();
4239 assert!(rx.try_recv().is_err());
4240
4241 assert!(
4243 host.interactions()
4244 .answer(leviath_core::interaction::InteractionResponse::text(
4245 "q1", "ok"
4246 ))
4247 );
4248 let _ = asking.await;
4249 }
4250
4251 #[tokio::test]
4252 async fn event_sender_feeds_subscribers() {
4253 let host = host_with(vec![]);
4254 let mut rx = host.subscribe();
4255 let event = WorldEvent::Completed {
4256 run_id: "r".to_string(),
4257 agent_id: "a".to_string(),
4258 status: "complete".to_string(),
4259 };
4260 host.event_sender().send(event.clone()).unwrap();
4261 assert_eq!(rx.try_recv().unwrap(), event);
4262 }
4263
4264 #[tokio::test]
4265 async fn emit_events_skips_despawned_agents() {
4266 let mut host = host_with(vec![]);
4267 let e = spawn(&mut host, "run-a", "agent-a");
4268 host.world_mut().world_mut().despawn(e);
4269 host.emit_events();
4271 }
4272
4273 #[tokio::test]
4274 async fn serve_returns_when_control_channel_closes() {
4275 let mut host = host_with(vec![text("done")]);
4276 let (op_tx, op_rx) = mpsc::unbounded_channel();
4277 drop(op_tx); host.serve(op_rx).await; }
4280
4281 #[tokio::test]
4282 async fn mock_helpers_are_exercised() {
4283 let p = Script {
4286 responses: Mutex::new(std::collections::VecDeque::new()),
4287 };
4288 assert_eq!(p.name(), "script");
4289 assert_eq!(p.count_tokens("t", "m").await, 1);
4290 assert_eq!(p.max_context_tokens("m"), 100_000);
4291 let _ = p.capabilities("m");
4292 let req = InferenceRequest {
4293 system: vec![],
4294 messages: vec![],
4295 model: "m".to_string(),
4296 max_tokens: 1,
4297 temperature: 0.0,
4298 tools: vec![],
4299 extra: serde_json::Value::Null,
4300 request_timeout_secs: None,
4301 };
4302 assert!(p.infer(req).await.is_err()); let exec = NoTools.exec_for(
4305 Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id"),
4306 vec![leviath_providers::ToolCall {
4307 id: "c".to_string(),
4308 name: "n".to_string(),
4309 arguments: serde_json::Value::Null,
4310 thought_signature: None,
4311 }],
4312 crate::pipeline::noop_progress(),
4313 );
4314 assert_eq!(exec().await, vec![("c".to_string(), String::new())]);
4315 }
4316
4317 #[tokio::test]
4318 async fn list_skips_despawned_entity() {
4319 let mut host = host_with(vec![]);
4320 let e = spawn(&mut host, "run-a", "agent-a");
4321 host.world_mut().world_mut().despawn(e);
4323
4324 let list = ask(&mut host, |reply| ControlOp::List { reply }).await.runs;
4325 assert!(list.is_empty()); let status = ask(&mut host, |reply| ControlOp::Status {
4327 run_id: "run-a".to_string(),
4328 reply,
4329 })
4330 .await;
4331 assert_eq!(status, None);
4332 }
4333
4334 fn waiting_because(
4339 host: &mut WorldHost,
4340 entity: Entity,
4341 attach: impl FnOnce(&mut bevy_ecs::world::EntityWorldMut),
4342 ) -> Option<WaitReason> {
4343 {
4344 let world = host.world_mut().world_mut();
4345 world
4346 .get_mut::<AgentState>(entity)
4347 .expect("spawned agent has state")
4348 .status = AgentStatus::Waiting;
4349 let mut e = world.entity_mut(entity);
4350 attach(&mut e);
4351 }
4352 host.wait_reason(entity)
4353 }
4354
4355 #[tokio::test]
4358 async fn wait_reason_is_none_unless_the_agent_is_waiting() {
4359 let mut host = host_with(vec![]);
4360 let e = spawn(&mut host, "run-a", "run-a");
4361 host.world_mut()
4362 .world_mut()
4363 .entity_mut(e)
4364 .insert(crate::pipeline::WaitingForChildren);
4365 assert_eq!(host.wait_reason(e), None);
4366 }
4367
4368 #[tokio::test]
4370 async fn wait_reason_is_none_for_an_unknown_entity() {
4371 let mut host = host_with(vec![]);
4372 let e = spawn(&mut host, "run-a", "run-a");
4373 host.world_mut().world_mut().despawn(e);
4374 assert_eq!(host.wait_reason(e), None);
4375 }
4376
4377 #[tokio::test]
4379 async fn wait_reason_is_none_when_nothing_claims_the_wait() {
4380 let mut host = host_with(vec![]);
4381 let e = spawn(&mut host, "run-a", "run-a");
4382 assert_eq!(waiting_because(&mut host, e, |_| {}), None);
4383 }
4384
4385 #[tokio::test]
4386 async fn wait_reason_reports_a_taint_gate() {
4387 let mut host = host_with(vec![]);
4388 let e = spawn(&mut host, "run-a", "run-a");
4389 let reason = waiting_because(&mut host, e, |entity| {
4390 entity.insert(crate::gate_prompt::AwaitingGatePrompt(1));
4391 });
4392 assert_eq!(reason, Some(WaitReason::TaintGate));
4393 }
4394
4395 #[tokio::test]
4396 async fn wait_reason_reports_an_interaction_point() {
4397 let mut host = host_with(vec![]);
4398 let e = spawn(&mut host, "run-a", "run-a");
4399 let reason = waiting_because(&mut host, e, |entity| {
4400 entity.insert(crate::interaction_points::AwaitingInteractionPoint);
4401 });
4402 assert_eq!(reason, Some(WaitReason::InteractionPoint));
4403 }
4404
4405 #[tokio::test]
4408 async fn wait_reason_counts_unfinished_children() {
4409 let mut host = host_with(vec![]);
4410 let parent = spawn(&mut host, "run-a", "run-a");
4411 let running = spawn(&mut host, "run-b", "run-b");
4412 let done = spawn(&mut host, "run-c", "run-c");
4413 {
4414 let world = host.world_mut().world_mut();
4415 world
4416 .get_mut::<AgentState>(done)
4417 .expect("child has state")
4418 .status = AgentStatus::Complete;
4419 }
4420 let reason = waiting_because(&mut host, parent, |entity| {
4421 entity.insert((
4422 crate::pipeline::WaitingForChildren,
4423 SubAgentChildren {
4424 children: vec![running, done],
4425 max_child_depth: 3,
4426 },
4427 ));
4428 });
4429 assert_eq!(reason, Some(WaitReason::Children { outstanding: 1 }));
4430 }
4431
4432 #[tokio::test]
4435 async fn wait_reason_reports_children_with_none_recorded() {
4436 let mut host = host_with(vec![]);
4437 let e = spawn(&mut host, "run-a", "run-a");
4438 let reason = waiting_because(&mut host, e, |entity| {
4439 entity.insert(crate::pipeline::WaitingForChildren);
4440 });
4441 assert_eq!(reason, Some(WaitReason::Children { outstanding: 0 }));
4442 }
4443
4444 fn open_prompt(
4447 host: &WorldHost,
4448 agent_id: &str,
4449 request: InteractionRequest,
4450 ) -> tokio::task::JoinHandle<InteractionResponse> {
4451 let backend = host.interactions().backend_for(agent_id.to_string());
4452 tokio::spawn(async move {
4453 use crate::dynamic_interaction::InteractionBackend;
4454 backend.ask(request).await
4455 })
4456 }
4457
4458 async fn await_pending(host: &WorldHost, agent_id: &str) {
4462 for _ in 0..8 {
4463 tokio::task::yield_now().await;
4464 }
4465 assert!(
4466 host.interactions()
4467 .pending()
4468 .iter()
4469 .any(|(id, _)| id == agent_id),
4470 "the hub registered a request for {agent_id}"
4471 );
4472 }
4473
4474 #[tokio::test]
4475 async fn wait_reason_distinguishes_a_tool_approval_from_a_question() {
4476 let mut host = host_with(vec![]);
4477 let e = spawn(&mut host, "run-a", "run-a");
4478
4479 let approval = open_prompt(
4480 &host,
4481 "run-a",
4482 InteractionRequest::tool_approval("req-1", "shell", serde_json::json!({}), "implement"),
4483 );
4484 await_pending(&host, "run-a").await;
4485 let reason = waiting_because(&mut host, e, |entity| {
4486 entity.insert(AwaitingInteraction);
4487 });
4488 assert_eq!(reason, Some(WaitReason::ToolApproval));
4489 assert_eq!(host.interactions().cancel_for_agent("run-a"), 1);
4492 approval.await.expect("the asking task finishes");
4493
4494 let question = open_prompt(
4495 &host,
4496 "run-a",
4497 InteractionRequest::free_text("req-2", "which one?", "implement", true),
4498 );
4499 await_pending(&host, "run-a").await;
4500 assert_eq!(host.wait_reason(e), Some(WaitReason::UserPrompt));
4501 assert_eq!(host.interactions().cancel_for_agent("run-a"), 1);
4502 question.await.expect("the asking task finishes");
4503 }
4504
4505 #[tokio::test]
4508 async fn wait_reason_falls_back_to_user_prompt_without_a_hub_entry() {
4509 let mut host = host_with(vec![]);
4510 let e = spawn(&mut host, "run-a", "run-a");
4511 let reason = waiting_because(&mut host, e, |entity| {
4512 entity.insert(AwaitingInteraction);
4513 });
4514 assert_eq!(reason, Some(WaitReason::UserPrompt));
4515 }
4516
4517 #[tokio::test]
4521 async fn a_gate_outranks_the_generic_interaction_marker() {
4522 let mut host = host_with(vec![]);
4523 let e = spawn(&mut host, "run-a", "run-a");
4524 let reason = waiting_because(&mut host, e, |entity| {
4525 entity.insert((
4526 AwaitingInteraction,
4527 crate::gate_prompt::AwaitingGatePrompt(1),
4528 ));
4529 });
4530 assert_eq!(reason, Some(WaitReason::TaintGate));
4531 }
4532
4533 #[tokio::test]
4536 async fn wait_reason_counts_outstanding_fan_out_workers() {
4537 let mut host = host_with(vec![]);
4538 let parent = spawn(&mut host, "run-a", "run-a");
4539 let worker = spawn(&mut host, "run-b", "run-b");
4540 {
4541 let world = host.world_mut().world_mut();
4542 world
4543 .get_mut::<AgentState>(parent)
4544 .expect("parent has state")
4545 .status = AgentStatus::Waiting;
4546 crate::fanout::restore_fan_out_waiting(
4548 world,
4549 parent,
4550 crate::fanout::FanOutState {
4551 config: leviath_core::blueprint::FanOutConfig {
4552 worker_agent: None,
4553 worker_stage: Some("work".to_string()),
4554 worker_query: None,
4555 merge_stage: None,
4556 max_workers: 2,
4557 on_worker_failure: Default::default(),
4558 split_prompt: String::new(),
4559 },
4560 max_workers: 2,
4561 pending: vec![
4562 crate::fanout::WorkItem::default(),
4563 crate::fanout::WorkItem::default(),
4564 ],
4565 active: vec![("item-1".to_string(), "run-b".to_string())],
4566 summaries: Vec::new(),
4567 failures: Vec::new(),
4568 },
4569 &|run_id| (run_id == "run-b").then_some(worker),
4570 );
4571 }
4572 assert_eq!(
4573 host.wait_reason(parent),
4574 Some(WaitReason::FanOutWorkers { outstanding: 3 })
4575 );
4576 }
4577
4578 #[tokio::test]
4582 async fn list_reports_blueprint_shape_and_unattended() {
4583 let mut host = host_with(vec![]);
4584 let e = spawn(&mut host, "run-a", "run-a");
4585 host.world_mut().world_mut().entity_mut(e).insert((
4586 RunMetadata {
4587 run_id: "run-a".to_string(),
4588 agent_name: "coder".to_string(),
4589 agent_path: "/tmp/agent".to_string(),
4590 task: "t".to_string(),
4591 model: None,
4592 workdir: "/tmp".to_string(),
4593 num_stages: 3,
4594 started_at: 0,
4595 parent_run_id: None,
4596 metadata: HashMap::new(),
4597 callback_url: None,
4598 callback_secret: None,
4599 title: None,
4600 unattended: true,
4601 read_paths: None,
4602 },
4603 TokenTotals {
4604 tool_calls: 9,
4605 ..Default::default()
4606 },
4607 {
4608 let mut watermark = crate::pipeline::PersistWatermark::default();
4609 watermark.backdate(1_700);
4610 watermark
4611 },
4612 ));
4613 let list = ask(&mut host, |reply| ControlOp::List { reply }).await.runs;
4614 assert_eq!(list[0].num_stages, Some(3));
4615 assert_eq!(list[0].tool_calls, 9);
4616 assert!(list[0].unattended);
4617 assert_eq!(list[0].last_progress_at, Some(1_700));
4618 assert!(!list[0].empty_output);
4621 }
4622
4623 #[tokio::test]
4626 async fn list_reports_a_finished_run_that_produced_nothing() {
4627 let mut host = host_with(vec![]);
4628 let e = spawn(&mut host, "run-a", "run-a");
4629 host.world_mut()
4630 .world_mut()
4631 .entity_mut(e)
4632 .insert(crate::persistence::RunOutcomeFlags::default());
4633 assert!(!ask(&mut host, |reply| ControlOp::List { reply }).await.runs[0].empty_output);
4635
4636 host.world_mut()
4637 .world_mut()
4638 .get_mut::<AgentState>(e)
4639 .expect("spawned agent has state")
4640 .status = AgentStatus::Complete;
4641 assert!(ask(&mut host, |reply| ControlOp::List { reply }).await.runs[0].empty_output);
4642
4643 host.world_mut()
4645 .world_mut()
4646 .get_mut::<crate::persistence::RunOutcomeFlags>(e)
4647 .expect("just inserted")
4648 .0
4649 .no_output_tools = true;
4650 assert!(!ask(&mut host, |reply| ControlOp::List { reply }).await.runs[0].empty_output);
4651 }
4652
4653 #[tokio::test]
4655 async fn list_explains_a_waiting_run() {
4656 let mut host = host_with(vec![]);
4657 let e = spawn(&mut host, "run-a", "run-a");
4658 waiting_because(&mut host, e, |entity| {
4659 entity.insert(crate::pipeline::WaitingForChildren);
4660 });
4661 let list = ask(&mut host, |reply| ControlOp::List { reply }).await.runs;
4662 assert_eq!(list.len(), 1);
4663 assert_eq!(
4664 list[0].wait_reason,
4665 Some(WaitReason::Children { outstanding: 0 })
4666 );
4667 assert_eq!(list[0].stage_index, Some(0));
4668 assert_eq!(list[0].num_stages, None);
4671 assert!(!list[0].unattended);
4672 }
4673
4674 #[test]
4675 fn every_world_event_variant_carries_its_run_id() {
4676 let rid = "run-x".to_string();
4677 let aid = "agent-x".to_string();
4678 let events = vec![
4679 WorldEvent::Spawned {
4680 run_id: rid.clone(),
4681 agent_id: aid.clone(),
4682 blueprint: "b".to_string(),
4683 },
4684 WorldEvent::Status {
4685 run_id: rid.clone(),
4686 agent_id: aid.clone(),
4687 status: "active".to_string(),
4688 stage: "s".to_string(),
4689 iteration: 1,
4690 tool_calls: 0,
4691 accepts_messages: false,
4692 },
4693 WorldEvent::Tokens {
4694 run_id: rid.clone(),
4695 agent_id: aid.clone(),
4696 prompt_tokens: 1,
4697 completion_tokens: 2,
4698 cached_tokens: 0,
4699 cache_write_tokens: 0,
4700 },
4701 WorldEvent::Context {
4702 run_id: rid.clone(),
4703 agent_id: aid.clone(),
4704 total_tokens: 3,
4705 max_tokens: 4,
4706 },
4707 WorldEvent::Interaction {
4708 run_id: rid.clone(),
4709 agent_id: aid.clone(),
4710 request: InteractionRequest::free_text("i", "p", "s", true),
4711 },
4712 WorldEvent::Completed {
4713 run_id: rid.clone(),
4714 agent_id: aid.clone(),
4715 status: "complete".to_string(),
4716 },
4717 WorldEvent::StageTransition {
4718 run_id: rid.clone(),
4719 agent_id: aid.clone(),
4720 from: "a".to_string(),
4721 to: "b".to_string(),
4722 iteration: 1,
4723 },
4724 WorldEvent::ToolCallStarted {
4725 run_id: rid.clone(),
4726 agent_id: aid.clone(),
4727 call_id: "c".to_string(),
4728 tool: "t".to_string(),
4729 },
4730 WorldEvent::ToolCallFinished {
4731 run_id: rid.clone(),
4732 agent_id: aid.clone(),
4733 call_id: "c".to_string(),
4734 tool: "t".to_string(),
4735 ok: true,
4736 summary: "s".to_string(),
4737 },
4738 WorldEvent::Log {
4739 run_id: rid.clone(),
4740 agent_id: aid.clone(),
4741 line: "l".to_string(),
4742 },
4743 ];
4744 for ev in events {
4745 assert_eq!(ev.run_id(), "run-x");
4746 }
4747 }
4748}