1use std::sync::Arc;
9
10use leviath_providers::Tool;
11use leviath_runtime::ProviderRegistry;
12use leviath_runtime::host::WorldHost;
13use leviath_runtime::inference_pool::InferencePoolConfig;
14use leviath_runtime::interaction_hub::InteractionHub;
15use leviath_runtime::world::PipelineWorld;
16use tokio::runtime::Handle;
17use tokio::sync::Mutex;
18
19use leviath_runtime::fanout::FanOutSpawnerRes;
20
21use crate::config::Config;
22use crate::daemon::fanout_spawner::DaemonFanOutSpawner;
23use crate::daemon::spawn::build_agent;
24use crate::daemon::tool_service::CliToolService;
25use crate::tools::ToolRegistry;
26
27pub fn control_address() -> Option<leviath_runtime::control_socket::ControlId> {
31 control_dir().map(|dir| leviath_runtime::control_socket::control_id(&dir))
32}
33
34pub fn control_dir() -> Option<std::path::PathBuf> {
39 leviath_core::paths::data_dir()
40}
41
42pub const CURRENT_BUILD: &str = env!("LEVIATH_BUILD");
47
48pub fn build_marker_path() -> Option<std::path::PathBuf> {
51 leviath_core::paths::data_dir().map(|d| d.join("daemon.build"))
52}
53
54pub fn write_build_marker() {
57 build_marker_path().into_iter().for_each(|path| {
61 let _ = path.parent().map(std::fs::create_dir_all);
62 let _ = std::fs::write(&path, CURRENT_BUILD);
63 });
64}
65
66pub fn read_build_marker() -> Option<String> {
68 build_marker_path()
69 .and_then(|path| std::fs::read_to_string(path).ok())
70 .map(|s| s.trim().to_string())
71}
72
73pub fn daemon_build_is_stale(recorded: Option<&str>) -> bool {
77 recorded != Some(CURRENT_BUILD)
78}
79
80pub async fn setup_daemon_host(
84 config: Config,
85 runs_dir: std::path::PathBuf,
86 runtime: Handle,
87) -> anyhow::Result<WorldHost> {
88 setup_daemon_host_with(
89 config,
90 runs_dir,
91 runtime,
92 &leviath_providers::provider::build_http_client,
93 )
94 .await
95}
96
97const PROVIDER_PRIME_TIMEOUT_SECS: u64 = 10;
104
105pub async fn setup_daemon_host_with(
108 config: Config,
109 runs_dir: std::path::PathBuf,
110 runtime: Handle,
111 build_client: leviath_providers::provider::HttpClientFactory<'_>,
112) -> anyhow::Result<WorldHost> {
113 crate::daemon::script_host::set_local_network_allowed(config.security.allow_local_network);
118 let providers = crate::commands::run::session::build_provider_registry_from_config_with(
119 &config,
120 build_client,
121 )?;
122 providers
130 .prime_capabilities(std::time::Duration::from_secs(PROVIDER_PRIME_TIMEOUT_SECS))
131 .await;
132 let registry = ToolRegistry::build(std::env::temp_dir(), &config).await;
135 let mcp_pool = crate::daemon::mcp_pool::McpPool::for_daemon_with(
141 registry.mcp.clone(),
142 &config.mcp_servers,
143 config.security.credential_store,
144 config.security.allow_env_vars.clone(),
145 config.limits.mcp_idle_disconnect_secs,
146 );
147 mcp_pool.warm_recovered(&runs_dir).await;
148 Ok(build_host(HostParts {
149 config,
150 providers,
151 runs_dir,
152 shared_mcp: registry.mcp,
153 mcp_tool_defs: registry.mcp_tool_defs,
154 mcp_pool,
155 runtime,
156 now_secs: || chrono::Utc::now().timestamp(),
157 }))
158}
159
160fn make_reaper(
165 tool_service: Arc<CliToolService>,
166 mcp_pool: Arc<crate::daemon::mcp_pool::McpPool>,
167) -> leviath_runtime::host::Reaper {
168 Box::new(move |world, entity| {
169 if let Some(md) = world
172 .world()
173 .get::<leviath_runtime::persistence::RunMetadata>(entity)
174 {
175 let run_id = md.run_id.clone();
176 mcp_pool.release_run(&run_id);
177 }
178 tool_service.reap(entity)
179 })
180}
181
182pub struct HostParts {
189 pub config: Config,
191 pub providers: ProviderRegistry,
193 pub runs_dir: std::path::PathBuf,
195 pub shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
197 pub mcp_tool_defs: Vec<Tool>,
199 pub mcp_pool: Arc<crate::daemon::mcp_pool::McpPool>,
201 pub runtime: Handle,
203 pub now_secs: fn() -> i64,
205}
206
207pub fn build_host(parts: HostParts) -> WorldHost {
212 let hub = InteractionHub::new();
213 hub.set_timeout_secs(parts.config.limits.interaction_timeout_secs);
217 let tool_service = Arc::new(CliToolService::new());
218 let pool_config =
222 InferencePoolConfig::new().with_default(parts.config.limits.max_concurrent_inferences);
223 let mut world = PipelineWorld::new(
224 parts.providers,
225 tool_service.clone(),
226 pool_config,
227 parts.config.limits.max_concurrent_tools,
228 Some(parts.runs_dir.clone()),
229 parts.runtime,
230 );
231 world.set_exact_token_counting(parts.config.limits.exact_token_counting);
233 world
236 .world_mut()
237 .insert_resource(leviath_runtime::pipeline::StallTimeout(
238 parts.config.limits.stall_timeout_secs,
239 ));
240 world
244 .world_mut()
245 .insert_resource(leviath_runtime::pipeline::WedgeTimeout(
246 parts.config.limits.wedge_timeout_secs,
247 ));
248 world
252 .world_mut()
253 .insert_resource(leviath_runtime::pipeline::CircuitPolicy {
254 failures_before_open: parts.config.limits.provider_failures_before_open,
255 cooldown_secs: parts.config.limits.provider_circuit_cooldown_secs,
256 });
257 world
258 .world_mut()
259 .init_resource::<leviath_runtime::pipeline::ProviderCircuits>();
260 world
263 .world_mut()
264 .insert_resource(leviath_runtime::pipeline::InferenceRetryTuning {
265 max_attempts: parts.config.limits.inference_retry_attempts,
266 base_delay_ms: parts.config.limits.inference_retry_base_ms,
267 });
268 world.insert_interaction_hub(hub.clone());
271 let mut host = WorldHost::with_interactions(world, hub.clone());
272 host.set_dead_cycles_before_relief(parts.config.limits.dead_cycles_before_relief);
275 host.set_finished_retention_secs(parts.config.limits.finished_retention_secs);
278 let subagent_tx = host.subagent_sender();
281
282 let reloaded = crate::daemon::recovery::reload_persisted_agents(
286 host.world_mut(),
287 crate::daemon::spawn::SpawnDeps {
288 tool_service: tool_service.as_ref(),
289 config: &parts.config,
290 shared_mcp: parts.shared_mcp.clone(),
291 mcp_tool_defs: &parts.mcp_tool_defs,
292 hub: &hub,
293 now_secs: (parts.now_secs)(),
294 subagent_tx: subagent_tx.clone(),
295 },
296 &parts.runs_dir,
297 );
298 for (run_id, entity) in reloaded {
299 host.register(run_id, entity);
300 }
301
302 let reloader = std::sync::Arc::new(crate::daemon::config_reload::ConfigReloader::new(
309 Config::config_path(),
310 parts.config.clone(),
311 ));
312
313 let fanout_spawner = DaemonFanOutSpawner {
317 config: reloader.clone(),
318 shared_mcp: parts.shared_mcp.clone(),
319 mcp_tool_defs: parts.mcp_tool_defs.clone(),
320 mcp_pool: parts.mcp_pool.clone(),
321 hub: hub.clone(),
322 subagent_tx: subagent_tx.clone(),
323 tool_service: tool_service.clone(),
324 agents_dir: leviath_core::paths::agents_dir(),
325 now_secs: parts.now_secs,
326 };
327 host.world_mut()
328 .world_mut()
329 .insert_resource(FanOutSpawnerRes(Arc::new(fanout_spawner)));
330
331 let policy = crate::commands::policy::load_policy().unwrap_or_default();
335 host.world_mut()
336 .world_mut()
337 .insert_resource(leviath_runtime::pipeline::PolicyGate(policy));
338
339 host.world_mut()
342 .world_mut()
343 .insert_resource(leviath_runtime::title::TitleSettings(
344 parts.config.title.clone(),
345 ));
346
347 let script_checker =
350 crate::daemon::gate_rules::build_gate_script_checker(&crate::commands::policy::rules_dir());
351 host.world_mut()
352 .world_mut()
353 .insert_resource(leviath_runtime::pipeline::GateScriptRules(script_checker));
354
355 if let Some(built) = leviath_telemetry::build_sink(&parts.config.observability) {
361 host.world_mut()
362 .world_mut()
363 .insert_resource(leviath_runtime::telemetry::Telemetry(built.sink));
364 if let Some(layer) = built.log_layer {
365 crate::logging::install_otel_layer(layer);
366 }
367 }
368
369 let reload_tools = tool_service.clone();
373 let reload_reloader = reloader.clone();
374 let reload_mcp = parts.shared_mcp.clone();
375 let reload_defs = parts.mcp_tool_defs.clone();
376 let reload_hub = hub.clone();
377 let reload_tx = subagent_tx.clone();
378 let reload_runs = parts.runs_dir.clone();
379 let reload_pool = parts.mcp_pool.clone();
380 host.set_reloader(Box::new(move |world, run_id| {
381 let reload_config = reload_reloader.current();
384 let entity = crate::daemon::recovery::reload_run(
385 world,
386 crate::daemon::spawn::SpawnDeps {
387 tool_service: reload_tools.as_ref(),
388 config: &reload_config,
389 shared_mcp: reload_mcp.clone(),
390 mcp_tool_defs: &reload_defs,
391 hub: &reload_hub,
392 now_secs: (parts.now_secs)(),
393 subagent_tx: reload_tx.clone(),
394 },
395 run_id,
396 &reload_runs,
397 );
398 lease_reloaded(&reload_pool, run_id, entity.is_some());
399 entity
400 }));
401
402 let terminate_runs = parts.runs_dir.clone();
408 host.set_force_terminator(Box::new(move |run_id| {
409 crate::runstate::force_cancel_in(&terminate_runs.join(run_id), (parts.now_secs)())
410 .found_run()
411 }));
412
413 host.set_reaper(make_reaper(tool_service.clone(), parts.mcp_pool.clone()));
418
419 let pp_pool = parts.mcp_pool.clone();
428 let pp_agents_dir = leviath_core::paths::agents_dir();
429 host.set_spawn_preprocessor(Box::new(move |args| {
430 let pool = pp_pool.clone();
431 let blueprint_path = args.blueprint_path.clone();
432 let agents_dir = pp_agents_dir.clone();
433 Box::pin(async move {
434 warm_blueprint_mcp(&pool, &blueprint_path).await;
435 warm_fanout_worker_mcp(&pool, &blueprint_path, agents_dir.as_deref()).await;
436 })
437 }));
438
439 let spawn_pool = parts.mcp_pool.clone();
443 let spawn_runs_dir = parts.runs_dir.clone();
444 let spawn_reloader = reloader.clone();
445 host.set_spawner(Box::new(move |world, args| {
446 write_placeholder_meta(&spawn_runs_dir, args);
453 let defs = per_agent_mcp_defs(&spawn_pool, &parts.mcp_tool_defs, &args.blueprint_path);
454 spawn_pool.lease_blueprint(&args.blueprint_path, &args.run_id);
457 let config = spawn_reloader.current();
461 let built = build_agent(
462 world.world_mut(),
463 crate::daemon::spawn::SpawnDeps {
464 tool_service: tool_service.as_ref(),
465 config: &config,
466 shared_mcp: parts.shared_mcp.clone(),
467 mcp_tool_defs: &defs,
468 hub: &hub,
469 now_secs: (parts.now_secs)(),
470 subagent_tx: subagent_tx.clone(),
471 },
472 args,
473 );
474 if let Err(message) = &built {
479 crate::runstate::force_error_in(
480 &spawn_runs_dir.join(&args.run_id),
481 message,
482 (parts.now_secs)(),
483 );
484 }
485 built
486 }));
487 host
488}
489
490fn write_placeholder_meta(runs_dir: &std::path::Path, args: &leviath_runtime::host::SpawnArgs) {
507 let agent_name = args
511 .run_id
512 .rsplitn(3, '-')
513 .nth(2)
514 .unwrap_or(&args.run_id)
515 .to_string();
516 let meta = leviath_core::run_meta::RunMeta::new(
517 args.run_id.clone(),
518 agent_name,
519 args.blueprint_path.clone(),
520 args.task.clone(),
521 None,
522 args.workdir.clone(),
523 0,
524 );
525 if let Err(e) = crate::runstate::create_run_in(&runs_dir.join(&args.run_id), &meta) {
526 tracing::warn!(run_id = %args.run_id, error = %e, "could not pre-create run directory");
527 }
528}
529
530fn lease_reloaded(pool: &crate::daemon::mcp_pool::McpPool, run_id: &str, reloaded: bool) {
535 if !reloaded {
536 return;
537 }
538 if let Ok(meta) = crate::runstate::read_meta(run_id) {
539 pool.lease_blueprint(&meta.agent_path, run_id);
540 }
541}
542
543async fn warm_blueprint_mcp(pool: &crate::daemon::mcp_pool::McpPool, blueprint_path: &str) {
547 if let Ok(toml) = std::fs::read_to_string(blueprint_path) {
548 for server in crate::daemon::mcp_pool::parse_blueprint_mcp_servers(&toml) {
549 pool.ensure(&server).await;
550 }
551 }
552}
553
554async fn warm_fanout_worker_mcp(
561 pool: &crate::daemon::mcp_pool::McpPool,
562 blueprint_path: &str,
563 agents_dir: Option<&std::path::Path>,
564) {
565 let Ok(content) = std::fs::read_to_string(blueprint_path) else {
566 return;
567 };
568 let Ok(blueprint) = leviath_core::manifest::parse_manifest(&content) else {
569 return;
570 };
571 for stage in &blueprint.stages {
572 let leviath_core::blueprint::StageMode::FanOut { config } = &stage.mode else {
573 continue;
574 };
575 if config.worker_stage.is_some() {
577 continue;
578 }
579 let Ok((resolve_path, _)) = crate::daemon::fanout_spawner::resolve_worker_source(
580 config,
581 blueprint_path,
582 agents_dir,
583 ) else {
584 continue;
585 };
586 let Ok(manifest) = crate::commands::run::manifest::find_manifest(&resolve_path) else {
587 continue;
588 };
589 if let Ok(worker_toml) = std::fs::read_to_string(&manifest) {
590 for server in crate::daemon::mcp_pool::parse_blueprint_mcp_servers(&worker_toml) {
591 pool.ensure(&server).await;
592 }
593 }
594 }
595}
596
597fn per_agent_mcp_defs(
602 pool: &crate::daemon::mcp_pool::McpPool,
603 global: &[Tool],
604 blueprint_path: &str,
605) -> Vec<Tool> {
606 let mut defs = global.to_vec();
607 if let Ok(toml) = std::fs::read_to_string(blueprint_path) {
608 let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(&toml);
609 defs.extend(pool.cached_defs_for(&servers));
610 }
611 defs
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use leviath_runtime::components::AgentStatus;
618 use leviath_runtime::host::{ControlOp, SpawnArgs};
619 use tokio::sync::oneshot;
620
621 fn config_with_anthropic_key() -> Config {
624 let mut config = Config::default();
625 config.providers.anthropic_api_key = Some("test-key".to_string());
626 config
627 }
628
629 #[tokio::test]
630 async fn make_reaper_delegates_to_tool_service_reap() {
631 let tool_service = Arc::new(CliToolService::new());
634 let mut world = PipelineWorld::new(
635 ProviderRegistry::new(),
636 tool_service.clone(),
637 InferencePoolConfig::new(),
638 1,
639 None,
640 Handle::current(),
641 );
642 let mut reaper = make_reaper(
643 tool_service.clone(),
644 crate::daemon::mcp_pool::McpPool::for_daemon(
645 Arc::new(tokio::sync::Mutex::new(leviath_mcp::ToolExecutor::new())),
646 &[],
647 ),
648 );
649 let entity = bevy_ecs::entity::Entity::from_raw_u32(1)
652 .expect("a small literal index is always a valid entity id");
653 reaper(&mut world, entity);
654 assert!(tool_service.take(entity).is_none());
655
656 let with_meta = world.spawn_agent((leviath_runtime::persistence::RunMetadata {
660 run_id: "reaped-run".to_string(),
661 agent_name: "a".to_string(),
662 agent_path: "/p".to_string(),
663 task: "t".to_string(),
664 model: None,
665 workdir: "/w".to_string(),
666 num_stages: 1,
667 started_at: 0,
668 parent_run_id: None,
669 metadata: std::collections::HashMap::new(),
670 callback_url: None,
671 callback_secret: None,
672 title: None,
673 unattended: false,
674 read_paths: None,
675 output_request: None,
676 },));
677 reaper(&mut world, with_meta.entity());
678 assert!(tool_service.take(with_meta.entity()).is_none());
679 }
680
681 struct FakeProvider;
682 #[async_trait::async_trait]
683 impl leviath_providers::Provider for FakeProvider {
684 async fn infer(
685 &self,
686 _r: &leviath_providers::InferenceRequest,
687 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
688 Err(leviath_providers::ProviderError::Other("test".to_string()))
689 }
690 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
691 1
692 }
693 fn max_context_tokens(&self, _m: &str) -> usize {
694 1000
695 }
696 fn name(&self) -> &str {
697 "fake"
698 }
699 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
700 leviath_providers::ModelCapabilities::default()
701 }
702 }
703
704 #[test]
705 fn control_address_is_derived_from_leviath_home() {
706 let a = temp_env::with_var("LEVIATH_HOME", Some("/tmp/leviath-home-a"), control_address)
707 .unwrap();
708 let b = temp_env::with_var("LEVIATH_HOME", Some("/tmp/leviath-home-b"), control_address)
709 .unwrap();
710 assert_ne!(a, b);
712 #[cfg(unix)]
714 {
715 assert!(a.ends_with(".leviath/control.sock"));
716 assert!(a.starts_with("/tmp/leviath-home-a"));
717 }
718 }
719
720 #[tokio::test]
721 async fn setup_daemon_host_builds_a_working_host() {
722 let runs = tempfile::tempdir().unwrap();
727 let mut host = setup_daemon_host(
728 config_with_anthropic_key(),
729 runs.path().to_path_buf(),
730 Handle::current(),
731 )
732 .await
733 .expect("the daemon host builds in tests");
734
735 let dir = tempfile::tempdir().unwrap();
738 let manifest = dir.path().join("agent.leviath");
739 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
740 let (reply, rx) = oneshot::channel();
741 host.handle(ControlOp::Spawn {
742 args: Box::new(SpawnArgs {
743 run_id: "run-s".to_string(),
744 blueprint_path: manifest.to_string_lossy().to_string(),
745 task: "t".to_string(),
746 regions: Default::default(),
747 model: None,
748 workdir: std::env::temp_dir().to_string_lossy().to_string(),
749 metadata: Default::default(),
750 callback_url: None,
751 callback_secret: None,
752 yolo: false,
753 no_seed_commands: false,
754 allow: Vec::new(),
755 max_depth: None,
756 parent_run_id: None,
757 output: None,
758 }),
759 reply,
760 });
761 assert_eq!(rx.await.unwrap(), Ok("run-s".to_string()));
762 }
763
764 #[tokio::test]
770 async fn spawner_records_the_failure_in_the_run_dir_it_staked_out() {
771 let runs = tempfile::tempdir().unwrap();
772 let mut host = setup_daemon_host(
773 Config::default(),
774 runs.path().to_path_buf(),
775 Handle::current(),
776 )
777 .await
778 .expect("the daemon host builds in tests");
779 let (reply, rx) = oneshot::channel();
780 host.handle(ControlOp::Spawn {
781 args: Box::new(SpawnArgs {
782 run_id: "my-agent-1234-ab12".to_string(),
785 blueprint_path: "/no/such/agent.leviath".to_string(),
786 task: "t".to_string(),
787 workdir: std::env::temp_dir().to_string_lossy().to_string(),
788 ..Default::default()
789 }),
790 reply,
791 });
792 assert!(rx.await.unwrap().is_err());
793
794 let meta = crate::runstate::read_meta_from(&runs.path().join("my-agent-1234-ab12"))
795 .expect("a failed spawn still leaves meta.json behind");
796 assert_eq!(meta.status, leviath_core::run_meta::RunStatus::Error);
798 assert!(
799 meta.error
800 .is_some_and(|e| e.contains("/no/such/agent.leviath")),
801 "and it says what went wrong"
802 );
803 assert_eq!(meta.task, "t");
804 assert_eq!(meta.agent_name, "my-agent");
806 }
807
808 #[test]
809 fn placeholder_meta_falls_back_to_the_whole_run_id_as_the_agent_name() {
810 let runs = tempfile::tempdir().unwrap();
811 let args = SpawnArgs {
812 run_id: "odd".to_string(),
814 task: "t".to_string(),
815 ..Default::default()
816 };
817 write_placeholder_meta(runs.path(), &args);
818 let meta = crate::runstate::read_meta_from(&runs.path().join("odd")).unwrap();
819 assert_eq!(meta.agent_name, "odd");
820 }
821
822 #[test]
823 fn placeholder_meta_failure_is_logged_not_fatal() {
824 crate::test_support::with_tracing(|| {
827 let dir = tempfile::tempdir().unwrap();
828 let blocker = dir.path().join("not-a-dir");
829 std::fs::write(&blocker, "x").unwrap();
830 let args = SpawnArgs {
831 run_id: "blocked".to_string(),
832 ..Default::default()
833 };
834 write_placeholder_meta(&blocker.join("runs"), &args);
835 assert!(
836 crate::runstate::read_meta_from(&blocker.join("runs").join("blocked")).is_err()
837 );
838 });
839 }
840
841 #[tokio::test]
851 async fn spawner_writes_the_placeholder_under_the_hosts_runs_dir() {
852 let runs = tempfile::tempdir().unwrap();
853 crate::runstate::with_isolated_runs_dir_async(
861 "setup-host-isolation",
862 |global| async move {
863 let global_before = run_ids_in(&global);
864
865 let mut host = setup_daemon_host(
866 Config::default(),
867 runs.path().to_path_buf(),
868 Handle::current(),
869 )
870 .await
871 .expect("the daemon host builds in tests");
872 let (reply, rx) = oneshot::channel();
873 host.handle(ControlOp::Spawn {
874 args: Box::new(SpawnArgs {
875 run_id: "isolation-1234-ab12".to_string(),
879 blueprint_path: "/no/such/agent.leviath".to_string(),
880 task: "t".to_string(),
881 workdir: std::env::temp_dir().to_string_lossy().to_string(),
882 ..Default::default()
883 }),
884 reply,
885 });
886 assert!(rx.await.unwrap().is_err(), "the spawn itself fails");
887
888 assert!(
889 crate::runstate::read_meta_from(&runs.path().join("isolation-1234-ab12"))
890 .is_ok(),
891 "the placeholder lands in the host's configured runs dir"
892 );
893 assert_eq!(
894 run_ids_in(&global),
895 global_before,
896 "spawning through a host must not write into the home-resolved runs dir"
897 );
898 },
899 )
900 .await;
901 }
902
903 #[tokio::test]
909 async fn cancelling_an_unreloadable_run_terminates_it_on_disk() {
910 let runs = tempfile::tempdir().unwrap();
911 let mut host = setup_daemon_host(
912 Config::default(),
913 runs.path().to_path_buf(),
914 Handle::current(),
915 )
916 .await
917 .expect("the daemon host builds in tests");
918
919 let run_dir = runs.path().join("gone-1234-ab12");
923 let meta = leviath_core::run_meta::RunMeta::new(
924 "gone-1234-ab12".to_string(),
925 "gone".to_string(),
926 "/no/such/dir/agent.leviath".to_string(),
928 "t".to_string(),
929 None,
930 std::env::temp_dir().to_string_lossy().to_string(),
931 1,
932 );
933 crate::runstate::create_run_in(&run_dir, &meta).unwrap();
934 assert!(
935 !crate::runstate::is_terminal_status(
936 &crate::runstate::read_meta_from(&run_dir).unwrap().status
937 ),
938 "the run starts out looking live"
939 );
940
941 let (reply, rx) = oneshot::channel();
942 host.handle(ControlOp::Cancel {
943 run_id: "gone-1234-ab12".to_string(),
944 reply,
945 });
946 assert!(rx.await.unwrap(), "the cancel reports that it applied");
947 assert_eq!(
948 crate::runstate::read_meta_from(&run_dir).unwrap().status,
949 leviath_core::run_meta::RunStatus::Cancelled,
950 "and it reached disk, so nothing shows the run as live any more"
951 );
952
953 let (reply, rx) = oneshot::channel();
955 host.handle(ControlOp::Cancel {
956 run_id: "no-such-run".to_string(),
957 reply,
958 });
959 assert!(!rx.await.unwrap());
960 }
961
962 fn run_ids_in(dir: &std::path::Path) -> std::collections::BTreeSet<String> {
965 std::fs::read_dir(dir)
966 .into_iter()
967 .flatten()
968 .flatten()
969 .map(|e| e.file_name().to_string_lossy().into_owned())
970 .collect()
971 }
972
973 #[test]
974 fn run_ids_in_lists_entries_and_tolerates_a_missing_dir() {
975 let dir = tempfile::tempdir().unwrap();
976 std::fs::create_dir_all(dir.path().join("run-one")).unwrap();
977 std::fs::create_dir_all(dir.path().join("run-two")).unwrap();
978 assert_eq!(
979 run_ids_in(dir.path()),
980 ["run-one".to_string(), "run-two".to_string()]
981 .into_iter()
982 .collect()
983 );
984 assert!(run_ids_in(&dir.path().join("nope")).is_empty());
986 }
987
988 fn stub_server_py() -> (tempfile::TempDir, std::path::PathBuf) {
992 let dir = tempfile::tempdir().unwrap();
993 let path = dir.path().join("stub.py");
994 std::fs::write(
995 &path,
996 r#"
997import sys, json
998def respond(i, r):
999 sys.stdout.write(json.dumps({"jsonrpc":"2.0","id":i,"result":r})+"\n"); sys.stdout.flush()
1000for line in sys.stdin:
1001 line=line.strip()
1002 if not line: continue
1003 req=json.loads(line); m=req.get("method",""); i=req.get("id")
1004 if m=="initialize": respond(i,{"capabilities":{"tools":{"listChanged":True}},"protocolVersion":"2024-11-05"})
1005 elif m=="notifications/initialized": pass
1006 elif m=="tools/list": respond(i,{"tools":[{"name":"stub_search","description":"s","inputSchema":{"type":"object","properties":{}}}]})
1007 elif m=="tools/call": respond(i,{"content":[{"type":"text","text":"ok"}],"isError":False})
1008 else: respond(i,{})
1009"#,
1010 )
1011 .unwrap();
1012 (dir, path)
1013 }
1014
1015 fn blueprint_with_mcp(dir: &std::path::Path, stub_py: &std::path::Path) -> std::path::PathBuf {
1018 let manifest = dir.join("agent.leviath");
1019 std::fs::write(
1020 &manifest,
1021 format!(
1022 r#"
1023[agent]
1024name = "mcpagent"
1025entry_stage = "work"
1026
1027[[mcp_servers]]
1028name = "search"
1029command = "python3"
1030args = ['{}']
1031
1032[stages.work]
1033mode = "autonomous"
1034model = {{ provider = "fake", model = "m" }}
1035available_tools = ["stub_search"]
1036system_prompt = "use stub_search"
1037
1038[context.regions]
1039task = {{ kind = "pinned", max_tokens = 200, seed = {{ caller = "task" }} }}
1040"#,
1041 stub_py.to_string_lossy()
1042 ),
1043 )
1044 .unwrap();
1045 manifest
1046 }
1047
1048 fn empty_pool() -> crate::daemon::mcp_pool::McpPool {
1049 crate::daemon::mcp_pool::McpPool::new(
1050 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1051 Default::default(),
1052 )
1053 }
1054
1055 #[test]
1059 fn lease_reloaded_leases_only_on_a_successful_reload() {
1060 crate::runstate::with_isolated_runs_dir("lease-reloaded", |_d| {
1061 let pool = empty_pool();
1062 lease_reloaded(&pool, "any-run", false);
1063 lease_reloaded(&pool, "ghost-run", true);
1064 let meta = leviath_core::run_meta::RunMeta::new(
1065 "reloaded-run".to_string(),
1066 "agent".to_string(),
1067 "/no/such/agent.leviath".to_string(),
1068 "t".to_string(),
1069 None,
1070 "/w".to_string(),
1071 1,
1072 );
1073 crate::runstate::create_run(&meta).unwrap();
1074 lease_reloaded(&pool, "reloaded-run", true);
1077 });
1078 }
1079
1080 #[tokio::test]
1081 async fn warm_blueprint_mcp_connects_declared_servers() {
1082 let (_stub_dir, stub) = stub_server_py();
1083 let dir = tempfile::tempdir().unwrap();
1084 let manifest = blueprint_with_mcp(dir.path(), &stub);
1085 let pool = empty_pool();
1086 warm_blueprint_mcp(&pool, &manifest.to_string_lossy()).await;
1087 let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(
1089 &std::fs::read_to_string(&manifest).unwrap(),
1090 );
1091 let defs = pool.cached_defs_for(&servers);
1092 assert_eq!(defs.len(), 1);
1093 assert_eq!(defs[0].name, "stub_search");
1094 }
1095
1096 #[tokio::test]
1097 async fn warm_blueprint_mcp_missing_manifest_is_noop() {
1098 let pool = empty_pool();
1099 warm_blueprint_mcp(&pool, "/no/such/agent.leviath").await;
1101 }
1102
1103 fn parent_with_fanout_worker_agent(
1106 dir: &std::path::Path,
1107 worker_source: &str,
1108 ) -> std::path::PathBuf {
1109 let manifest = dir.join("parent.leviath");
1110 std::fs::write(
1111 &manifest,
1112 format!(
1113 "[agent]\nname = \"parent\"\n\n\
1114 [stages.main]\nmode = \"autonomous\"\n\n\
1115 [stages.parallel]\nmode = \"fan_out\"\nworker_agent = '{worker_source}'\nsplit_prompt = \"go\"\n"
1116 ),
1117 )
1118 .unwrap();
1119 manifest
1120 }
1121
1122 #[tokio::test]
1123 async fn warm_fanout_worker_mcp_prewarms_worker_agent_servers() {
1124 let (_stub_dir, stub) = stub_server_py();
1125 let worker_dir = tempfile::tempdir().unwrap();
1127 blueprint_with_mcp(worker_dir.path(), &stub);
1128 let parent_dir = tempfile::tempdir().unwrap();
1130 let parent = parent_with_fanout_worker_agent(
1131 parent_dir.path(),
1132 &worker_dir.path().to_string_lossy(),
1133 );
1134 let pool = empty_pool();
1135 warm_fanout_worker_mcp(&pool, &parent.to_string_lossy(), None).await;
1136 let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(
1139 &std::fs::read_to_string(worker_dir.path().join("agent.leviath")).unwrap(),
1140 );
1141 let defs = pool.cached_defs_for(&servers);
1142 assert_eq!(defs.len(), 1);
1143 assert_eq!(defs[0].name, "stub_search");
1144 }
1145
1146 #[tokio::test]
1147 async fn warm_fanout_worker_mcp_skips_and_tolerates_every_arm() {
1148 let pool = empty_pool();
1149 warm_fanout_worker_mcp(&pool, "/no/such/parent.leviath", None).await;
1151 let dir = tempfile::tempdir().unwrap();
1153 let bad = dir.path().join("bad.leviath");
1154 std::fs::write(&bad, "not : valid : toml").unwrap();
1155 warm_fanout_worker_mcp(&pool, &bad.to_string_lossy(), None).await;
1156 let plain = dir.path().join("plain.leviath");
1158 std::fs::write(
1159 &plain,
1160 "[agent]\nname = \"p\"\n\n[stages.main]\nmode = \"autonomous\"\n",
1161 )
1162 .unwrap();
1163 warm_fanout_worker_mcp(&pool, &plain.to_string_lossy(), None).await;
1164 let ws = dir.path().join("ws.leviath");
1166 std::fs::write(
1167 &ws,
1168 "[agent]\nname = \"p\"\n\n\
1169 [stages.parallel]\nmode = \"fan_out\"\nworker_stage = \"w\"\nsplit_prompt = \"go\"\n\n\
1170 [stages.w]\nmode = \"autonomous\"\nallow_as_worker = true\n",
1171 )
1172 .unwrap();
1173 warm_fanout_worker_mcp(&pool, &ws.to_string_lossy(), None).await;
1174 let wq = dir.path().join("wq.leviath");
1176 std::fs::write(
1177 &wq,
1178 "[agent]\nname = \"p\"\n\n\
1179 [stages.parallel]\nmode = \"fan_out\"\nworker_query = \"x\"\nsplit_prompt = \"go\"\n",
1180 )
1181 .unwrap();
1182 warm_fanout_worker_mcp(&pool, &wq.to_string_lossy(), None).await;
1183 let miss = parent_with_fanout_worker_agent(dir.path(), "/no/such/worker/xyz");
1185 warm_fanout_worker_mcp(&pool, &miss.to_string_lossy(), None).await;
1186 let worker_dir = tempfile::tempdir().unwrap();
1189 std::fs::write(
1190 worker_dir.path().join("agent.leviath"),
1191 "[agent]\nname = \"w\"\n\n[stages.main]\nmode = \"autonomous\"\n",
1192 )
1193 .unwrap();
1194 let noservers =
1195 parent_with_fanout_worker_agent(dir.path(), &worker_dir.path().to_string_lossy());
1196 warm_fanout_worker_mcp(&pool, &noservers.to_string_lossy(), None).await;
1197 let dir_manifest = tempfile::tempdir().unwrap();
1201 std::fs::create_dir(dir_manifest.path().join("agent.leviath")).unwrap();
1202 let unreadable =
1203 parent_with_fanout_worker_agent(dir.path(), &dir_manifest.path().to_string_lossy());
1204 warm_fanout_worker_mcp(&pool, &unreadable.to_string_lossy(), None).await;
1205 }
1206
1207 #[test]
1208 fn per_agent_mcp_defs_appends_declared_and_falls_back_to_global() {
1209 let (_stub_dir, stub) = stub_server_py();
1210 let dir = tempfile::tempdir().unwrap();
1211 let manifest = blueprint_with_mcp(dir.path(), &stub);
1212 let pool = empty_pool();
1213 let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(
1216 &std::fs::read_to_string(&manifest).unwrap(),
1217 );
1218 pool.seed(
1219 &servers[0],
1220 vec![Tool {
1221 name: "stub_search".into(),
1222 description: String::new(),
1223 parameters: serde_json::json!({}),
1224 }],
1225 );
1226 let global = vec![Tool {
1227 name: "global_tool".into(),
1228 description: String::new(),
1229 parameters: serde_json::json!({}),
1230 }];
1231 let defs = per_agent_mcp_defs(&pool, &global, &manifest.to_string_lossy());
1232 let names: Vec<&str> = defs.iter().map(|t| t.name.as_str()).collect();
1233 assert_eq!(names, vec!["global_tool", "stub_search"]);
1234 let only_global = per_agent_mcp_defs(&pool, &global, "/no/such/x");
1236 assert_eq!(only_global.len(), 1);
1237 assert_eq!(only_global[0].name, "global_tool");
1238 }
1239
1240 #[tokio::test]
1241 async fn build_host_seeds_global_mcp_servers() {
1242 let config = Config {
1244 mcp_servers: vec![leviath_mcp::MCPServerConfig::stdio(
1245 "global-srv",
1246 "python3",
1247 vec!["-c".to_string(), "pass".to_string()],
1248 )],
1249 ..Config::default()
1250 };
1251 let runs = tempfile::tempdir().unwrap();
1252 let _host = build_host(HostParts {
1253 config,
1254 providers: ProviderRegistry::new(),
1255 runs_dir: runs.path().to_path_buf(),
1256 shared_mcp: Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1257 mcp_tool_defs: Vec::new(),
1258 mcp_pool: crate::daemon::mcp_pool::McpPool::for_daemon(
1259 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1260 &[],
1261 ),
1262 runtime: Handle::current(),
1263 now_secs: || 0,
1264 });
1265 }
1266
1267 #[tokio::test]
1268 async fn build_host_installs_the_configured_telemetry_sink() {
1269 let config = Config {
1271 observability: leviath_core::config::ObservabilityConfig {
1272 enabled: true,
1273 exporter: leviath_core::config::TelemetryExporterKind::Stdout,
1274 endpoint: None,
1275 service_name: None,
1276 },
1277 ..Config::default()
1278 };
1279 let runs = tempfile::tempdir().unwrap();
1280 let mut host = build_host(HostParts {
1281 config,
1282 providers: ProviderRegistry::new(),
1283 runs_dir: runs.path().to_path_buf(),
1284 shared_mcp: Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1285 mcp_tool_defs: Vec::new(),
1286 mcp_pool: crate::daemon::mcp_pool::McpPool::for_daemon(
1287 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1288 &[],
1289 ),
1290 runtime: Handle::current(),
1291 now_secs: || 0,
1292 });
1293 assert!(
1294 host.world_mut()
1295 .world_mut()
1296 .get_resource::<leviath_runtime::telemetry::Telemetry>()
1297 .is_some()
1298 );
1299 }
1300
1301 #[tokio::test(flavor = "multi_thread")]
1302 async fn build_host_with_otlp_also_installs_the_log_layer() {
1303 let config = Config {
1309 observability: leviath_core::config::ObservabilityConfig {
1310 enabled: true,
1311 exporter: leviath_core::config::TelemetryExporterKind::Otlp,
1312 endpoint: Some("http://127.0.0.1:9".to_string()),
1313 service_name: Some("leviath-test".to_string()),
1314 },
1315 ..Config::default()
1316 };
1317 let runs = tempfile::tempdir().unwrap();
1318 let mut host = build_host(HostParts {
1319 config,
1320 providers: ProviderRegistry::new(),
1321 runs_dir: runs.path().to_path_buf(),
1322 shared_mcp: Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1323 mcp_tool_defs: Vec::new(),
1324 mcp_pool: crate::daemon::mcp_pool::McpPool::for_daemon(
1325 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1326 &[],
1327 ),
1328 runtime: Handle::current(),
1329 now_secs: || 0,
1330 });
1331 assert!(
1332 host.world_mut()
1333 .world_mut()
1334 .get_resource::<leviath_runtime::telemetry::Telemetry>()
1335 .is_some()
1336 );
1337 }
1338
1339 #[tokio::test]
1340 async fn serve_runs_spawn_preprocessor_for_per_agent_mcp() {
1341 let (_stub_dir, stub) = stub_server_py();
1345 let agent_dir = tempfile::tempdir().unwrap();
1346 let manifest = blueprint_with_mcp(agent_dir.path(), &stub);
1347 let mut providers = ProviderRegistry::new();
1349 providers.register("fake".to_string(), Arc::new(FakeProvider));
1350 let runs = tempfile::tempdir().unwrap();
1351 let mut host = build_host(HostParts {
1352 config: Config::default(),
1353 providers,
1354 runs_dir: runs.path().to_path_buf(),
1355 shared_mcp: Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1356 mcp_tool_defs: Vec::new(),
1357 mcp_pool: crate::daemon::mcp_pool::McpPool::for_daemon(
1358 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1359 &[],
1360 ),
1361 runtime: Handle::current(),
1362 now_secs: || 0,
1363 });
1364 let (ctl_tx, ctl_rx) = tokio::sync::mpsc::unbounded_channel();
1365 let (reply, reply_rx) = oneshot::channel();
1366 ctl_tx
1367 .send(ControlOp::Spawn {
1368 args: Box::new(SpawnArgs {
1369 run_id: "run-mcp".to_string(),
1370 blueprint_path: manifest.to_string_lossy().to_string(),
1371 task: "t".to_string(),
1372 regions: Default::default(),
1373 model: None,
1374 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1375 metadata: Default::default(),
1376 callback_url: None,
1377 callback_secret: None,
1378 yolo: false,
1379 no_seed_commands: false,
1380 allow: Vec::new(),
1381 max_depth: None,
1382 parent_run_id: None,
1383 output: None,
1384 }),
1385 reply,
1386 })
1387 .unwrap();
1388 drop(ctl_tx);
1390 host.serve(ctl_rx).await;
1391 assert_eq!(reply_rx.await.unwrap(), Ok("run-mcp".to_string()));
1392 }
1393
1394 #[tokio::test]
1395 async fn fake_provider_methods_are_exercised() {
1396 use leviath_providers::Provider;
1397 let p = FakeProvider;
1398 assert_eq!(p.name(), "fake");
1399 assert_eq!(p.count_tokens("t", "m").await, 1);
1400 assert_eq!(p.max_context_tokens("m"), 1000);
1401 let _ = p.capabilities("m");
1402 assert!(
1403 p.infer(&leviath_providers::InferenceRequest {
1404 system: vec![],
1405 messages: vec![],
1406 model: "m".to_string(),
1407 max_tokens: 1,
1408 temperature: 0.0,
1409 tools: vec![],
1410 extra: serde_json::Value::Null,
1411 request_timeout_secs: None,
1412 })
1413 .await
1414 .is_err()
1415 );
1416 }
1417
1418 #[tokio::test]
1419 async fn build_host_spawns_agents_through_the_installed_spawner() {
1420 let dir = tempfile::tempdir().unwrap();
1421 let manifest = dir.path().join("agent.leviath");
1422 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
1423
1424 let mut registry = ProviderRegistry::new();
1425 registry.register("anthropic".to_string(), Arc::new(FakeProvider));
1426 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1427
1428 let runs = tempfile::tempdir().unwrap();
1429 let mut host = build_host(HostParts {
1430 config: Config::default(),
1431 providers: registry,
1432 runs_dir: runs.path().to_path_buf(),
1433 shared_mcp: mcp,
1434 mcp_tool_defs: vec![],
1435 mcp_pool: crate::daemon::mcp_pool::McpPool::for_daemon(
1436 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1437 &[],
1438 ),
1439 runtime: Handle::current(),
1440 now_secs: || 100,
1441 });
1442
1443 let (reply, rx) = oneshot::channel();
1445 host.handle(ControlOp::Spawn {
1446 args: Box::new(SpawnArgs {
1447 run_id: "run-1".to_string(),
1448 blueprint_path: manifest.to_string_lossy().to_string(),
1449 task: "do it".to_string(),
1450 regions: Default::default(),
1451 model: None,
1452 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1453 metadata: Default::default(),
1454 callback_url: None,
1455 callback_secret: None,
1456 yolo: false,
1457 no_seed_commands: false,
1458 allow: Vec::new(),
1459 max_depth: None,
1460 parent_run_id: None,
1461 output: None,
1462 }),
1463 reply,
1464 });
1465 assert_eq!(rx.await.unwrap(), Ok("run-1".to_string()));
1466
1467 let (reply, rx) = oneshot::channel();
1469 host.handle(ControlOp::Status {
1470 run_id: "run-1".to_string(),
1471 reply,
1472 });
1473 assert_eq!(rx.await.unwrap(), Some(AgentStatus::Active));
1474 }
1475
1476 #[tokio::test]
1477 async fn build_host_reloads_and_registers_persisted_runs() {
1478 let agent = tempfile::tempdir().unwrap();
1481 let manifest = agent.path().join("agent.leviath");
1482 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
1483
1484 let runs = tempfile::tempdir().unwrap();
1485 let run_dir = runs.path().join("resumed");
1486 std::fs::create_dir_all(&run_dir).unwrap();
1487 let meta = leviath_core::run_meta::RunMeta {
1488 run_id: "resumed".to_string(),
1489 agent_name: "coder".to_string(),
1490 agent_path: manifest.to_string_lossy().to_string(),
1491 task: "resume".to_string(),
1492 model: None,
1493 pid: 0,
1494 status: leviath_core::run_meta::RunStatus::Running,
1495 current_stage: "implement".to_string(),
1496 stage_index: 0,
1497 num_stages: 1,
1498 iteration: 2,
1499 prompt_tokens: 0,
1500 completion_tokens: 0,
1501 cached_tokens: 0,
1502 cache_write_tokens: 0,
1503 tool_calls: 0,
1504 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1505 started_at: 1,
1506 updated_at: 1,
1507 last_progress_at: None,
1508 error: None,
1509 title: None,
1510 metadata: Default::default(),
1511 callback_url: None,
1512 callback_secret: None,
1513 parent_run_id: None,
1514 children: Vec::new(),
1515 depth: 0,
1516 max_child_depth: 0,
1517 flags: Default::default(),
1518 yolo: false,
1519 read_paths: None,
1520 final_output: None,
1521 output_request: None,
1522 };
1523 std::fs::write(
1524 run_dir.join("meta.json"),
1525 serde_json::to_string(&meta).unwrap(),
1526 )
1527 .unwrap();
1528
1529 let mut registry = ProviderRegistry::new();
1530 registry.register("anthropic".to_string(), Arc::new(FakeProvider));
1531 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1532 let mut host = build_host(HostParts {
1533 config: Config::default(),
1534 providers: registry,
1535 runs_dir: runs.path().to_path_buf(),
1536 shared_mcp: mcp,
1537 mcp_tool_defs: vec![],
1538 mcp_pool: crate::daemon::mcp_pool::McpPool::for_daemon(
1539 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1540 &[],
1541 ),
1542 runtime: Handle::current(),
1543 now_secs: || 100,
1544 });
1545
1546 let (reply, rx) = oneshot::channel();
1548 host.handle(ControlOp::Status {
1549 run_id: "resumed".to_string(),
1550 reply,
1551 });
1552 assert_eq!(rx.await.unwrap(), Some(AgentStatus::Active));
1553 }
1554
1555 #[tokio::test]
1556 async fn build_host_installs_a_reloader_that_pages_in_unloaded_runs() {
1557 let agent = tempfile::tempdir().unwrap();
1561 let manifest = agent.path().join("agent.leviath");
1562 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
1563
1564 let runs = tempfile::tempdir().unwrap();
1565 let mut registry = ProviderRegistry::new();
1566 registry.register("anthropic".to_string(), Arc::new(FakeProvider));
1567 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1568 let mut host = build_host(HostParts {
1569 config: Config::default(),
1570 providers: registry,
1571 runs_dir: runs.path().to_path_buf(),
1572 shared_mcp: mcp,
1573 mcp_tool_defs: vec![],
1574 mcp_pool: crate::daemon::mcp_pool::McpPool::for_daemon(
1575 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1576 &[],
1577 ),
1578 runtime: Handle::current(),
1579 now_secs: || 100,
1580 });
1581
1582 let run_dir = runs.path().join("late");
1585 std::fs::create_dir_all(&run_dir).unwrap();
1586 let meta = leviath_core::run_meta::RunMeta {
1587 run_id: "late".to_string(),
1588 agent_name: "coder".to_string(),
1589 agent_path: manifest.to_string_lossy().to_string(),
1590 task: "page me in".to_string(),
1591 model: None,
1592 pid: 0,
1593 status: leviath_core::run_meta::RunStatus::Running,
1594 current_stage: "implement".to_string(),
1595 stage_index: 0,
1596 num_stages: 1,
1597 iteration: 1,
1598 prompt_tokens: 0,
1599 completion_tokens: 0,
1600 cached_tokens: 0,
1601 cache_write_tokens: 0,
1602 tool_calls: 0,
1603 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1604 started_at: 1,
1605 updated_at: 1,
1606 last_progress_at: None,
1607 error: None,
1608 title: None,
1609 metadata: Default::default(),
1610 callback_url: None,
1611 callback_secret: None,
1612 parent_run_id: None,
1613 children: Vec::new(),
1614 depth: 0,
1615 max_child_depth: 0,
1616 flags: Default::default(),
1617 yolo: false,
1618 read_paths: None,
1619 final_output: None,
1620 output_request: None,
1621 };
1622 std::fs::write(
1623 run_dir.join("meta.json"),
1624 serde_json::to_string(&meta).unwrap(),
1625 )
1626 .unwrap();
1627
1628 let (reply, rx) = oneshot::channel();
1630 host.handle(ControlOp::Status {
1631 run_id: "late".to_string(),
1632 reply,
1633 });
1634 assert_eq!(rx.await.unwrap(), None);
1635
1636 let (reply, rx) = oneshot::channel();
1638 host.handle(ControlOp::Cancel {
1639 run_id: "late".to_string(),
1640 reply,
1641 });
1642 assert!(rx.await.unwrap());
1643 }
1644
1645 #[test]
1646 fn daemon_build_is_stale_compares_against_current_build() {
1647 assert!(daemon_build_is_stale(None), "missing marker is stale");
1648 assert!(
1649 daemon_build_is_stale(Some("some-other-build")),
1650 "a different build is stale"
1651 );
1652 assert!(
1653 !daemon_build_is_stale(Some(CURRENT_BUILD)),
1654 "the current build is not stale"
1655 );
1656 }
1657
1658 #[test]
1659 fn build_marker_round_trips_and_is_current() {
1660 let dir = tempfile::tempdir().unwrap();
1661 temp_env::with_var("LEVIATH_HOME", Some(dir.path()), || {
1662 assert!(read_build_marker().is_none());
1664 assert!(daemon_build_is_stale(read_build_marker().as_deref()));
1665
1666 write_build_marker();
1667 let path = build_marker_path().unwrap();
1668 assert!(path.exists());
1669 assert_eq!(read_build_marker().as_deref(), Some(CURRENT_BUILD));
1670 assert!(!daemon_build_is_stale(read_build_marker().as_deref()));
1672 });
1673 }
1674
1675 #[tokio::test]
1676 async fn the_daemon_refuses_to_start_without_a_usable_https_client() {
1677 let dir = tempfile::tempdir().expect("tempdir");
1680 let mut config = Config::default();
1681 config.providers.anthropic_api_key = Some("k".to_string());
1682 let err =
1683 setup_daemon_host_with(config, dir.path().to_path_buf(), Handle::current(), &|_t| {
1684 Err(leviath_providers::provider::malformed_url_error())
1685 })
1686 .await
1687 .err()
1688 .expect("a failing client factory should stop the daemon starting");
1689 assert!(err.to_string().contains("root certificate store"));
1690 }
1691}