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.insert_interaction_hub(hub.clone());
263 let mut host = WorldHost::with_interactions(world, hub.clone());
264 host.set_dead_cycles_before_relief(parts.config.limits.dead_cycles_before_relief);
267 host.set_finished_retention_secs(parts.config.limits.finished_retention_secs);
270 let subagent_tx = host.subagent_sender();
273
274 let reloaded = crate::daemon::recovery::reload_persisted_agents(
278 host.world_mut(),
279 crate::daemon::spawn::SpawnDeps {
280 tool_service: tool_service.as_ref(),
281 config: &parts.config,
282 shared_mcp: parts.shared_mcp.clone(),
283 mcp_tool_defs: &parts.mcp_tool_defs,
284 hub: &hub,
285 now_secs: (parts.now_secs)(),
286 subagent_tx: subagent_tx.clone(),
287 },
288 &parts.runs_dir,
289 );
290 for (run_id, entity) in reloaded {
291 host.register(run_id, entity);
292 }
293
294 let reloader = std::sync::Arc::new(crate::daemon::config_reload::ConfigReloader::new(
301 Config::config_path(),
302 parts.config.clone(),
303 ));
304
305 let fanout_spawner = DaemonFanOutSpawner {
309 config: reloader.clone(),
310 shared_mcp: parts.shared_mcp.clone(),
311 mcp_tool_defs: parts.mcp_tool_defs.clone(),
312 mcp_pool: parts.mcp_pool.clone(),
313 hub: hub.clone(),
314 subagent_tx: subagent_tx.clone(),
315 tool_service: tool_service.clone(),
316 agents_dir: leviath_core::paths::agents_dir(),
317 now_secs: parts.now_secs,
318 };
319 host.world_mut()
320 .world_mut()
321 .insert_resource(FanOutSpawnerRes(Arc::new(fanout_spawner)));
322
323 let policy = crate::commands::policy::load_policy().unwrap_or_default();
327 host.world_mut()
328 .world_mut()
329 .insert_resource(leviath_runtime::pipeline::PolicyGate(policy));
330
331 host.world_mut()
334 .world_mut()
335 .insert_resource(leviath_runtime::title::TitleSettings(
336 parts.config.title.clone(),
337 ));
338
339 let script_checker =
342 crate::daemon::gate_rules::build_gate_script_checker(&crate::commands::policy::rules_dir());
343 host.world_mut()
344 .world_mut()
345 .insert_resource(leviath_runtime::pipeline::GateScriptRules(script_checker));
346
347 if let Some(built) = leviath_telemetry::build_sink(&parts.config.observability) {
353 host.world_mut()
354 .world_mut()
355 .insert_resource(leviath_runtime::telemetry::Telemetry(built.sink));
356 if let Some(layer) = built.log_layer {
357 crate::logging::install_otel_layer(layer);
358 }
359 }
360
361 let reload_tools = tool_service.clone();
365 let reload_reloader = reloader.clone();
366 let reload_mcp = parts.shared_mcp.clone();
367 let reload_defs = parts.mcp_tool_defs.clone();
368 let reload_hub = hub.clone();
369 let reload_tx = subagent_tx.clone();
370 let reload_runs = parts.runs_dir.clone();
371 let reload_pool = parts.mcp_pool.clone();
372 host.set_reloader(Box::new(move |world, run_id| {
373 let reload_config = reload_reloader.current();
376 let entity = crate::daemon::recovery::reload_run(
377 world,
378 crate::daemon::spawn::SpawnDeps {
379 tool_service: reload_tools.as_ref(),
380 config: &reload_config,
381 shared_mcp: reload_mcp.clone(),
382 mcp_tool_defs: &reload_defs,
383 hub: &reload_hub,
384 now_secs: (parts.now_secs)(),
385 subagent_tx: reload_tx.clone(),
386 },
387 run_id,
388 &reload_runs,
389 );
390 lease_reloaded(&reload_pool, run_id, entity.is_some());
391 entity
392 }));
393
394 let terminate_runs = parts.runs_dir.clone();
400 host.set_force_terminator(Box::new(move |run_id| {
401 crate::runstate::force_cancel_in(&terminate_runs.join(run_id), (parts.now_secs)())
402 .found_run()
403 }));
404
405 host.set_reaper(make_reaper(tool_service.clone(), parts.mcp_pool.clone()));
410
411 let pp_pool = parts.mcp_pool.clone();
420 let pp_agents_dir = leviath_core::paths::agents_dir();
421 host.set_spawn_preprocessor(Box::new(move |args| {
422 let pool = pp_pool.clone();
423 let blueprint_path = args.blueprint_path.clone();
424 let agents_dir = pp_agents_dir.clone();
425 Box::pin(async move {
426 warm_blueprint_mcp(&pool, &blueprint_path).await;
427 warm_fanout_worker_mcp(&pool, &blueprint_path, agents_dir.as_deref()).await;
428 })
429 }));
430
431 let spawn_pool = parts.mcp_pool.clone();
435 let spawn_runs_dir = parts.runs_dir.clone();
436 let spawn_reloader = reloader.clone();
437 host.set_spawner(Box::new(move |world, args| {
438 write_placeholder_meta(&spawn_runs_dir, args);
445 let defs = per_agent_mcp_defs(&spawn_pool, &parts.mcp_tool_defs, &args.blueprint_path);
446 spawn_pool.lease_blueprint(&args.blueprint_path, &args.run_id);
449 let config = spawn_reloader.current();
453 let built = build_agent(
454 world.world_mut(),
455 crate::daemon::spawn::SpawnDeps {
456 tool_service: tool_service.as_ref(),
457 config: &config,
458 shared_mcp: parts.shared_mcp.clone(),
459 mcp_tool_defs: &defs,
460 hub: &hub,
461 now_secs: (parts.now_secs)(),
462 subagent_tx: subagent_tx.clone(),
463 },
464 args,
465 );
466 if let Err(message) = &built {
471 crate::runstate::force_error_in(
472 &spawn_runs_dir.join(&args.run_id),
473 message,
474 (parts.now_secs)(),
475 );
476 }
477 built
478 }));
479 host
480}
481
482fn write_placeholder_meta(runs_dir: &std::path::Path, args: &leviath_runtime::host::SpawnArgs) {
499 let agent_name = args
503 .run_id
504 .rsplitn(3, '-')
505 .nth(2)
506 .unwrap_or(&args.run_id)
507 .to_string();
508 let meta = leviath_core::run_meta::RunMeta::new(
509 args.run_id.clone(),
510 agent_name,
511 args.blueprint_path.clone(),
512 args.task.clone(),
513 None,
514 args.workdir.clone(),
515 0,
516 );
517 if let Err(e) = crate::runstate::create_run_in(&runs_dir.join(&args.run_id), &meta) {
518 tracing::warn!(run_id = %args.run_id, error = %e, "could not pre-create run directory");
519 }
520}
521
522fn lease_reloaded(pool: &crate::daemon::mcp_pool::McpPool, run_id: &str, reloaded: bool) {
527 if !reloaded {
528 return;
529 }
530 if let Ok(meta) = crate::runstate::read_meta(run_id) {
531 pool.lease_blueprint(&meta.agent_path, run_id);
532 }
533}
534
535async fn warm_blueprint_mcp(pool: &crate::daemon::mcp_pool::McpPool, blueprint_path: &str) {
539 if let Ok(toml) = std::fs::read_to_string(blueprint_path) {
540 for server in crate::daemon::mcp_pool::parse_blueprint_mcp_servers(&toml) {
541 pool.ensure(&server).await;
542 }
543 }
544}
545
546async fn warm_fanout_worker_mcp(
553 pool: &crate::daemon::mcp_pool::McpPool,
554 blueprint_path: &str,
555 agents_dir: Option<&std::path::Path>,
556) {
557 let Ok(content) = std::fs::read_to_string(blueprint_path) else {
558 return;
559 };
560 let Ok(blueprint) = leviath_core::manifest::parse_manifest(&content) else {
561 return;
562 };
563 for stage in &blueprint.stages {
564 let leviath_core::blueprint::StageMode::FanOut { config } = &stage.mode else {
565 continue;
566 };
567 if config.worker_stage.is_some() {
569 continue;
570 }
571 let Ok((resolve_path, _)) = crate::daemon::fanout_spawner::resolve_worker_source(
572 config,
573 blueprint_path,
574 agents_dir,
575 ) else {
576 continue;
577 };
578 let Ok(manifest) = crate::commands::run::manifest::find_manifest(&resolve_path) else {
579 continue;
580 };
581 if let Ok(worker_toml) = std::fs::read_to_string(&manifest) {
582 for server in crate::daemon::mcp_pool::parse_blueprint_mcp_servers(&worker_toml) {
583 pool.ensure(&server).await;
584 }
585 }
586 }
587}
588
589fn per_agent_mcp_defs(
594 pool: &crate::daemon::mcp_pool::McpPool,
595 global: &[Tool],
596 blueprint_path: &str,
597) -> Vec<Tool> {
598 let mut defs = global.to_vec();
599 if let Ok(toml) = std::fs::read_to_string(blueprint_path) {
600 let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(&toml);
601 defs.extend(pool.cached_defs_for(&servers));
602 }
603 defs
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609 use leviath_runtime::components::AgentStatus;
610 use leviath_runtime::host::{ControlOp, SpawnArgs};
611 use tokio::sync::oneshot;
612
613 fn config_with_anthropic_key() -> Config {
616 let mut config = Config::default();
617 config.providers.anthropic_api_key = Some("test-key".to_string());
618 config
619 }
620
621 #[tokio::test]
622 async fn make_reaper_delegates_to_tool_service_reap() {
623 let tool_service = Arc::new(CliToolService::new());
626 let mut world = PipelineWorld::new(
627 ProviderRegistry::new(),
628 tool_service.clone(),
629 InferencePoolConfig::new(),
630 1,
631 None,
632 Handle::current(),
633 );
634 let mut reaper = make_reaper(
635 tool_service.clone(),
636 crate::daemon::mcp_pool::McpPool::for_daemon(
637 Arc::new(tokio::sync::Mutex::new(leviath_mcp::ToolExecutor::new())),
638 &[],
639 ),
640 );
641 let entity = bevy_ecs::entity::Entity::from_raw_u32(1)
644 .expect("a small literal index is always a valid entity id");
645 reaper(&mut world, entity);
646 assert!(tool_service.take(entity).is_none());
647
648 let with_meta = world.spawn_agent((leviath_runtime::persistence::RunMetadata {
652 run_id: "reaped-run".to_string(),
653 agent_name: "a".to_string(),
654 agent_path: "/p".to_string(),
655 task: "t".to_string(),
656 model: None,
657 workdir: "/w".to_string(),
658 num_stages: 1,
659 started_at: 0,
660 parent_run_id: None,
661 metadata: std::collections::HashMap::new(),
662 callback_url: None,
663 callback_secret: None,
664 title: None,
665 unattended: false,
666 read_paths: None,
667 output_request: None,
668 },));
669 reaper(&mut world, with_meta.entity());
670 assert!(tool_service.take(with_meta.entity()).is_none());
671 }
672
673 struct FakeProvider;
674 #[async_trait::async_trait]
675 impl leviath_providers::Provider for FakeProvider {
676 async fn infer(
677 &self,
678 _r: &leviath_providers::InferenceRequest,
679 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
680 Err(leviath_providers::ProviderError::Other("test".to_string()))
681 }
682 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
683 1
684 }
685 fn max_context_tokens(&self, _m: &str) -> usize {
686 1000
687 }
688 fn name(&self) -> &str {
689 "fake"
690 }
691 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
692 leviath_providers::ModelCapabilities::default()
693 }
694 }
695
696 #[test]
697 fn control_address_is_derived_from_leviath_home() {
698 let a = temp_env::with_var("LEVIATH_HOME", Some("/tmp/leviath-home-a"), control_address)
699 .unwrap();
700 let b = temp_env::with_var("LEVIATH_HOME", Some("/tmp/leviath-home-b"), control_address)
701 .unwrap();
702 assert_ne!(a, b);
704 #[cfg(unix)]
706 {
707 assert!(a.ends_with(".leviath/control.sock"));
708 assert!(a.starts_with("/tmp/leviath-home-a"));
709 }
710 }
711
712 #[tokio::test]
713 async fn setup_daemon_host_builds_a_working_host() {
714 let runs = tempfile::tempdir().unwrap();
719 let mut host = setup_daemon_host(
720 config_with_anthropic_key(),
721 runs.path().to_path_buf(),
722 Handle::current(),
723 )
724 .await
725 .expect("the daemon host builds in tests");
726
727 let dir = tempfile::tempdir().unwrap();
730 let manifest = dir.path().join("agent.leviath");
731 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
732 let (reply, rx) = oneshot::channel();
733 host.handle(ControlOp::Spawn {
734 args: Box::new(SpawnArgs {
735 run_id: "run-s".to_string(),
736 blueprint_path: manifest.to_string_lossy().to_string(),
737 task: "t".to_string(),
738 regions: Default::default(),
739 model: None,
740 workdir: std::env::temp_dir().to_string_lossy().to_string(),
741 metadata: Default::default(),
742 callback_url: None,
743 callback_secret: None,
744 yolo: false,
745 no_seed_commands: false,
746 allow: Vec::new(),
747 max_depth: None,
748 parent_run_id: None,
749 output: None,
750 }),
751 reply,
752 });
753 assert_eq!(rx.await.unwrap(), Ok("run-s".to_string()));
754 }
755
756 #[tokio::test]
762 async fn spawner_records_the_failure_in_the_run_dir_it_staked_out() {
763 let runs = tempfile::tempdir().unwrap();
764 let mut host = setup_daemon_host(
765 Config::default(),
766 runs.path().to_path_buf(),
767 Handle::current(),
768 )
769 .await
770 .expect("the daemon host builds in tests");
771 let (reply, rx) = oneshot::channel();
772 host.handle(ControlOp::Spawn {
773 args: Box::new(SpawnArgs {
774 run_id: "my-agent-1234-ab12".to_string(),
777 blueprint_path: "/no/such/agent.leviath".to_string(),
778 task: "t".to_string(),
779 workdir: std::env::temp_dir().to_string_lossy().to_string(),
780 ..Default::default()
781 }),
782 reply,
783 });
784 assert!(rx.await.unwrap().is_err());
785
786 let meta = crate::runstate::read_meta_from(&runs.path().join("my-agent-1234-ab12"))
787 .expect("a failed spawn still leaves meta.json behind");
788 assert_eq!(meta.status, leviath_core::run_meta::RunStatus::Error);
790 assert!(
791 meta.error
792 .is_some_and(|e| e.contains("/no/such/agent.leviath")),
793 "and it says what went wrong"
794 );
795 assert_eq!(meta.task, "t");
796 assert_eq!(meta.agent_name, "my-agent");
798 }
799
800 #[test]
801 fn placeholder_meta_falls_back_to_the_whole_run_id_as_the_agent_name() {
802 let runs = tempfile::tempdir().unwrap();
803 let args = SpawnArgs {
804 run_id: "odd".to_string(),
806 task: "t".to_string(),
807 ..Default::default()
808 };
809 write_placeholder_meta(runs.path(), &args);
810 let meta = crate::runstate::read_meta_from(&runs.path().join("odd")).unwrap();
811 assert_eq!(meta.agent_name, "odd");
812 }
813
814 #[test]
815 fn placeholder_meta_failure_is_logged_not_fatal() {
816 crate::test_support::with_tracing(|| {
819 let dir = tempfile::tempdir().unwrap();
820 let blocker = dir.path().join("not-a-dir");
821 std::fs::write(&blocker, "x").unwrap();
822 let args = SpawnArgs {
823 run_id: "blocked".to_string(),
824 ..Default::default()
825 };
826 write_placeholder_meta(&blocker.join("runs"), &args);
827 assert!(
828 crate::runstate::read_meta_from(&blocker.join("runs").join("blocked")).is_err()
829 );
830 });
831 }
832
833 #[tokio::test]
843 async fn spawner_writes_the_placeholder_under_the_hosts_runs_dir() {
844 let runs = tempfile::tempdir().unwrap();
845 crate::runstate::with_isolated_runs_dir_async(
853 "setup-host-isolation",
854 |global| async move {
855 let global_before = run_ids_in(&global);
856
857 let mut host = setup_daemon_host(
858 Config::default(),
859 runs.path().to_path_buf(),
860 Handle::current(),
861 )
862 .await
863 .expect("the daemon host builds in tests");
864 let (reply, rx) = oneshot::channel();
865 host.handle(ControlOp::Spawn {
866 args: Box::new(SpawnArgs {
867 run_id: "isolation-1234-ab12".to_string(),
871 blueprint_path: "/no/such/agent.leviath".to_string(),
872 task: "t".to_string(),
873 workdir: std::env::temp_dir().to_string_lossy().to_string(),
874 ..Default::default()
875 }),
876 reply,
877 });
878 assert!(rx.await.unwrap().is_err(), "the spawn itself fails");
879
880 assert!(
881 crate::runstate::read_meta_from(&runs.path().join("isolation-1234-ab12"))
882 .is_ok(),
883 "the placeholder lands in the host's configured runs dir"
884 );
885 assert_eq!(
886 run_ids_in(&global),
887 global_before,
888 "spawning through a host must not write into the home-resolved runs dir"
889 );
890 },
891 )
892 .await;
893 }
894
895 #[tokio::test]
901 async fn cancelling_an_unreloadable_run_terminates_it_on_disk() {
902 let runs = tempfile::tempdir().unwrap();
903 let mut host = setup_daemon_host(
904 Config::default(),
905 runs.path().to_path_buf(),
906 Handle::current(),
907 )
908 .await
909 .expect("the daemon host builds in tests");
910
911 let run_dir = runs.path().join("gone-1234-ab12");
915 let meta = leviath_core::run_meta::RunMeta::new(
916 "gone-1234-ab12".to_string(),
917 "gone".to_string(),
918 "/no/such/dir/agent.leviath".to_string(),
920 "t".to_string(),
921 None,
922 std::env::temp_dir().to_string_lossy().to_string(),
923 1,
924 );
925 crate::runstate::create_run_in(&run_dir, &meta).unwrap();
926 assert!(
927 !crate::runstate::is_terminal_status(
928 &crate::runstate::read_meta_from(&run_dir).unwrap().status
929 ),
930 "the run starts out looking live"
931 );
932
933 let (reply, rx) = oneshot::channel();
934 host.handle(ControlOp::Cancel {
935 run_id: "gone-1234-ab12".to_string(),
936 reply,
937 });
938 assert!(rx.await.unwrap(), "the cancel reports that it applied");
939 assert_eq!(
940 crate::runstate::read_meta_from(&run_dir).unwrap().status,
941 leviath_core::run_meta::RunStatus::Cancelled,
942 "and it reached disk, so nothing shows the run as live any more"
943 );
944
945 let (reply, rx) = oneshot::channel();
947 host.handle(ControlOp::Cancel {
948 run_id: "no-such-run".to_string(),
949 reply,
950 });
951 assert!(!rx.await.unwrap());
952 }
953
954 fn run_ids_in(dir: &std::path::Path) -> std::collections::BTreeSet<String> {
957 std::fs::read_dir(dir)
958 .into_iter()
959 .flatten()
960 .flatten()
961 .map(|e| e.file_name().to_string_lossy().into_owned())
962 .collect()
963 }
964
965 #[test]
966 fn run_ids_in_lists_entries_and_tolerates_a_missing_dir() {
967 let dir = tempfile::tempdir().unwrap();
968 std::fs::create_dir_all(dir.path().join("run-one")).unwrap();
969 std::fs::create_dir_all(dir.path().join("run-two")).unwrap();
970 assert_eq!(
971 run_ids_in(dir.path()),
972 ["run-one".to_string(), "run-two".to_string()]
973 .into_iter()
974 .collect()
975 );
976 assert!(run_ids_in(&dir.path().join("nope")).is_empty());
978 }
979
980 fn stub_server_py() -> (tempfile::TempDir, std::path::PathBuf) {
984 let dir = tempfile::tempdir().unwrap();
985 let path = dir.path().join("stub.py");
986 std::fs::write(
987 &path,
988 r#"
989import sys, json
990def respond(i, r):
991 sys.stdout.write(json.dumps({"jsonrpc":"2.0","id":i,"result":r})+"\n"); sys.stdout.flush()
992for line in sys.stdin:
993 line=line.strip()
994 if not line: continue
995 req=json.loads(line); m=req.get("method",""); i=req.get("id")
996 if m=="initialize": respond(i,{"capabilities":{"tools":{"listChanged":True}},"protocolVersion":"2024-11-05"})
997 elif m=="notifications/initialized": pass
998 elif m=="tools/list": respond(i,{"tools":[{"name":"stub_search","description":"s","inputSchema":{"type":"object","properties":{}}}]})
999 elif m=="tools/call": respond(i,{"content":[{"type":"text","text":"ok"}],"isError":False})
1000 else: respond(i,{})
1001"#,
1002 )
1003 .unwrap();
1004 (dir, path)
1005 }
1006
1007 fn blueprint_with_mcp(dir: &std::path::Path, stub_py: &std::path::Path) -> std::path::PathBuf {
1010 let manifest = dir.join("agent.leviath");
1011 std::fs::write(
1012 &manifest,
1013 format!(
1014 r#"
1015[agent]
1016name = "mcpagent"
1017entry_stage = "work"
1018
1019[[mcp_servers]]
1020name = "search"
1021command = "python3"
1022args = ['{}']
1023
1024[stages.work]
1025mode = "autonomous"
1026model = {{ provider = "fake", model = "m" }}
1027available_tools = ["stub_search"]
1028system_prompt = "use stub_search"
1029
1030[context.regions]
1031task = {{ kind = "pinned", max_tokens = 200, seed = {{ caller = "task" }} }}
1032"#,
1033 stub_py.to_string_lossy()
1034 ),
1035 )
1036 .unwrap();
1037 manifest
1038 }
1039
1040 fn empty_pool() -> crate::daemon::mcp_pool::McpPool {
1041 crate::daemon::mcp_pool::McpPool::new(
1042 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1043 Default::default(),
1044 )
1045 }
1046
1047 #[test]
1051 fn lease_reloaded_leases_only_on_a_successful_reload() {
1052 crate::runstate::with_isolated_runs_dir("lease-reloaded", |_d| {
1053 let pool = empty_pool();
1054 lease_reloaded(&pool, "any-run", false);
1055 lease_reloaded(&pool, "ghost-run", true);
1056 let meta = leviath_core::run_meta::RunMeta::new(
1057 "reloaded-run".to_string(),
1058 "agent".to_string(),
1059 "/no/such/agent.leviath".to_string(),
1060 "t".to_string(),
1061 None,
1062 "/w".to_string(),
1063 1,
1064 );
1065 crate::runstate::create_run(&meta).unwrap();
1066 lease_reloaded(&pool, "reloaded-run", true);
1069 });
1070 }
1071
1072 #[tokio::test]
1073 async fn warm_blueprint_mcp_connects_declared_servers() {
1074 let (_stub_dir, stub) = stub_server_py();
1075 let dir = tempfile::tempdir().unwrap();
1076 let manifest = blueprint_with_mcp(dir.path(), &stub);
1077 let pool = empty_pool();
1078 warm_blueprint_mcp(&pool, &manifest.to_string_lossy()).await;
1079 let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(
1081 &std::fs::read_to_string(&manifest).unwrap(),
1082 );
1083 let defs = pool.cached_defs_for(&servers);
1084 assert_eq!(defs.len(), 1);
1085 assert_eq!(defs[0].name, "stub_search");
1086 }
1087
1088 #[tokio::test]
1089 async fn warm_blueprint_mcp_missing_manifest_is_noop() {
1090 let pool = empty_pool();
1091 warm_blueprint_mcp(&pool, "/no/such/agent.leviath").await;
1093 }
1094
1095 fn parent_with_fanout_worker_agent(
1098 dir: &std::path::Path,
1099 worker_source: &str,
1100 ) -> std::path::PathBuf {
1101 let manifest = dir.join("parent.leviath");
1102 std::fs::write(
1103 &manifest,
1104 format!(
1105 "[agent]\nname = \"parent\"\n\n\
1106 [stages.main]\nmode = \"autonomous\"\n\n\
1107 [stages.parallel]\nmode = \"fan_out\"\nworker_agent = '{worker_source}'\nsplit_prompt = \"go\"\n"
1108 ),
1109 )
1110 .unwrap();
1111 manifest
1112 }
1113
1114 #[tokio::test]
1115 async fn warm_fanout_worker_mcp_prewarms_worker_agent_servers() {
1116 let (_stub_dir, stub) = stub_server_py();
1117 let worker_dir = tempfile::tempdir().unwrap();
1119 blueprint_with_mcp(worker_dir.path(), &stub);
1120 let parent_dir = tempfile::tempdir().unwrap();
1122 let parent = parent_with_fanout_worker_agent(
1123 parent_dir.path(),
1124 &worker_dir.path().to_string_lossy(),
1125 );
1126 let pool = empty_pool();
1127 warm_fanout_worker_mcp(&pool, &parent.to_string_lossy(), None).await;
1128 let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(
1131 &std::fs::read_to_string(worker_dir.path().join("agent.leviath")).unwrap(),
1132 );
1133 let defs = pool.cached_defs_for(&servers);
1134 assert_eq!(defs.len(), 1);
1135 assert_eq!(defs[0].name, "stub_search");
1136 }
1137
1138 #[tokio::test]
1139 async fn warm_fanout_worker_mcp_skips_and_tolerates_every_arm() {
1140 let pool = empty_pool();
1141 warm_fanout_worker_mcp(&pool, "/no/such/parent.leviath", None).await;
1143 let dir = tempfile::tempdir().unwrap();
1145 let bad = dir.path().join("bad.leviath");
1146 std::fs::write(&bad, "not : valid : toml").unwrap();
1147 warm_fanout_worker_mcp(&pool, &bad.to_string_lossy(), None).await;
1148 let plain = dir.path().join("plain.leviath");
1150 std::fs::write(
1151 &plain,
1152 "[agent]\nname = \"p\"\n\n[stages.main]\nmode = \"autonomous\"\n",
1153 )
1154 .unwrap();
1155 warm_fanout_worker_mcp(&pool, &plain.to_string_lossy(), None).await;
1156 let ws = dir.path().join("ws.leviath");
1158 std::fs::write(
1159 &ws,
1160 "[agent]\nname = \"p\"\n\n\
1161 [stages.parallel]\nmode = \"fan_out\"\nworker_stage = \"w\"\nsplit_prompt = \"go\"\n\n\
1162 [stages.w]\nmode = \"autonomous\"\nallow_as_worker = true\n",
1163 )
1164 .unwrap();
1165 warm_fanout_worker_mcp(&pool, &ws.to_string_lossy(), None).await;
1166 let wq = dir.path().join("wq.leviath");
1168 std::fs::write(
1169 &wq,
1170 "[agent]\nname = \"p\"\n\n\
1171 [stages.parallel]\nmode = \"fan_out\"\nworker_query = \"x\"\nsplit_prompt = \"go\"\n",
1172 )
1173 .unwrap();
1174 warm_fanout_worker_mcp(&pool, &wq.to_string_lossy(), None).await;
1175 let miss = parent_with_fanout_worker_agent(dir.path(), "/no/such/worker/xyz");
1177 warm_fanout_worker_mcp(&pool, &miss.to_string_lossy(), None).await;
1178 let worker_dir = tempfile::tempdir().unwrap();
1181 std::fs::write(
1182 worker_dir.path().join("agent.leviath"),
1183 "[agent]\nname = \"w\"\n\n[stages.main]\nmode = \"autonomous\"\n",
1184 )
1185 .unwrap();
1186 let noservers =
1187 parent_with_fanout_worker_agent(dir.path(), &worker_dir.path().to_string_lossy());
1188 warm_fanout_worker_mcp(&pool, &noservers.to_string_lossy(), None).await;
1189 let dir_manifest = tempfile::tempdir().unwrap();
1193 std::fs::create_dir(dir_manifest.path().join("agent.leviath")).unwrap();
1194 let unreadable =
1195 parent_with_fanout_worker_agent(dir.path(), &dir_manifest.path().to_string_lossy());
1196 warm_fanout_worker_mcp(&pool, &unreadable.to_string_lossy(), None).await;
1197 }
1198
1199 #[test]
1200 fn per_agent_mcp_defs_appends_declared_and_falls_back_to_global() {
1201 let (_stub_dir, stub) = stub_server_py();
1202 let dir = tempfile::tempdir().unwrap();
1203 let manifest = blueprint_with_mcp(dir.path(), &stub);
1204 let pool = empty_pool();
1205 let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(
1208 &std::fs::read_to_string(&manifest).unwrap(),
1209 );
1210 pool.seed(
1211 &servers[0],
1212 vec![Tool {
1213 name: "stub_search".into(),
1214 description: String::new(),
1215 parameters: serde_json::json!({}),
1216 }],
1217 );
1218 let global = vec![Tool {
1219 name: "global_tool".into(),
1220 description: String::new(),
1221 parameters: serde_json::json!({}),
1222 }];
1223 let defs = per_agent_mcp_defs(&pool, &global, &manifest.to_string_lossy());
1224 let names: Vec<&str> = defs.iter().map(|t| t.name.as_str()).collect();
1225 assert_eq!(names, vec!["global_tool", "stub_search"]);
1226 let only_global = per_agent_mcp_defs(&pool, &global, "/no/such/x");
1228 assert_eq!(only_global.len(), 1);
1229 assert_eq!(only_global[0].name, "global_tool");
1230 }
1231
1232 #[tokio::test]
1233 async fn build_host_seeds_global_mcp_servers() {
1234 let config = Config {
1236 mcp_servers: vec![leviath_mcp::MCPServerConfig::stdio(
1237 "global-srv",
1238 "python3",
1239 vec!["-c".to_string(), "pass".to_string()],
1240 )],
1241 ..Config::default()
1242 };
1243 let runs = tempfile::tempdir().unwrap();
1244 let _host = build_host(HostParts {
1245 config,
1246 providers: ProviderRegistry::new(),
1247 runs_dir: runs.path().to_path_buf(),
1248 shared_mcp: Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1249 mcp_tool_defs: Vec::new(),
1250 mcp_pool: crate::daemon::mcp_pool::McpPool::for_daemon(
1251 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1252 &[],
1253 ),
1254 runtime: Handle::current(),
1255 now_secs: || 0,
1256 });
1257 }
1258
1259 #[tokio::test]
1260 async fn build_host_installs_the_configured_telemetry_sink() {
1261 let config = Config {
1263 observability: leviath_core::config::ObservabilityConfig {
1264 enabled: true,
1265 exporter: leviath_core::config::TelemetryExporterKind::Stdout,
1266 endpoint: None,
1267 service_name: None,
1268 },
1269 ..Config::default()
1270 };
1271 let runs = tempfile::tempdir().unwrap();
1272 let mut host = build_host(HostParts {
1273 config,
1274 providers: ProviderRegistry::new(),
1275 runs_dir: runs.path().to_path_buf(),
1276 shared_mcp: Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1277 mcp_tool_defs: Vec::new(),
1278 mcp_pool: crate::daemon::mcp_pool::McpPool::for_daemon(
1279 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1280 &[],
1281 ),
1282 runtime: Handle::current(),
1283 now_secs: || 0,
1284 });
1285 assert!(
1286 host.world_mut()
1287 .world_mut()
1288 .get_resource::<leviath_runtime::telemetry::Telemetry>()
1289 .is_some()
1290 );
1291 }
1292
1293 #[tokio::test(flavor = "multi_thread")]
1294 async fn build_host_with_otlp_also_installs_the_log_layer() {
1295 let config = Config {
1301 observability: leviath_core::config::ObservabilityConfig {
1302 enabled: true,
1303 exporter: leviath_core::config::TelemetryExporterKind::Otlp,
1304 endpoint: Some("http://127.0.0.1:9".to_string()),
1305 service_name: Some("leviath-test".to_string()),
1306 },
1307 ..Config::default()
1308 };
1309 let runs = tempfile::tempdir().unwrap();
1310 let mut host = build_host(HostParts {
1311 config,
1312 providers: ProviderRegistry::new(),
1313 runs_dir: runs.path().to_path_buf(),
1314 shared_mcp: Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1315 mcp_tool_defs: Vec::new(),
1316 mcp_pool: crate::daemon::mcp_pool::McpPool::for_daemon(
1317 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1318 &[],
1319 ),
1320 runtime: Handle::current(),
1321 now_secs: || 0,
1322 });
1323 assert!(
1324 host.world_mut()
1325 .world_mut()
1326 .get_resource::<leviath_runtime::telemetry::Telemetry>()
1327 .is_some()
1328 );
1329 }
1330
1331 #[tokio::test]
1332 async fn serve_runs_spawn_preprocessor_for_per_agent_mcp() {
1333 let (_stub_dir, stub) = stub_server_py();
1337 let agent_dir = tempfile::tempdir().unwrap();
1338 let manifest = blueprint_with_mcp(agent_dir.path(), &stub);
1339 let mut providers = ProviderRegistry::new();
1341 providers.register("fake".to_string(), Arc::new(FakeProvider));
1342 let runs = tempfile::tempdir().unwrap();
1343 let mut host = build_host(HostParts {
1344 config: Config::default(),
1345 providers,
1346 runs_dir: runs.path().to_path_buf(),
1347 shared_mcp: Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1348 mcp_tool_defs: Vec::new(),
1349 mcp_pool: crate::daemon::mcp_pool::McpPool::for_daemon(
1350 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1351 &[],
1352 ),
1353 runtime: Handle::current(),
1354 now_secs: || 0,
1355 });
1356 let (ctl_tx, ctl_rx) = tokio::sync::mpsc::unbounded_channel();
1357 let (reply, reply_rx) = oneshot::channel();
1358 ctl_tx
1359 .send(ControlOp::Spawn {
1360 args: Box::new(SpawnArgs {
1361 run_id: "run-mcp".to_string(),
1362 blueprint_path: manifest.to_string_lossy().to_string(),
1363 task: "t".to_string(),
1364 regions: Default::default(),
1365 model: None,
1366 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1367 metadata: Default::default(),
1368 callback_url: None,
1369 callback_secret: None,
1370 yolo: false,
1371 no_seed_commands: false,
1372 allow: Vec::new(),
1373 max_depth: None,
1374 parent_run_id: None,
1375 output: None,
1376 }),
1377 reply,
1378 })
1379 .unwrap();
1380 drop(ctl_tx);
1382 host.serve(ctl_rx).await;
1383 assert_eq!(reply_rx.await.unwrap(), Ok("run-mcp".to_string()));
1384 }
1385
1386 #[tokio::test]
1387 async fn fake_provider_methods_are_exercised() {
1388 use leviath_providers::Provider;
1389 let p = FakeProvider;
1390 assert_eq!(p.name(), "fake");
1391 assert_eq!(p.count_tokens("t", "m").await, 1);
1392 assert_eq!(p.max_context_tokens("m"), 1000);
1393 let _ = p.capabilities("m");
1394 assert!(
1395 p.infer(&leviath_providers::InferenceRequest {
1396 system: vec![],
1397 messages: vec![],
1398 model: "m".to_string(),
1399 max_tokens: 1,
1400 temperature: 0.0,
1401 tools: vec![],
1402 extra: serde_json::Value::Null,
1403 request_timeout_secs: None,
1404 })
1405 .await
1406 .is_err()
1407 );
1408 }
1409
1410 #[tokio::test]
1411 async fn build_host_spawns_agents_through_the_installed_spawner() {
1412 let dir = tempfile::tempdir().unwrap();
1413 let manifest = dir.path().join("agent.leviath");
1414 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
1415
1416 let mut registry = ProviderRegistry::new();
1417 registry.register("anthropic".to_string(), Arc::new(FakeProvider));
1418 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1419
1420 let runs = tempfile::tempdir().unwrap();
1421 let mut host = build_host(HostParts {
1422 config: Config::default(),
1423 providers: registry,
1424 runs_dir: runs.path().to_path_buf(),
1425 shared_mcp: mcp,
1426 mcp_tool_defs: vec![],
1427 mcp_pool: crate::daemon::mcp_pool::McpPool::for_daemon(
1428 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1429 &[],
1430 ),
1431 runtime: Handle::current(),
1432 now_secs: || 100,
1433 });
1434
1435 let (reply, rx) = oneshot::channel();
1437 host.handle(ControlOp::Spawn {
1438 args: Box::new(SpawnArgs {
1439 run_id: "run-1".to_string(),
1440 blueprint_path: manifest.to_string_lossy().to_string(),
1441 task: "do it".to_string(),
1442 regions: Default::default(),
1443 model: None,
1444 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1445 metadata: Default::default(),
1446 callback_url: None,
1447 callback_secret: None,
1448 yolo: false,
1449 no_seed_commands: false,
1450 allow: Vec::new(),
1451 max_depth: None,
1452 parent_run_id: None,
1453 output: None,
1454 }),
1455 reply,
1456 });
1457 assert_eq!(rx.await.unwrap(), Ok("run-1".to_string()));
1458
1459 let (reply, rx) = oneshot::channel();
1461 host.handle(ControlOp::Status {
1462 run_id: "run-1".to_string(),
1463 reply,
1464 });
1465 assert_eq!(rx.await.unwrap(), Some(AgentStatus::Active));
1466 }
1467
1468 #[tokio::test]
1469 async fn build_host_reloads_and_registers_persisted_runs() {
1470 let agent = tempfile::tempdir().unwrap();
1473 let manifest = agent.path().join("agent.leviath");
1474 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
1475
1476 let runs = tempfile::tempdir().unwrap();
1477 let run_dir = runs.path().join("resumed");
1478 std::fs::create_dir_all(&run_dir).unwrap();
1479 let meta = leviath_core::run_meta::RunMeta {
1480 run_id: "resumed".to_string(),
1481 agent_name: "coder".to_string(),
1482 agent_path: manifest.to_string_lossy().to_string(),
1483 task: "resume".to_string(),
1484 model: None,
1485 pid: 0,
1486 status: leviath_core::run_meta::RunStatus::Running,
1487 current_stage: "implement".to_string(),
1488 stage_index: 0,
1489 num_stages: 1,
1490 iteration: 2,
1491 prompt_tokens: 0,
1492 completion_tokens: 0,
1493 cached_tokens: 0,
1494 cache_write_tokens: 0,
1495 tool_calls: 0,
1496 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1497 started_at: 1,
1498 updated_at: 1,
1499 last_progress_at: None,
1500 error: None,
1501 title: None,
1502 metadata: Default::default(),
1503 callback_url: None,
1504 callback_secret: None,
1505 parent_run_id: None,
1506 children: Vec::new(),
1507 depth: 0,
1508 max_child_depth: 0,
1509 flags: Default::default(),
1510 yolo: false,
1511 read_paths: None,
1512 final_output: None,
1513 output_request: None,
1514 };
1515 std::fs::write(
1516 run_dir.join("meta.json"),
1517 serde_json::to_string(&meta).unwrap(),
1518 )
1519 .unwrap();
1520
1521 let mut registry = ProviderRegistry::new();
1522 registry.register("anthropic".to_string(), Arc::new(FakeProvider));
1523 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1524 let mut host = build_host(HostParts {
1525 config: Config::default(),
1526 providers: registry,
1527 runs_dir: runs.path().to_path_buf(),
1528 shared_mcp: mcp,
1529 mcp_tool_defs: vec![],
1530 mcp_pool: crate::daemon::mcp_pool::McpPool::for_daemon(
1531 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1532 &[],
1533 ),
1534 runtime: Handle::current(),
1535 now_secs: || 100,
1536 });
1537
1538 let (reply, rx) = oneshot::channel();
1540 host.handle(ControlOp::Status {
1541 run_id: "resumed".to_string(),
1542 reply,
1543 });
1544 assert_eq!(rx.await.unwrap(), Some(AgentStatus::Active));
1545 }
1546
1547 #[tokio::test]
1548 async fn build_host_installs_a_reloader_that_pages_in_unloaded_runs() {
1549 let agent = tempfile::tempdir().unwrap();
1553 let manifest = agent.path().join("agent.leviath");
1554 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
1555
1556 let runs = tempfile::tempdir().unwrap();
1557 let mut registry = ProviderRegistry::new();
1558 registry.register("anthropic".to_string(), Arc::new(FakeProvider));
1559 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1560 let mut host = build_host(HostParts {
1561 config: Config::default(),
1562 providers: registry,
1563 runs_dir: runs.path().to_path_buf(),
1564 shared_mcp: mcp,
1565 mcp_tool_defs: vec![],
1566 mcp_pool: crate::daemon::mcp_pool::McpPool::for_daemon(
1567 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1568 &[],
1569 ),
1570 runtime: Handle::current(),
1571 now_secs: || 100,
1572 });
1573
1574 let run_dir = runs.path().join("late");
1577 std::fs::create_dir_all(&run_dir).unwrap();
1578 let meta = leviath_core::run_meta::RunMeta {
1579 run_id: "late".to_string(),
1580 agent_name: "coder".to_string(),
1581 agent_path: manifest.to_string_lossy().to_string(),
1582 task: "page me in".to_string(),
1583 model: None,
1584 pid: 0,
1585 status: leviath_core::run_meta::RunStatus::Running,
1586 current_stage: "implement".to_string(),
1587 stage_index: 0,
1588 num_stages: 1,
1589 iteration: 1,
1590 prompt_tokens: 0,
1591 completion_tokens: 0,
1592 cached_tokens: 0,
1593 cache_write_tokens: 0,
1594 tool_calls: 0,
1595 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1596 started_at: 1,
1597 updated_at: 1,
1598 last_progress_at: None,
1599 error: None,
1600 title: None,
1601 metadata: Default::default(),
1602 callback_url: None,
1603 callback_secret: None,
1604 parent_run_id: None,
1605 children: Vec::new(),
1606 depth: 0,
1607 max_child_depth: 0,
1608 flags: Default::default(),
1609 yolo: false,
1610 read_paths: None,
1611 final_output: None,
1612 output_request: None,
1613 };
1614 std::fs::write(
1615 run_dir.join("meta.json"),
1616 serde_json::to_string(&meta).unwrap(),
1617 )
1618 .unwrap();
1619
1620 let (reply, rx) = oneshot::channel();
1622 host.handle(ControlOp::Status {
1623 run_id: "late".to_string(),
1624 reply,
1625 });
1626 assert_eq!(rx.await.unwrap(), None);
1627
1628 let (reply, rx) = oneshot::channel();
1630 host.handle(ControlOp::Cancel {
1631 run_id: "late".to_string(),
1632 reply,
1633 });
1634 assert!(rx.await.unwrap());
1635 }
1636
1637 #[test]
1638 fn daemon_build_is_stale_compares_against_current_build() {
1639 assert!(daemon_build_is_stale(None), "missing marker is stale");
1640 assert!(
1641 daemon_build_is_stale(Some("some-other-build")),
1642 "a different build is stale"
1643 );
1644 assert!(
1645 !daemon_build_is_stale(Some(CURRENT_BUILD)),
1646 "the current build is not stale"
1647 );
1648 }
1649
1650 #[test]
1651 fn build_marker_round_trips_and_is_current() {
1652 let dir = tempfile::tempdir().unwrap();
1653 temp_env::with_var("LEVIATH_HOME", Some(dir.path()), || {
1654 assert!(read_build_marker().is_none());
1656 assert!(daemon_build_is_stale(read_build_marker().as_deref()));
1657
1658 write_build_marker();
1659 let path = build_marker_path().unwrap();
1660 assert!(path.exists());
1661 assert_eq!(read_build_marker().as_deref(), Some(CURRENT_BUILD));
1662 assert!(!daemon_build_is_stale(read_build_marker().as_deref()));
1664 });
1665 }
1666
1667 #[tokio::test]
1668 async fn the_daemon_refuses_to_start_without_a_usable_https_client() {
1669 let dir = tempfile::tempdir().expect("tempdir");
1672 let mut config = Config::default();
1673 config.providers.anthropic_api_key = Some("k".to_string());
1674 let err =
1675 setup_daemon_host_with(config, dir.path().to_path_buf(), Handle::current(), &|_t| {
1676 Err(leviath_providers::provider::malformed_url_error())
1677 })
1678 .await
1679 .err()
1680 .expect("a failing client factory should stop the daemon starting");
1681 assert!(err.to_string().contains("root certificate store"));
1682 }
1683}