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