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