Skip to main content

leviath_cli/daemon/
setup.rs

1//! Daemon assembly: build a fully-wired [`WorldHost`] (world + tool service +
2//! interaction hub + the blueprint spawner) ready to be driven by
3//! [`WorldHost::serve`]. The async setup (provider registry, MCP connections)
4//! happens in the binary and is passed in; this wiring is synchronous and
5//! testable - spawning an agent through the installed spawner exercises the whole
6//! path.
7
8use 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::commands::run::session::build_provider_registry_from_config;
22use crate::config::Config;
23use crate::daemon::fanout_spawner::DaemonFanOutSpawner;
24use crate::daemon::spawn::build_agent;
25use crate::daemon::tool_service::CliToolService;
26use crate::tools::ToolRegistry;
27
28/// The daemon's control-channel id, derived from `<leviath-home>/.leviath`
29/// (honoring `LEVIATH_HOME`): a Unix-socket path on Unix, a named-pipe name on
30/// Windows. `None` if no home directory can be resolved.
31pub fn control_address() -> Option<leviath_runtime::control_socket::ControlId> {
32    control_dir().map(|dir| leviath_runtime::control_socket::control_id(&dir))
33}
34
35/// The directory holding the control channel and its token.
36///
37/// Separate from [`control_address`] because on Windows a control id is a pipe
38/// name rather than a path, so the token's location cannot be derived from it.
39pub fn control_dir() -> Option<std::path::PathBuf> {
40    leviath_core::paths::data_dir()
41}
42
43/// This CLI binary's build id (short git hash, `-dirty` when the tree had
44/// uncommitted changes), embedded at compile time by `build.rs`. A long-lived
45/// daemon records the build it started from; a mismatch means the installed
46/// binary is newer and the daemon is running stale code.
47pub const CURRENT_BUILD: &str = env!("LEVIATH_BUILD");
48
49/// Path to the file where a running daemon records its build id
50/// (`<leviath-home>/.leviath/daemon.build`).
51pub fn build_marker_path() -> Option<std::path::PathBuf> {
52    leviath_core::paths::data_dir().map(|d| d.join("daemon.build"))
53}
54
55/// Record [`CURRENT_BUILD`] so the CLI can detect a stale daemon later.
56/// Best-effort - a missing marker just triggers a restart on the next command.
57pub fn write_build_marker() {
58    // Combinators (rather than `if let`) so the "no home dir" / "no parent"
59    // fallbacks don't add branches that can't be exercised where a home always
60    // resolves - mirroring `control_address`'s `.map` style.
61    build_marker_path().into_iter().for_each(|path| {
62        let _ = path.parent().map(std::fs::create_dir_all);
63        let _ = std::fs::write(&path, CURRENT_BUILD);
64    });
65}
66
67/// The build id a running daemon recorded, if the marker exists and is readable.
68pub fn read_build_marker() -> Option<String> {
69    build_marker_path()
70        .and_then(|path| std::fs::read_to_string(path).ok())
71        .map(|s| s.trim().to_string())
72}
73
74/// Whether a running daemon should be restarted because it is on a different
75/// build than this CLI (or recorded no build at all - e.g. it predates this
76/// check).
77pub fn daemon_build_is_stale(recorded: Option<&str>) -> bool {
78    recorded != Some(CURRENT_BUILD)
79}
80
81/// Build the daemon's [`WorldHost`], doing the async startup work: build the
82/// provider registry from config and connect the shared MCP servers (both reused
83/// by every agent), then wire the host + spawner via [`build_host`].
84pub async fn setup_daemon_host(
85    config: Config,
86    runs_dir: std::path::PathBuf,
87    runtime: Handle,
88) -> WorldHost {
89    // Apply the machine-wide outbound-network policy before anything can fetch.
90    // It lives in a process-wide atomic because the shared blocking HTTP client's
91    // redirect policy has no per-agent context to consult; see
92    // `script_host::set_local_network_allowed`.
93    crate::daemon::script_host::set_local_network_allowed(config.security.allow_local_network);
94    let providers = build_provider_registry_from_config(&config);
95    // MCP connections are shared across agents; the workdir here only seeds the
96    // (discarded) built-ins - each agent gets its own over its own workdir.
97    let registry = ToolRegistry::build(std::env::temp_dir(), &config).await;
98    // The shared MCP pool: seed the connected global servers, then reconnect the
99    // per-agent MCP servers of any non-terminal persisted run so a run reloaded on
100    // restart can still execute its blueprint MCP tools (recovery warming - the
101    // async counterpart of the live-spawn preprocessor, done here before the
102    // sync reload inside build_host).
103    let mcp_pool = crate::daemon::mcp_pool::McpPool::for_daemon_with(
104        registry.mcp.clone(),
105        &config.mcp_servers,
106        config.security.credential_store,
107        config.security.allow_env_vars.clone(),
108    );
109    mcp_pool.warm_recovered(&runs_dir).await;
110    build_host(
111        config,
112        providers,
113        runs_dir,
114        registry.mcp,
115        registry.mcp_tool_defs,
116        mcp_pool,
117        runtime,
118        || chrono::Utc::now().timestamp(),
119    )
120}
121
122/// The reap hook installed on the host: drops a reaped agent's tool state and
123/// tears down its sandbox via [`CliToolService::reap`]. Factored out (rather than
124/// an inline closure) so its body is exercised by a unit test - the daemon itself
125/// only ever fires the reaper from the private `serve()` loop.
126fn make_reaper(tool_service: Arc<CliToolService>) -> leviath_runtime::host::Reaper {
127    Box::new(move |_world, entity| tool_service.reap(entity))
128}
129
130/// Build the daemon's [`WorldHost`]: one world hosting every agent, its tool
131/// service + interaction hub, and a `Spawn`-op spawner that loads blueprints and
132/// registers per-agent tool state. `shared_mcp` / `mcp_tool_defs` are the MCP
133/// connections built once at startup and reused by every agent.
134#[allow(clippy::too_many_arguments)]
135pub fn build_host(
136    config: Config,
137    providers: ProviderRegistry,
138    runs_dir: std::path::PathBuf,
139    shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
140    mcp_tool_defs: Vec<Tool>,
141    mcp_pool: Arc<crate::daemon::mcp_pool::McpPool>,
142    runtime: Handle,
143    now_secs: fn() -> i64,
144) -> WorldHost {
145    let hub = InteractionHub::new();
146    // How long a prompt may go unanswered before the hub resolves it itself, so
147    // an operator who walked away costs the run a delay rather than its slot
148    // for as long as the daemon lives (issue #204).
149    hub.set_timeout_secs(config.limits.interaction_timeout_secs);
150    let tool_service = Arc::new(CliToolService::new());
151    // The configured global fallback bounds concurrent inference for any model
152    // without its own per-model pool entry (defaults to a small cap so a fresh
153    // install can't fan out unbounded requests against provider rate limits).
154    let pool_config =
155        InferencePoolConfig::new().with_default(config.limits.max_concurrent_inferences);
156    let mut world = PipelineWorld::new(
157        providers,
158        tool_service.clone(),
159        pool_config,
160        config.limits.max_concurrent_tools,
161        Some(runs_dir.clone()),
162        runtime,
163    );
164    // Opt-in accurate pre-inference budget guard (off by default).
165    world.set_exact_token_counting(config.limits.exact_token_counting);
166    // How long a run may sit unable to dispatch before the watchdog fails it
167    // rather than leaving it "running" for ever (issue #190).
168    world
169        .world_mut()
170        .insert_resource(leviath_runtime::pipeline::StallTimeout(
171            config.limits.stall_timeout_secs,
172        ));
173    // How long a run may sit in a state nothing can reach at all before the
174    // watchdog fails it and releases what it was holding (issue #202). Off
175    // unless the operator sets it.
176    world
177        .world_mut()
178        .insert_resource(leviath_runtime::pipeline::WedgeTimeout(
179            config.limits.wedge_timeout_secs,
180        ));
181    // Take a provider out of service after it has failed this many times in a
182    // row for a reason only a person can fix, so the next run does not have to
183    // rediscover it (issue #201).
184    world
185        .world_mut()
186        .insert_resource(leviath_runtime::pipeline::CircuitPolicy {
187            failures_before_open: config.limits.provider_failures_before_open,
188            cooldown_secs: config.limits.provider_circuit_cooldown_secs,
189        });
190    world
191        .world_mut()
192        .init_resource::<leviath_runtime::pipeline::ProviderCircuits>();
193    // Share the hub with the tick loop so a blocked agent's open prompt is
194    // reflected into its status (Active ↔ Waiting) for the dashboard to surface.
195    world.insert_interaction_hub(hub.clone());
196    let mut host = WorldHost::with_interactions(world, hub.clone());
197    // How long the daemon may sit with a full tool lane and no run moving before
198    // it widens the lane to break the jam (issue #191).
199    host.set_dead_cycles_before_relief(config.limits.dead_cycles_before_relief);
200    // How long a finished run keeps its place in the listing, so a scheduler
201    // polling on an interval can see how a run ended (issue #205).
202    host.set_finished_retention_secs(config.limits.finished_retention_secs);
203    // Handed to each agent's tool state so its sub-agent tools reach the world
204    // through the host.
205    let subagent_tx = host.subagent_sender();
206
207    // Restart recovery: reload persisted non-terminal agents so interrupted runs
208    // (including mid-inference ones) resume. Done before the spawner moves the
209    // shared resources.
210    let reloaded = crate::daemon::recovery::reload_persisted_agents(
211        host.world_mut(),
212        tool_service.as_ref(),
213        &config,
214        shared_mcp.clone(),
215        &mcp_tool_defs,
216        &hub,
217        &runs_dir,
218        now_secs(),
219        &subagent_tx,
220    );
221    for (run_id, entity) in reloaded {
222        host.register(run_id, entity);
223    }
224
225    // Config hot-reload: after boot, spawn-time config (permissions,
226    // `[read_paths]`, sandbox, limits, taint) is served from here, reloaded
227    // when `config.toml` changes on disk. The boot infrastructure (provider
228    // registry, MCP pool, network policy, telemetry) keeps the boot snapshot -
229    // those hold live connections and need a restart - so the reloader takes a
230    // clone and the boot snapshot stays usable below.
231    let reloader = std::sync::Arc::new(crate::daemon::config_reload::ConfigReloader::new(
232        Config::config_path(),
233        config.clone(),
234    ));
235
236    // Install the fan-out spawner as a world resource so the runtime's fan-out
237    // systems can start workers (it captures the same context as the spawner
238    // below, cloned before those move into the closure).
239    let fanout_spawner = DaemonFanOutSpawner {
240        config: reloader.clone(),
241        shared_mcp: shared_mcp.clone(),
242        mcp_tool_defs: mcp_tool_defs.clone(),
243        mcp_pool: mcp_pool.clone(),
244        hub: hub.clone(),
245        subagent_tx: subagent_tx.clone(),
246        tool_service: tool_service.clone(),
247        agents_dir: leviath_core::paths::agents_dir(),
248        now_secs,
249    };
250    host.world_mut()
251        .world_mut()
252        .insert_resource(FanOutSpawnerRes(Arc::new(fanout_spawner)));
253
254    // The tool allowlist policy (`policy.toml`), for the taint gate. A malformed
255    // file falls back to an empty policy (deny-by-clearance only) rather than
256    // failing daemon startup.
257    let policy = crate::commands::policy::load_policy().unwrap_or_default();
258    host.world_mut()
259        .world_mut()
260        .insert_resource(leviath_runtime::pipeline::PolicyGate(policy));
261
262    // Run-title generation settings; spawn only marks a run for titling when
263    // `[title]` is enabled, and the dispatch system reads provider/model here.
264    host.world_mut()
265        .world_mut()
266        .insert_resource(leviath_runtime::title::TitleSettings(config.title.clone()));
267
268    // Scripted gate rules (`<config>/leviath/rules/*.rhai`), consulted by the gate
269    // after the static allowlist (a no-op checker when there are none).
270    let script_checker =
271        crate::daemon::gate_rules::build_gate_script_checker(&crate::commands::policy::rules_dir());
272    host.world_mut()
273        .world_mut()
274        .insert_resource(leviath_runtime::pipeline::GateScriptRules(script_checker));
275
276    // Structured observability (`[observability]`): replace the world's no-op
277    // telemetry sink with the configured exporter, and - for OTLP - forward
278    // the daemon's own tracing events through the same pipeline. A pipeline
279    // that fails to build logs a warning and leaves the no-op in place -
280    // observability must never stop the work it observes.
281    if let Some(built) = leviath_telemetry::build_sink(&config.observability) {
282        host.world_mut()
283            .world_mut()
284            .insert_resource(leviath_runtime::telemetry::Telemetry(built.sink));
285        if let Some(layer) = built.log_layer {
286            crate::logging::install_otel_layer(layer);
287        }
288    }
289
290    // Reload-on-demand: an op targeting an unloaded run pages it back in from
291    // disk. Capture the shared context (cloned before the spawner moves the
292    // originals below).
293    let reload_tools = tool_service.clone();
294    let reload_reloader = reloader.clone();
295    let reload_mcp = shared_mcp.clone();
296    let reload_defs = mcp_tool_defs.clone();
297    let reload_hub = hub.clone();
298    let reload_tx = subagent_tx.clone();
299    let reload_runs = runs_dir.clone();
300    host.set_reloader(Box::new(move |world, run_id| {
301        // Pages a run back in with the current on-disk config, matching what a
302        // real restart would restore it with.
303        let reload_config = reload_reloader.current();
304        crate::daemon::recovery::reload_run(
305            world,
306            reload_tools.as_ref(),
307            &reload_config,
308            reload_mcp.clone(),
309            &reload_defs,
310            &reload_hub,
311            run_id,
312            &reload_runs,
313            now_secs(),
314            &reload_tx,
315        )
316    }));
317
318    // Last resort for a cancel the world can't service: force the run's on-disk
319    // state to `Cancelled`. The reloader above declines whenever a run can't be
320    // rebuilt - deleted blueprint, unreadable metadata, died mid-spawn - and
321    // without this a cancel in that state wrote nothing at all, so `meta.json`
322    // went on claiming the run was live and nothing could ever clear it.
323    let terminate_runs = runs_dir.clone();
324    host.set_force_terminator(Box::new(move |run_id| {
325        crate::runstate::force_cancel_in(&terminate_runs.join(run_id), now_secs()).found_run()
326    }));
327
328    // Reap hook: when a terminal agent is reaped, tear down its sandbox and drop
329    // its per-agent tool state (the latter also fixing a prior leak where tool
330    // state was never released). Factored into `make_reaper` so the closure body
331    // is unit-testable - the daemon only ever drives it from `serve()`.
332    host.set_reaper(make_reaper(tool_service.clone()));
333
334    // The shared MCP pool (created + recovery-warmed by the caller). Per-agent
335    // `[[mcp_servers]]` connect lazily through it.
336
337    // Preprocessor: before the sync spawner runs, connect the blueprint's declared
338    // MCP servers into the shared pool (lazy, deduped) so they're warm to advertise -
339    // and pre-warm the servers declared by any `worker_agent`/`worker_query`
340    // fan-out worker this blueprint will spawn, so the *first* such worker already
341    // advertises them (they'd otherwise land one turn late - issue #97).
342    let pp_pool = mcp_pool.clone();
343    let pp_agents_dir = leviath_core::paths::agents_dir();
344    host.set_spawn_preprocessor(Box::new(move |args| {
345        let pool = pp_pool.clone();
346        let blueprint_path = args.blueprint_path.clone();
347        let agents_dir = pp_agents_dir.clone();
348        Box::pin(async move {
349            warm_blueprint_mcp(&pool, &blueprint_path).await;
350            warm_fanout_worker_mcp(&pool, &blueprint_path, agents_dir.as_deref()).await;
351        })
352    }));
353
354    // The spawner captures everything an agent needs; `now_secs` is called at
355    // spawn time for the run's start timestamp. Per-agent MCP defs = the global
356    // servers' defs plus this blueprint's declared servers' defs (warmed above).
357    let spawn_pool = mcp_pool.clone();
358    let spawn_runs_dir = runs_dir.clone();
359    let spawn_reloader = reloader.clone();
360    host.set_spawner(Box::new(move |world, args| {
361        // Stake out the run directory before anything that can fail: blueprint
362        // parsing, sandbox creation, provider resolution and seed validation all
363        // come later, and until now a failure at any of them left no trace on
364        // disk at all - no run dir, no meta.json, nothing to diagnose (#107).
365        // The reload path deliberately doesn't do this: it must not overwrite a
366        // recovering run's own metadata.
367        write_placeholder_meta(&spawn_runs_dir, args);
368        let defs = per_agent_mcp_defs(&spawn_pool, &mcp_tool_defs, &args.blueprint_path);
369        // Fresh config per spawn: a `config.toml` edit (a new `[read_paths]`
370        // grant, a permission change) takes effect on the next `lev run`
371        // without a daemon restart.
372        let config = spawn_reloader.current();
373        let built = build_agent(
374            world.world_mut(),
375            tool_service.as_ref(),
376            &config,
377            shared_mcp.clone(),
378            &defs,
379            &hub,
380            args,
381            now_secs(),
382            subagent_tx.clone(),
383        );
384        // The placeholder above is `Starting`, which is *not* terminal - so a
385        // failed spawn used to leave a run that claimed to be alive for ever,
386        // listed by `lev ps` and the dashboard with nothing behind it. Record
387        // the failure where the placeholder is (issue #190).
388        if let Err(message) = &built {
389            crate::runstate::force_error_in(
390                &spawn_runs_dir.join(&args.run_id),
391                message,
392                now_secs(),
393            );
394        }
395        built
396    }));
397    host
398}
399
400/// Create the run directory and write a `Starting` `meta.json` for a run that is
401/// about to be built, so a spawn that dies partway through still leaves something
402/// on disk to explain itself (in one live batch, 3 of 13 empty runs crashed
403/// before any state existed). Everything the agent hasn't resolved yet - model,
404/// stage names, stage count - is left blank; the first persistence tick
405/// overwrites the file with the real thing. Best-effort: a failure here must not
406/// block the spawn.
407///
408/// Writes under the host's configured `runs_dir` - the same directory the
409/// persistence lane and the reloader use. It deliberately does *not* go through
410/// `runstate::create_run`, which resolves the runs dir globally from
411/// `dirs::home_dir()`: that ignores a daemon configured with a different runs
412/// dir and, because `dirs::home_dir()` cannot be redirected by `$HOME` on macOS,
413/// lets any test that spawns through a real host write placeholder runs into the
414/// developer's own `~/.leviath/runs` (where they then show as permanently
415/// ACTIVE, since nothing would ever advance them).
416fn write_placeholder_meta(runs_dir: &std::path::Path, args: &leviath_runtime::host::SpawnArgs) {
417    // The real agent name lives in the blueprint, which hasn't been parsed yet -
418    // but the run id is `<agent>-<unix-secs>-<hex4>`, so its prefix is the name
419    // (dashes inside the agent name included).
420    let agent_name = args
421        .run_id
422        .rsplitn(3, '-')
423        .nth(2)
424        .unwrap_or(&args.run_id)
425        .to_string();
426    let meta = leviath_core::run_meta::RunMeta::new(
427        args.run_id.clone(),
428        agent_name,
429        args.blueprint_path.clone(),
430        args.task.clone(),
431        None,
432        args.workdir.clone(),
433        0,
434    );
435    if let Err(e) = crate::runstate::create_run_in(&runs_dir.join(&args.run_id), &meta) {
436        tracing::warn!(run_id = %args.run_id, error = %e, "could not pre-create run directory");
437    }
438}
439
440/// The spawn-preprocessor body: connect the blueprint's declared `[[mcp_servers]]`
441/// into `pool` (lazy, deduped by signature). A missing/unreadable manifest is a
442/// no-op. Extracted from the closure so its body is unit-testable.
443async fn warm_blueprint_mcp(pool: &crate::daemon::mcp_pool::McpPool, blueprint_path: &str) {
444    if let Ok(toml) = std::fs::read_to_string(blueprint_path) {
445        for server in crate::daemon::mcp_pool::parse_blueprint_mcp_servers(&toml) {
446            pool.ensure(&server).await;
447        }
448    }
449}
450
451/// Pre-warm the MCP servers declared by this blueprint's `worker_agent` /
452/// `worker_query` fan-out workers, so the *first* worker spawned advertises them
453/// immediately instead of one turn late. `worker_stage` workers reuse
454/// the parent's own blueprint, already warmed by [`warm_blueprint_mcp`], so they
455/// are skipped here. A worker source that can't be read/resolved is skipped.
456/// Extracted from the preprocessor closure so its body is unit-testable.
457async fn warm_fanout_worker_mcp(
458    pool: &crate::daemon::mcp_pool::McpPool,
459    blueprint_path: &str,
460    agents_dir: Option<&std::path::Path>,
461) {
462    let Ok(content) = std::fs::read_to_string(blueprint_path) else {
463        return;
464    };
465    let Ok(blueprint) = leviath_core::manifest::parse_manifest(&content) else {
466        return;
467    };
468    for stage in &blueprint.stages {
469        let leviath_core::blueprint::StageMode::FanOut { config } = &stage.mode else {
470            continue;
471        };
472        // A `worker_stage` worker runs the parent blueprint (already warmed).
473        if config.worker_stage.is_some() {
474            continue;
475        }
476        let Ok((resolve_path, _)) = crate::daemon::fanout_spawner::resolve_worker_source(
477            config,
478            blueprint_path,
479            agents_dir,
480        ) else {
481            continue;
482        };
483        let Ok(manifest) = crate::commands::run::manifest::find_manifest(&resolve_path) else {
484            continue;
485        };
486        if let Ok(worker_toml) = std::fs::read_to_string(&manifest) {
487            for server in crate::daemon::mcp_pool::parse_blueprint_mcp_servers(&worker_toml) {
488                pool.ensure(&server).await;
489            }
490        }
491    }
492}
493
494/// The per-agent MCP tool defs: the global servers' defs plus this blueprint's
495/// declared servers' cached defs (the pool must already be warm - the
496/// preprocessor ran). A missing/unreadable manifest yields just the global defs.
497/// Extracted from the spawner closure so its body is unit-testable.
498fn per_agent_mcp_defs(
499    pool: &crate::daemon::mcp_pool::McpPool,
500    global: &[Tool],
501    blueprint_path: &str,
502) -> Vec<Tool> {
503    let mut defs = global.to_vec();
504    if let Ok(toml) = std::fs::read_to_string(blueprint_path) {
505        let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(&toml);
506        defs.extend(pool.cached_defs_for(&servers));
507    }
508    defs
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use leviath_runtime::components::AgentStatus;
515    use leviath_runtime::host::{ControlOp, SpawnArgs};
516    use tokio::sync::oneshot;
517
518    /// A config whose registry actually has `anthropic` in it, so a spawn of a
519    /// manifest naming that provider is not refused for having none.
520    fn config_with_anthropic_key() -> Config {
521        let mut config = Config::default();
522        config.providers.anthropic_api_key = Some("test-key".to_string());
523        config
524    }
525
526    #[tokio::test]
527    async fn make_reaper_delegates_to_tool_service_reap() {
528        // Exercises the reaper closure body build_host installs. The daemon only
529        // fires it from the private `serve()` loop, so drive it directly here.
530        let tool_service = Arc::new(CliToolService::new());
531        let mut world = PipelineWorld::new(
532            ProviderRegistry::new(),
533            tool_service.clone(),
534            InferencePoolConfig::new(),
535            1,
536            None,
537            Handle::current(),
538        );
539        let mut reaper = make_reaper(tool_service.clone());
540        // No registered state for this entity → a clean no-op (the reap-branch
541        // logic itself is covered by CliToolService::reap's own unit test).
542        let entity = bevy_ecs::entity::Entity::from_raw_u32(1)
543            .expect("a small literal index is always a valid entity id");
544        reaper(&mut world, entity);
545        assert!(tool_service.take(entity).is_none());
546    }
547
548    struct FakeProvider;
549    #[async_trait::async_trait]
550    impl leviath_providers::Provider for FakeProvider {
551        async fn infer(
552            &self,
553            _r: leviath_providers::InferenceRequest,
554        ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
555            Err(leviath_providers::ProviderError::Other("test".to_string()))
556        }
557        async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
558            1
559        }
560        fn max_context_tokens(&self, _m: &str) -> usize {
561            1000
562        }
563        fn name(&self) -> &str {
564            "fake"
565        }
566        fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
567            leviath_providers::ModelCapabilities::default()
568        }
569    }
570
571    #[test]
572    fn control_address_is_derived_from_leviath_home() {
573        let a = temp_env::with_var("LEVIATH_HOME", Some("/tmp/leviath-home-a"), control_address)
574            .unwrap();
575        let b = temp_env::with_var("LEVIATH_HOME", Some("/tmp/leviath-home-b"), control_address)
576            .unwrap();
577        // Different homes resolve to different control ids on every platform.
578        assert_ne!(a, b);
579        // On Unix the id is the socket path under the home's `.leviath` dir.
580        #[cfg(unix)]
581        {
582            assert!(a.ends_with(".leviath/control.sock"));
583            assert!(a.starts_with("/tmp/leviath-home-a"));
584        }
585    }
586
587    #[tokio::test]
588    async fn setup_daemon_host_builds_a_working_host() {
589        // Config::default has no MCP servers → the shared MCP connect is a no-op.
590        // An empty runs dir → restart recovery finds nothing to reload.
591        // A key for the manifest's provider, because a spawn whose stages have
592        // no usable provider is now refused outright (issue #190).
593        let runs = tempfile::tempdir().unwrap();
594        let mut host = setup_daemon_host(
595            config_with_anthropic_key(),
596            runs.path().to_path_buf(),
597            Handle::current(),
598        )
599        .await;
600
601        // Spawning through the wired host exercises the real setup end to end
602        // (including the now_secs timestamp closure).
603        let dir = tempfile::tempdir().unwrap();
604        let manifest = dir.path().join("agent.leviath");
605        std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
606        let (reply, rx) = oneshot::channel();
607        host.handle(ControlOp::Spawn {
608            args: Box::new(SpawnArgs {
609                run_id: "run-s".to_string(),
610                blueprint_path: manifest.to_string_lossy().to_string(),
611                task: "t".to_string(),
612                regions: Default::default(),
613                model: None,
614                workdir: std::env::temp_dir().to_string_lossy().to_string(),
615                metadata: Default::default(),
616                callback_url: None,
617                callback_secret: None,
618                yolo: false,
619                no_seed_commands: false,
620                allow: Vec::new(),
621                max_depth: None,
622                parent_run_id: None,
623            }),
624            reply,
625        });
626        assert_eq!(rx.await.unwrap(), Ok("run-s".to_string()));
627    }
628
629    /// A spawn can die before any state exists (3 of 13 empty runs in one live
630    /// batch), leaving nothing on disk to diagnose. The spawner stakes out the
631    /// run directory first, so a spawn that fails at *any* later step still
632    /// leaves a `meta.json` - and, since `Starting` is not terminal and would
633    /// otherwise claim the run was alive for ever, records the failure in it.
634    #[tokio::test]
635    async fn spawner_records_the_failure_in_the_run_dir_it_staked_out() {
636        let runs = tempfile::tempdir().unwrap();
637        let mut host = setup_daemon_host(
638            Config::default(),
639            runs.path().to_path_buf(),
640            Handle::current(),
641        )
642        .await;
643        let (reply, rx) = oneshot::channel();
644        host.handle(ControlOp::Spawn {
645            args: Box::new(SpawnArgs {
646                // A blueprint path that doesn't exist: the spawn fails at the
647                // very first step inside build_agent.
648                run_id: "my-agent-1234-ab12".to_string(),
649                blueprint_path: "/no/such/agent.leviath".to_string(),
650                task: "t".to_string(),
651                workdir: std::env::temp_dir().to_string_lossy().to_string(),
652                ..Default::default()
653            }),
654            reply,
655        });
656        assert!(rx.await.unwrap().is_err());
657
658        let meta = crate::runstate::read_meta_from(&runs.path().join("my-agent-1234-ab12"))
659            .expect("a failed spawn still leaves meta.json behind");
660        // Terminal, not `Starting`: nothing is going to advance this run.
661        assert_eq!(meta.status, leviath_core::run_meta::RunStatus::Error);
662        assert!(
663            meta.error
664                .is_some_and(|e| e.contains("/no/such/agent.leviath")),
665            "and it says what went wrong"
666        );
667        assert_eq!(meta.task, "t");
668        // The agent name is recovered from the run id's prefix, dashes and all.
669        assert_eq!(meta.agent_name, "my-agent");
670    }
671
672    #[test]
673    fn placeholder_meta_falls_back_to_the_whole_run_id_as_the_agent_name() {
674        let runs = tempfile::tempdir().unwrap();
675        let args = SpawnArgs {
676            // Not the `<agent>-<secs>-<hex>` shape the run-id minter makes.
677            run_id: "odd".to_string(),
678            task: "t".to_string(),
679            ..Default::default()
680        };
681        write_placeholder_meta(runs.path(), &args);
682        let meta = crate::runstate::read_meta_from(&runs.path().join("odd")).unwrap();
683        assert_eq!(meta.agent_name, "odd");
684    }
685
686    #[test]
687    fn placeholder_meta_failure_is_logged_not_fatal() {
688        // An unwritable runs dir (here: a path *under a regular file*) must not
689        // stop the spawn - the placeholder is a diagnostic, not a prerequisite.
690        crate::test_support::with_tracing(|| {
691            let dir = tempfile::tempdir().unwrap();
692            let blocker = dir.path().join("not-a-dir");
693            std::fs::write(&blocker, "x").unwrap();
694            let args = SpawnArgs {
695                run_id: "blocked".to_string(),
696                ..Default::default()
697            };
698            write_placeholder_meta(&blocker.join("runs"), &args);
699            assert!(
700                crate::runstate::read_meta_from(&blocker.join("runs").join("blocked")).is_err()
701            );
702        });
703    }
704
705    /// The spawner stakes out the run directory under the **host's configured**
706    /// `runs_dir`, never the home-resolved global one.
707    ///
708    /// This is an isolation invariant, not a convenience: `runstate::run_dir()`
709    /// goes through `dirs::home_dir()`, which ignores a `$HOME` override on macOS,
710    /// so a spawner that used it wrote into the developer's real `~/.leviath/runs`
711    /// from any test that drove a real host - leaving `status: "starting"` runs
712    /// that no daemon owned and nothing could ever advance. Asserting the global
713    /// dir is untouched is what keeps that from coming back.
714    #[tokio::test]
715    async fn spawner_writes_the_placeholder_under_the_hosts_runs_dir() {
716        let runs = tempfile::tempdir().unwrap();
717        // Resolve the global dir once: sibling tests redirect it via the
718        // process-global `LEVIATH_RUNS_DIR`, so resolving it twice could compare
719        // two different directories.
720        let global = crate::runstate::runs_dir();
721        let global_before = run_ids_in(&global);
722
723        let mut host = setup_daemon_host(
724            Config::default(),
725            runs.path().to_path_buf(),
726            Handle::current(),
727        )
728        .await;
729        let (reply, rx) = oneshot::channel();
730        host.handle(ControlOp::Spawn {
731            args: Box::new(SpawnArgs {
732                // A blueprint that doesn't exist: the spawn fails *after* the
733                // placeholder is staked out, which is the case that leaves a run
734                // dir behind.
735                run_id: "isolation-1234-ab12".to_string(),
736                blueprint_path: "/no/such/agent.leviath".to_string(),
737                task: "t".to_string(),
738                workdir: std::env::temp_dir().to_string_lossy().to_string(),
739                ..Default::default()
740            }),
741            reply,
742        });
743        assert!(rx.await.unwrap().is_err(), "the spawn itself fails");
744
745        assert!(
746            crate::runstate::read_meta_from(&runs.path().join("isolation-1234-ab12")).is_ok(),
747            "the placeholder lands in the host's configured runs dir"
748        );
749        assert_eq!(
750            run_ids_in(&global),
751            global_before,
752            "spawning through a host must not write into the home-resolved runs dir"
753        );
754    }
755
756    /// End-to-end for the unkillable-run shape: a run whose blueprint no longer
757    /// exists cannot be rebuilt, so the reloader declines - and a cancel that
758    /// stops there, replying "no such run" and writing nothing, leaves
759    /// `meta.json` claiming the run is live with no way to ever clear it. It
760    /// must be terminated on disk instead.
761    #[tokio::test]
762    async fn cancelling_an_unreloadable_run_terminates_it_on_disk() {
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
771        // Staked out *after* startup, so the recovery sweep (which marks
772        // un-reloadable runs as crashed) hasn't already dealt with it - this is
773        // the live case: the daemon is up and the run cannot be paged in.
774        let run_dir = runs.path().join("gone-1234-ab12");
775        let meta = leviath_core::run_meta::RunMeta::new(
776            "gone-1234-ab12".to_string(),
777            "gone".to_string(),
778            // A blueprint path that does not exist - the deleted-manifest case.
779            "/no/such/dir/agent.leviath".to_string(),
780            "t".to_string(),
781            None,
782            std::env::temp_dir().to_string_lossy().to_string(),
783            1,
784        );
785        crate::runstate::create_run_in(&run_dir, &meta).unwrap();
786        assert!(
787            !crate::runstate::is_terminal_status(
788                &crate::runstate::read_meta_from(&run_dir).unwrap().status
789            ),
790            "the run starts out looking live"
791        );
792
793        let (reply, rx) = oneshot::channel();
794        host.handle(ControlOp::Cancel {
795            run_id: "gone-1234-ab12".to_string(),
796            reply,
797        });
798        assert!(rx.await.unwrap(), "the cancel reports that it applied");
799        assert_eq!(
800            crate::runstate::read_meta_from(&run_dir).unwrap().status,
801            leviath_core::run_meta::RunStatus::Cancelled,
802            "and it reached disk, so nothing shows the run as live any more"
803        );
804
805        // A run id that names nothing at all is still an honest miss.
806        let (reply, rx) = oneshot::channel();
807        host.handle(ControlOp::Cancel {
808            run_id: "no-such-run".to_string(),
809            reply,
810        });
811        assert!(!rx.await.unwrap());
812    }
813
814    /// The run ids present in `dir`. An unreadable or absent directory is an
815    /// empty set, which is the same assertion for the isolation check.
816    fn run_ids_in(dir: &std::path::Path) -> std::collections::BTreeSet<String> {
817        std::fs::read_dir(dir)
818            .into_iter()
819            .flatten()
820            .flatten()
821            .map(|e| e.file_name().to_string_lossy().into_owned())
822            .collect()
823    }
824
825    #[test]
826    fn run_ids_in_lists_entries_and_tolerates_a_missing_dir() {
827        let dir = tempfile::tempdir().unwrap();
828        std::fs::create_dir_all(dir.path().join("run-one")).unwrap();
829        std::fs::create_dir_all(dir.path().join("run-two")).unwrap();
830        assert_eq!(
831            run_ids_in(dir.path()),
832            ["run-one".to_string(), "run-two".to_string()]
833                .into_iter()
834                .collect()
835        );
836        // A dir that doesn't exist reads as "nothing there", not a panic.
837        assert!(run_ids_in(&dir.path().join("nope")).is_empty());
838    }
839
840    // ── per-agent MCP (issue #97) ──
841
842    /// A python stub MCP server written to a temp file; returns (tempdir, path).
843    fn stub_server_py() -> (tempfile::TempDir, std::path::PathBuf) {
844        let dir = tempfile::tempdir().unwrap();
845        let path = dir.path().join("stub.py");
846        std::fs::write(
847            &path,
848            r#"
849import sys, json
850def respond(i, r):
851    sys.stdout.write(json.dumps({"jsonrpc":"2.0","id":i,"result":r})+"\n"); sys.stdout.flush()
852for line in sys.stdin:
853    line=line.strip()
854    if not line: continue
855    req=json.loads(line); m=req.get("method",""); i=req.get("id")
856    if m=="initialize": respond(i,{"capabilities":{"tools":{"listChanged":True}},"protocolVersion":"2024-11-05"})
857    elif m=="notifications/initialized": pass
858    elif m=="tools/list": respond(i,{"tools":[{"name":"stub_search","description":"s","inputSchema":{"type":"object","properties":{}}}]})
859    elif m=="tools/call": respond(i,{"content":[{"type":"text","text":"ok"}],"isError":False})
860    else: respond(i,{})
861"#,
862        )
863        .unwrap();
864        (dir, path)
865    }
866
867    /// Write a blueprint declaring one stdio `[[mcp_servers]]` → the stub; returns
868    /// its manifest path.
869    fn blueprint_with_mcp(dir: &std::path::Path, stub_py: &std::path::Path) -> std::path::PathBuf {
870        let manifest = dir.join("agent.leviath");
871        std::fs::write(
872            &manifest,
873            format!(
874                r#"
875[agent]
876name = "mcpagent"
877entry_stage = "work"
878
879[[mcp_servers]]
880name = "search"
881command = "python3"
882args = ['{}']
883
884[stages.work]
885mode = "autonomous"
886model = {{ provider = "fake", model = "m" }}
887available_tools = ["stub_search"]
888system_prompt = "use stub_search"
889
890[context.regions]
891task = {{ kind = "pinned", max_tokens = 200, seed = {{ caller_input = "task" }} }}
892"#,
893                stub_py.to_string_lossy()
894            ),
895        )
896        .unwrap();
897        manifest
898    }
899
900    fn empty_pool() -> crate::daemon::mcp_pool::McpPool {
901        crate::daemon::mcp_pool::McpPool::new(
902            Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
903            Default::default(),
904        )
905    }
906
907    #[tokio::test]
908    async fn warm_blueprint_mcp_connects_declared_servers() {
909        let (_stub_dir, stub) = stub_server_py();
910        let dir = tempfile::tempdir().unwrap();
911        let manifest = blueprint_with_mcp(dir.path(), &stub);
912        let pool = empty_pool();
913        warm_blueprint_mcp(&pool, &manifest.to_string_lossy()).await;
914        // The declared server is now warm: its tool is cached + advertised.
915        let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(
916            &std::fs::read_to_string(&manifest).unwrap(),
917        );
918        let defs = pool.cached_defs_for(&servers);
919        assert_eq!(defs.len(), 1);
920        assert_eq!(defs[0].name, "stub_search");
921    }
922
923    #[tokio::test]
924    async fn warm_blueprint_mcp_missing_manifest_is_noop() {
925        let pool = empty_pool();
926        // Unreadable path → the read-error arm, no panic.
927        warm_blueprint_mcp(&pool, "/no/such/agent.leviath").await;
928    }
929
930    /// Write a parent blueprint whose fan-out stage delegates to `worker_source`
931    /// (a `worker_agent` path). Returns the parent manifest path.
932    fn parent_with_fanout_worker_agent(
933        dir: &std::path::Path,
934        worker_source: &str,
935    ) -> std::path::PathBuf {
936        let manifest = dir.join("parent.leviath");
937        std::fs::write(
938            &manifest,
939            format!(
940                "[agent]\nname = \"parent\"\n\n\
941                 [stages.main]\nmode = \"autonomous\"\n\n\
942                 [stages.parallel]\nmode = \"fan_out\"\nworker_agent = '{worker_source}'\nsplit_prompt = \"go\"\n"
943            ),
944        )
945        .unwrap();
946        manifest
947    }
948
949    #[tokio::test]
950    async fn warm_fanout_worker_mcp_prewarms_worker_agent_servers() {
951        let (_stub_dir, stub) = stub_server_py();
952        // A worker blueprint declaring an MCP server.
953        let worker_dir = tempfile::tempdir().unwrap();
954        blueprint_with_mcp(worker_dir.path(), &stub);
955        // A parent whose fan-out delegates to that worker directory.
956        let parent_dir = tempfile::tempdir().unwrap();
957        let parent = parent_with_fanout_worker_agent(
958            parent_dir.path(),
959            &worker_dir.path().to_string_lossy(),
960        );
961        let pool = empty_pool();
962        warm_fanout_worker_mcp(&pool, &parent.to_string_lossy(), None).await;
963        // The worker's declared server is now warm (its tool cached), so the first
964        // worker will advertise it immediately.
965        let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(
966            &std::fs::read_to_string(worker_dir.path().join("agent.leviath")).unwrap(),
967        );
968        let defs = pool.cached_defs_for(&servers);
969        assert_eq!(defs.len(), 1);
970        assert_eq!(defs[0].name, "stub_search");
971    }
972
973    #[tokio::test]
974    async fn warm_fanout_worker_mcp_skips_and_tolerates_every_arm() {
975        let pool = empty_pool();
976        // Unreadable parent → read-error return.
977        warm_fanout_worker_mcp(&pool, "/no/such/parent.leviath", None).await;
978        // Unparsable parent → parse-error return.
979        let dir = tempfile::tempdir().unwrap();
980        let bad = dir.path().join("bad.leviath");
981        std::fs::write(&bad, "not : valid : toml").unwrap();
982        warm_fanout_worker_mcp(&pool, &bad.to_string_lossy(), None).await;
983        // A blueprint with only a non-fan-out stage → the `continue` (not FanOut).
984        let plain = dir.path().join("plain.leviath");
985        std::fs::write(
986            &plain,
987            "[agent]\nname = \"p\"\n\n[stages.main]\nmode = \"autonomous\"\n",
988        )
989        .unwrap();
990        warm_fanout_worker_mcp(&pool, &plain.to_string_lossy(), None).await;
991        // A `worker_stage` fan-out → skipped (reuses the parent's own servers).
992        let ws = dir.path().join("ws.leviath");
993        std::fs::write(
994            &ws,
995            "[agent]\nname = \"p\"\n\n\
996             [stages.parallel]\nmode = \"fan_out\"\nworker_stage = \"w\"\nsplit_prompt = \"go\"\n\n\
997             [stages.w]\nmode = \"autonomous\"\nallow_as_worker = true\n",
998        )
999        .unwrap();
1000        warm_fanout_worker_mcp(&pool, &ws.to_string_lossy(), None).await;
1001        // A `worker_query` with no agents dir → resolve_worker_source errors → skip.
1002        let wq = dir.path().join("wq.leviath");
1003        std::fs::write(
1004            &wq,
1005            "[agent]\nname = \"p\"\n\n\
1006             [stages.parallel]\nmode = \"fan_out\"\nworker_query = \"x\"\nsplit_prompt = \"go\"\n",
1007        )
1008        .unwrap();
1009        warm_fanout_worker_mcp(&pool, &wq.to_string_lossy(), None).await;
1010        // A `worker_agent` pointing at a nonexistent path → find_manifest errors → skip.
1011        let miss = parent_with_fanout_worker_agent(dir.path(), "/no/such/worker/xyz");
1012        warm_fanout_worker_mcp(&pool, &miss.to_string_lossy(), None).await;
1013        // A `worker_agent` whose blueprint declares no [[mcp_servers]] → read-ok,
1014        // empty server loop.
1015        let worker_dir = tempfile::tempdir().unwrap();
1016        std::fs::write(
1017            worker_dir.path().join("agent.leviath"),
1018            "[agent]\nname = \"w\"\n\n[stages.main]\nmode = \"autonomous\"\n",
1019        )
1020        .unwrap();
1021        let noservers =
1022            parent_with_fanout_worker_agent(dir.path(), &worker_dir.path().to_string_lossy());
1023        warm_fanout_worker_mcp(&pool, &noservers.to_string_lossy(), None).await;
1024        // A `worker_agent` dir whose `agent.leviath` is itself a directory:
1025        // find_manifest resolves it (it `exists()`), but reading it fails → the
1026        // inner read-error arm.
1027        let dir_manifest = tempfile::tempdir().unwrap();
1028        std::fs::create_dir(dir_manifest.path().join("agent.leviath")).unwrap();
1029        let unreadable =
1030            parent_with_fanout_worker_agent(dir.path(), &dir_manifest.path().to_string_lossy());
1031        warm_fanout_worker_mcp(&pool, &unreadable.to_string_lossy(), None).await;
1032    }
1033
1034    #[test]
1035    fn per_agent_mcp_defs_appends_declared_and_falls_back_to_global() {
1036        let (_stub_dir, stub) = stub_server_py();
1037        let dir = tempfile::tempdir().unwrap();
1038        let manifest = blueprint_with_mcp(dir.path(), &stub);
1039        let pool = empty_pool();
1040        // Warm the pool by seeding the declared server's defs (avoids a live
1041        // connect in this sync test).
1042        let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(
1043            &std::fs::read_to_string(&manifest).unwrap(),
1044        );
1045        pool.seed(
1046            &servers[0],
1047            vec![Tool {
1048                name: "stub_search".into(),
1049                description: String::new(),
1050                parameters: serde_json::json!({}),
1051            }],
1052        );
1053        let global = vec![Tool {
1054            name: "global_tool".into(),
1055            description: String::new(),
1056            parameters: serde_json::json!({}),
1057        }];
1058        let defs = per_agent_mcp_defs(&pool, &global, &manifest.to_string_lossy());
1059        let names: Vec<&str> = defs.iter().map(|t| t.name.as_str()).collect();
1060        assert_eq!(names, vec!["global_tool", "stub_search"]);
1061        // Missing manifest → just the global defs (read-error arm).
1062        let only_global = per_agent_mcp_defs(&pool, &global, "/no/such/x");
1063        assert_eq!(only_global.len(), 1);
1064        assert_eq!(only_global[0].name, "global_tool");
1065    }
1066
1067    #[tokio::test]
1068    async fn build_host_seeds_global_mcp_servers() {
1069        // A config with a (never-connected) global server exercises the seed loop.
1070        let config = Config {
1071            mcp_servers: vec![leviath_mcp::MCPServerConfig::stdio(
1072                "global-srv",
1073                "python3",
1074                vec!["-c".to_string(), "pass".to_string()],
1075            )],
1076            ..Config::default()
1077        };
1078        let runs = tempfile::tempdir().unwrap();
1079        let _host = build_host(
1080            config,
1081            ProviderRegistry::new(),
1082            runs.path().to_path_buf(),
1083            Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1084            Vec::new(),
1085            crate::daemon::mcp_pool::McpPool::for_daemon(
1086                Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1087                &[],
1088            ),
1089            Handle::current(),
1090            || 0,
1091        );
1092    }
1093
1094    #[tokio::test]
1095    async fn build_host_installs_the_configured_telemetry_sink() {
1096        // `[observability] enabled + stdout` replaces the world's no-op sink.
1097        let config = Config {
1098            observability: leviath_core::config::ObservabilityConfig {
1099                enabled: true,
1100                exporter: leviath_core::config::TelemetryExporterKind::Stdout,
1101                endpoint: None,
1102                service_name: None,
1103            },
1104            ..Config::default()
1105        };
1106        let runs = tempfile::tempdir().unwrap();
1107        let mut host = build_host(
1108            config,
1109            ProviderRegistry::new(),
1110            runs.path().to_path_buf(),
1111            Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1112            Vec::new(),
1113            crate::daemon::mcp_pool::McpPool::for_daemon(
1114                Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1115                &[],
1116            ),
1117            Handle::current(),
1118            || 0,
1119        );
1120        assert!(
1121            host.world_mut()
1122                .world_mut()
1123                .get_resource::<leviath_runtime::telemetry::Telemetry>()
1124                .is_some()
1125        );
1126    }
1127
1128    #[tokio::test(flavor = "multi_thread")]
1129    async fn build_host_with_otlp_also_installs_the_log_layer() {
1130        // The OTLP exporter carries a daemon-log bridge layer; build_host must
1131        // route it into the logging reload slot (a no-op when no subscriber
1132        // slot exists, as in this test process - the routing is the point).
1133        // Port 9 (discard) is never connected until an export flush happens,
1134        // which this test doesn't trigger.
1135        let config = Config {
1136            observability: leviath_core::config::ObservabilityConfig {
1137                enabled: true,
1138                exporter: leviath_core::config::TelemetryExporterKind::Otlp,
1139                endpoint: Some("http://127.0.0.1:9".to_string()),
1140                service_name: Some("leviath-test".to_string()),
1141            },
1142            ..Config::default()
1143        };
1144        let runs = tempfile::tempdir().unwrap();
1145        let mut host = build_host(
1146            config,
1147            ProviderRegistry::new(),
1148            runs.path().to_path_buf(),
1149            Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1150            Vec::new(),
1151            crate::daemon::mcp_pool::McpPool::for_daemon(
1152                Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1153                &[],
1154            ),
1155            Handle::current(),
1156            || 0,
1157        );
1158        assert!(
1159            host.world_mut()
1160                .world_mut()
1161                .get_resource::<leviath_runtime::telemetry::Telemetry>()
1162                .is_some()
1163        );
1164    }
1165
1166    #[tokio::test]
1167    async fn serve_runs_spawn_preprocessor_for_per_agent_mcp() {
1168        // Drive a real spawn through `serve()` so the spawn preprocessor fires
1169        // (the only path that invokes it): the agent declares an MCP server, which
1170        // gets connected + advertised, and the spawn replies Ok.
1171        let (_stub_dir, stub) = stub_server_py();
1172        let agent_dir = tempfile::tempdir().unwrap();
1173        let manifest = blueprint_with_mcp(agent_dir.path(), &stub);
1174        // A `fake` provider so stage resolution succeeds.
1175        let mut providers = ProviderRegistry::new();
1176        providers.register("fake".to_string(), Arc::new(FakeProvider));
1177        let runs = tempfile::tempdir().unwrap();
1178        let mut host = build_host(
1179            Config::default(),
1180            providers,
1181            runs.path().to_path_buf(),
1182            Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1183            Vec::new(),
1184            crate::daemon::mcp_pool::McpPool::for_daemon(
1185                Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1186                &[],
1187            ),
1188            Handle::current(),
1189            || 0,
1190        );
1191        let (ctl_tx, ctl_rx) = tokio::sync::mpsc::unbounded_channel();
1192        let (reply, reply_rx) = oneshot::channel();
1193        ctl_tx
1194            .send(ControlOp::Spawn {
1195                args: Box::new(SpawnArgs {
1196                    run_id: "run-mcp".to_string(),
1197                    blueprint_path: manifest.to_string_lossy().to_string(),
1198                    task: "t".to_string(),
1199                    regions: Default::default(),
1200                    model: None,
1201                    workdir: std::env::temp_dir().to_string_lossy().to_string(),
1202                    metadata: Default::default(),
1203                    callback_url: None,
1204                    callback_secret: None,
1205                    yolo: false,
1206                    no_seed_commands: false,
1207                    allow: Vec::new(),
1208                    max_depth: None,
1209                    parent_run_id: None,
1210                }),
1211                reply,
1212            })
1213            .unwrap();
1214        // Close the control channel so serve() returns after handling the op.
1215        drop(ctl_tx);
1216        host.serve(ctl_rx).await;
1217        assert_eq!(reply_rx.await.unwrap(), Ok("run-mcp".to_string()));
1218    }
1219
1220    #[tokio::test]
1221    async fn fake_provider_methods_are_exercised() {
1222        use leviath_providers::Provider;
1223        let p = FakeProvider;
1224        assert_eq!(p.name(), "fake");
1225        assert_eq!(p.count_tokens("t", "m").await, 1);
1226        assert_eq!(p.max_context_tokens("m"), 1000);
1227        let _ = p.capabilities("m");
1228        assert!(
1229            p.infer(leviath_providers::InferenceRequest {
1230                system: vec![],
1231                messages: vec![],
1232                model: "m".to_string(),
1233                max_tokens: 1,
1234                temperature: 0.0,
1235                tools: vec![],
1236                extra: serde_json::Value::Null,
1237                request_timeout_secs: None,
1238            })
1239            .await
1240            .is_err()
1241        );
1242    }
1243
1244    #[tokio::test]
1245    async fn build_host_spawns_agents_through_the_installed_spawner() {
1246        let dir = tempfile::tempdir().unwrap();
1247        let manifest = dir.path().join("agent.leviath");
1248        std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
1249
1250        let mut registry = ProviderRegistry::new();
1251        registry.register("anthropic".to_string(), Arc::new(FakeProvider));
1252        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1253
1254        let runs = tempfile::tempdir().unwrap();
1255        let mut host = build_host(
1256            Config::default(),
1257            registry,
1258            runs.path().to_path_buf(),
1259            mcp,
1260            vec![],
1261            crate::daemon::mcp_pool::McpPool::for_daemon(
1262                Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1263                &[],
1264            ),
1265            Handle::current(),
1266            || 100,
1267        );
1268
1269        // Drive a Spawn control op through the host.
1270        let (reply, rx) = oneshot::channel();
1271        host.handle(ControlOp::Spawn {
1272            args: Box::new(SpawnArgs {
1273                run_id: "run-1".to_string(),
1274                blueprint_path: manifest.to_string_lossy().to_string(),
1275                task: "do it".to_string(),
1276                regions: Default::default(),
1277                model: None,
1278                workdir: std::env::temp_dir().to_string_lossy().to_string(),
1279                metadata: Default::default(),
1280                callback_url: None,
1281                callback_secret: None,
1282                yolo: false,
1283                no_seed_commands: false,
1284                allow: Vec::new(),
1285                max_depth: None,
1286                parent_run_id: None,
1287            }),
1288            reply,
1289        });
1290        assert_eq!(rx.await.unwrap(), Ok("run-1".to_string()));
1291
1292        // The run is registered and Active.
1293        let (reply, rx) = oneshot::channel();
1294        host.handle(ControlOp::Status {
1295            run_id: "run-1".to_string(),
1296            reply,
1297        });
1298        assert_eq!(rx.await.unwrap(), Some(AgentStatus::Active));
1299    }
1300
1301    #[tokio::test]
1302    async fn build_host_reloads_and_registers_persisted_runs() {
1303        // A running run persisted under the runs dir must be reloaded + registered
1304        // by `build_host` (exercising the recovery register loop).
1305        let agent = tempfile::tempdir().unwrap();
1306        let manifest = agent.path().join("agent.leviath");
1307        std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
1308
1309        let runs = tempfile::tempdir().unwrap();
1310        let run_dir = runs.path().join("resumed");
1311        std::fs::create_dir_all(&run_dir).unwrap();
1312        let meta = leviath_core::run_meta::RunMeta {
1313            run_id: "resumed".to_string(),
1314            agent_name: "coder".to_string(),
1315            agent_path: manifest.to_string_lossy().to_string(),
1316            task: "resume".to_string(),
1317            model: None,
1318            pid: 0,
1319            status: leviath_core::run_meta::RunStatus::Running,
1320            current_stage: "implement".to_string(),
1321            stage_index: 0,
1322            num_stages: 1,
1323            iteration: 2,
1324            prompt_tokens: 0,
1325            completion_tokens: 0,
1326            cached_tokens: 0,
1327            cache_write_tokens: 0,
1328            tool_calls: 0,
1329            workdir: std::env::temp_dir().to_string_lossy().to_string(),
1330            started_at: 1,
1331            updated_at: 1,
1332            last_progress_at: None,
1333            error: None,
1334            title: None,
1335            metadata: Default::default(),
1336            callback_url: None,
1337            callback_secret: None,
1338            parent_run_id: None,
1339            children: Vec::new(),
1340            depth: 0,
1341            max_child_depth: 0,
1342            flags: Default::default(),
1343            yolo: false,
1344            read_paths: None,
1345        };
1346        std::fs::write(
1347            run_dir.join("meta.json"),
1348            serde_json::to_string(&meta).unwrap(),
1349        )
1350        .unwrap();
1351
1352        let mut registry = ProviderRegistry::new();
1353        registry.register("anthropic".to_string(), Arc::new(FakeProvider));
1354        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1355        let mut host = build_host(
1356            Config::default(),
1357            registry,
1358            runs.path().to_path_buf(),
1359            mcp,
1360            vec![],
1361            crate::daemon::mcp_pool::McpPool::for_daemon(
1362                Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1363                &[],
1364            ),
1365            Handle::current(),
1366            || 100,
1367        );
1368
1369        // The reloaded run is registered → Status resolves it.
1370        let (reply, rx) = oneshot::channel();
1371        host.handle(ControlOp::Status {
1372            run_id: "resumed".to_string(),
1373            reply,
1374        });
1375        assert_eq!(rx.await.unwrap(), Some(AgentStatus::Active));
1376    }
1377
1378    #[tokio::test]
1379    async fn build_host_installs_a_reloader_that_pages_in_unloaded_runs() {
1380        // A run that lands on disk *after* startup (so it is not auto-reloaded)
1381        // must still be reachable: a control op targeting it fires the installed
1382        // reloader, which pages it into the world on demand.
1383        let agent = tempfile::tempdir().unwrap();
1384        let manifest = agent.path().join("agent.leviath");
1385        std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
1386
1387        let runs = tempfile::tempdir().unwrap();
1388        let mut registry = ProviderRegistry::new();
1389        registry.register("anthropic".to_string(), Arc::new(FakeProvider));
1390        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1391        let mut host = build_host(
1392            Config::default(),
1393            registry,
1394            runs.path().to_path_buf(),
1395            mcp,
1396            vec![],
1397            crate::daemon::mcp_pool::McpPool::for_daemon(
1398                Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1399                &[],
1400            ),
1401            Handle::current(),
1402            || 100,
1403        );
1404
1405        // Persist a running run only now - build_host's startup reload already ran,
1406        // so it is on disk but absent from the world.
1407        let run_dir = runs.path().join("late");
1408        std::fs::create_dir_all(&run_dir).unwrap();
1409        let meta = leviath_core::run_meta::RunMeta {
1410            run_id: "late".to_string(),
1411            agent_name: "coder".to_string(),
1412            agent_path: manifest.to_string_lossy().to_string(),
1413            task: "page me in".to_string(),
1414            model: None,
1415            pid: 0,
1416            status: leviath_core::run_meta::RunStatus::Running,
1417            current_stage: "implement".to_string(),
1418            stage_index: 0,
1419            num_stages: 1,
1420            iteration: 1,
1421            prompt_tokens: 0,
1422            completion_tokens: 0,
1423            cached_tokens: 0,
1424            cache_write_tokens: 0,
1425            tool_calls: 0,
1426            workdir: std::env::temp_dir().to_string_lossy().to_string(),
1427            started_at: 1,
1428            updated_at: 1,
1429            last_progress_at: None,
1430            error: None,
1431            title: None,
1432            metadata: Default::default(),
1433            callback_url: None,
1434            callback_secret: None,
1435            parent_run_id: None,
1436            children: Vec::new(),
1437            depth: 0,
1438            max_child_depth: 0,
1439            flags: Default::default(),
1440            yolo: false,
1441            read_paths: None,
1442        };
1443        std::fs::write(
1444            run_dir.join("meta.json"),
1445            serde_json::to_string(&meta).unwrap(),
1446        )
1447        .unwrap();
1448
1449        // It is not loaded yet: a read-only Status does not page it in.
1450        let (reply, rx) = oneshot::channel();
1451        host.handle(ControlOp::Status {
1452            run_id: "late".to_string(),
1453            reply,
1454        });
1455        assert_eq!(rx.await.unwrap(), None);
1456
1457        // A Cancel routes through the reloader, paging it in and acting on it.
1458        let (reply, rx) = oneshot::channel();
1459        host.handle(ControlOp::Cancel {
1460            run_id: "late".to_string(),
1461            reply,
1462        });
1463        assert!(rx.await.unwrap());
1464    }
1465
1466    #[test]
1467    fn daemon_build_is_stale_compares_against_current_build() {
1468        assert!(daemon_build_is_stale(None), "missing marker is stale");
1469        assert!(
1470            daemon_build_is_stale(Some("some-other-build")),
1471            "a different build is stale"
1472        );
1473        assert!(
1474            !daemon_build_is_stale(Some(CURRENT_BUILD)),
1475            "the current build is not stale"
1476        );
1477    }
1478
1479    #[test]
1480    fn build_marker_round_trips_and_is_current() {
1481        let dir = tempfile::tempdir().unwrap();
1482        temp_env::with_var("LEVIATH_HOME", Some(dir.path()), || {
1483            // No marker yet → read is None → treated as stale.
1484            assert!(read_build_marker().is_none());
1485            assert!(daemon_build_is_stale(read_build_marker().as_deref()));
1486
1487            write_build_marker();
1488            let path = build_marker_path().unwrap();
1489            assert!(path.exists());
1490            assert_eq!(read_build_marker().as_deref(), Some(CURRENT_BUILD));
1491            // A daemon that wrote the current build is not stale.
1492            assert!(!daemon_build_is_stale(read_build_marker().as_deref()));
1493        });
1494    }
1495}