Skip to main content

leviath_cli/daemon/
recovery.rs

1//! Restart recovery: reload persisted non-terminal agents into a fresh world when
2//! the daemon starts, so runs interrupted by a stop/crash resume where they left
3//! off - critically, any agent that was mid-inference re-issues that inference
4//! (the reloaded agent is `ReadyToInfer`), rather than being lost.
5//!
6//! For each `<runs_dir>/<run_id>/meta.json` whose status is non-terminal, this
7//! loads the blueprint (via [`build_agent_for_reload`], reusing the spawn path),
8//! which skips the required-at-spawn region gate since the window is restored
9//! from a snapshot; restores the
10//! persisted context / stage / iteration / token totals via
11//! [`leviath_runtime::restore::restore_agent`], puts the run's per-stage ledger
12//! back from `stages.json` via
13//! [`leviath_runtime::restore::restore_stage_ledger`], and preserves the
14//! original run metadata. Anything unreadable or un-reloadable is skipped (logged), never fatal.
15//!
16//! One exception to the "re-issue inference" resume: a run that was parked at a
17//! stage-boundary interaction point (e.g. `plan_approval`) wrote an
18//! `interactions.json` sidecar while blocked. For those, `reload_one` calls
19//! [`leviath_runtime::interaction_points::restore_interaction_point`] to bring the
20//! agent back in the *waiting* state with the same prompt re-opened, rather than
21//! re-inferring and dropping it. Model-initiated dynamic tools
22//! (`ask_user_*`, `present_for_review`, `edit_document`) and taint-gate prompts are
23//! not persisted - they block inside the transient tool-worker turn, so on restart
24//! they take the ordinary re-inference path and the model simply re-asks.
25//!
26//! ## Tool-call delivery contract (issue #96)
27//!
28//! A tool batch in flight at the crash is **replayed, not re-executed**. Dispatch
29//! journals the batch (a `ToolBatch` record) before its side effects can start,
30//! and every call's result the moment it finishes (`ToolCallDone`); when the fold
31//! surfaces such a pending batch, `reload_one` calls
32//! [`leviath_runtime::restore::restore_pending_batch`] to land the assistant turn
33//! with each completed call's real journaled result - so completed side effects
34//! are exactly-once across a restart. Calls whose completion never reached the
35//! journal (still executing, or the crash landed in the instant between the
36//! external effect and its journal append - a window no journal can close,
37//! since an external side effect can't be observed atomically) come back as
38//! verify-first `[error] interrupted` results rather than being silently re-run;
39//! the re-issued inference decides what still needs doing.
40
41use std::path::Path;
42
43use bevy_ecs::entity::Entity;
44use leviath_core::run_archive;
45use leviath_core::run_meta::{ContextSnapshot, RunMeta, RunStatus};
46use leviath_runtime::host::SpawnArgs;
47use leviath_runtime::interaction_points::InteractionPointState;
48use leviath_runtime::persistence::{RunMetadata, TokenTotals};
49use leviath_runtime::restore::restore_agent;
50use leviath_runtime::world::PipelineWorld;
51
52// Seven of recovery's former imports came in only to spell the seven parameters
53// that are now one `SpawnDeps`.
54use crate::daemon::spawn::{SpawnDeps, build_agent_for_reload};
55
56/// Reload every non-terminal persisted run under `runs_dir`, returning the
57/// `(run_id, entity)` pairs for the host to map. Runs that fail to reload are
58/// skipped.
59pub fn reload_persisted_agents(
60    world: &mut PipelineWorld,
61    deps: SpawnDeps<'_>,
62    runs_dir: &Path,
63) -> Vec<(String, leviath_runtime::world::AgentId)> {
64    let mut reloaded: Vec<(RunMeta, Entity)> = Vec::new();
65    let Ok(dir_entries) = std::fs::read_dir(runs_dir) else {
66        return Vec::new(); // no runs dir yet - nothing to recover
67    };
68    // Scan phase: collect every persisted run's metadata + whether it's parked mid
69    // fan-out (has a fanout.json), so the triage can rank them.
70    let candidates: Vec<(RunMeta, bool)> = dir_entries
71        .flatten()
72        .filter_map(|dir_entry| {
73            let run_dir = dir_entry.path();
74            let meta = read_meta(&run_dir)?; // no meta.json, or unreadable/unparseable
75            let parked_on_fanout = run_dir.join("fanout.json").exists();
76            Some((meta, parked_on_fanout))
77        })
78        .collect();
79    // Order phase: drop terminal runs and rank the rest actionable-first (in-flight
80    // inference / pending tool results before blocked-on-input), so interrupted work
81    // that can make progress resumes ahead of runs that can't.
82    let ordered = leviath_runtime::restore::triage_restores(candidates);
83    for meta in ordered {
84        let run_dir = runs_dir.join(&meta.run_id);
85        match reload_one(world, deps.clone(), &meta, &run_dir) {
86            Ok(entity) => reloaded.push((meta, entity)),
87            Err(e) => {
88                tracing::warn!(run_id = %meta.run_id, error = %e, "skipping un-reloadable run");
89                mark_crashed(&run_dir, meta, &e.to_string(), deps.now_secs);
90            }
91        }
92    }
93    // Second pass: every run is now an entity, so rebuild the parent→children
94    // tree deterministically from the persisted links (no heuristics), then
95    // resume any parent that was parked mid fan-out.
96    relink_tree(world, &reloaded);
97    restore_fan_outs(world, &reloaded, runs_dir);
98    // Scoped on the way out: the host stores these for the life of the daemon,
99    // which is exactly where a bare entity would lose track of its world.
100    reloaded
101        .into_iter()
102        .map(|(meta, entity)| (meta.run_id, world.own_agent(entity)))
103        .collect()
104}
105
106/// Page a single unloaded run back into the world from disk, on demand. Reads
107/// its persisted metadata; if the run exists and is non-terminal, reloads it
108/// (blueprint + tool state + context/stage) and returns the new entity. `None`
109/// if there's no such resumable run. This is the host's reload-on-demand seam
110/// (an op targeting an unloaded run pages it in first).
111pub fn reload_run(
112    world: &mut PipelineWorld,
113    deps: SpawnDeps<'_>,
114    run_id: &str,
115    runs_dir: &std::path::Path,
116) -> Option<leviath_runtime::world::AgentId> {
117    let run_dir = runs_dir.join(run_id);
118    let meta = read_meta(&run_dir)?;
119    if is_terminal(&meta.status) {
120        return None; // a finished run isn't paged back in
121    }
122    let entity = reload_one(world, deps, &meta, &run_dir).ok()?;
123    Some(world.own_agent(entity))
124}
125
126/// Rebuild `FanOutWaiting` for any reloaded parent that was parked mid fan-out
127/// (a `<run_dir>/fanout.json` is present), so its split/merge resumes rather than
128/// hanging. Active workers are re-linked by run-id via the reloaded run→entity
129/// map; a worker that didn't reload is recorded as a failure so the merge still
130/// completes. A malformed/absent file is skipped.
131fn restore_fan_outs(world: &mut PipelineWorld, reloaded: &[(RunMeta, Entity)], runs_dir: &Path) {
132    let by_run_id: std::collections::HashMap<&str, Entity> = reloaded
133        .iter()
134        .map(|(m, e)| (m.run_id.as_str(), *e))
135        .collect();
136    for (meta, entity) in reloaded {
137        let path = runs_dir.join(&meta.run_id).join("fanout.json");
138        let Some(state) = std::fs::read_to_string(&path)
139            .ok()
140            .and_then(|s| serde_json::from_str::<leviath_runtime::fanout::FanOutState>(&s).ok())
141        else {
142            continue;
143        };
144        leviath_runtime::fanout::restore_fan_out_waiting(
145            world.world_mut(),
146            *entity,
147            state,
148            &|rid| by_run_id.get(rid).copied(),
149        );
150    }
151}
152
153/// Rebuild `ParentRef` / `SubAgentChildren` on the freshly reloaded entities from
154/// their persisted `parent_run_id` / `children` links, so a restarted daemon
155/// resumes the exact sub-agent tree (a waiting parent holds for its children;
156/// children aren't orphaned). Links whose counterpart didn't reload are logged
157/// and skipped. Idempotent: existing components are overwritten, not duplicated.
158fn relink_tree(world: &mut PipelineWorld, reloaded: &[(RunMeta, Entity)]) {
159    use leviath_runtime::components::{AgentState, ParentRef, SubAgentChildren};
160
161    let by_run_id: std::collections::HashMap<&str, Entity> = reloaded
162        .iter()
163        .map(|(m, e)| (m.run_id.as_str(), *e))
164        .collect();
165    let w = world.world_mut();
166    for (meta, entity) in reloaded {
167        // Child → parent edge.
168        if let Some(parent_id) = &meta.parent_run_id {
169            match by_run_id.get(parent_id.as_str()) {
170                Some(&parent_entity) => {
171                    w.entity_mut(*entity).insert(ParentRef {
172                        parent_entity,
173                        parent_agent_id: parent_id.clone(),
174                        depth: meta.depth,
175                    });
176                }
177                None => tracing::warn!(
178                    run_id = %meta.run_id, parent = %parent_id,
179                    "parent run did not reload; leaving child unlinked"
180                ),
181            }
182        }
183        // Parent → children edge (skip any child that didn't reload).
184        if !meta.children.is_empty() {
185            let children: Vec<Entity> = meta
186                .children
187                .iter()
188                .filter_map(|cid| by_run_id.get(cid.as_str()).copied())
189                .collect();
190            if !children.is_empty() {
191                w.entity_mut(*entity).insert(SubAgentChildren {
192                    children,
193                    max_child_depth: meta.max_child_depth,
194                });
195            }
196            // Keep the serializable child list consistent with the rebuilt
197            // component so the next snapshot re-persists the same tree. A reloaded
198            // agent always carries `AgentState`.
199            w.get_mut::<AgentState>(*entity)
200                .expect("a reloaded agent always has AgentState")
201                .spawned_children_ids = meta.children.clone();
202        }
203    }
204}
205
206/// Read + parse `<run_dir>/meta.json`, returning `None` if it is missing or
207/// invalid.
208fn read_meta(run_dir: &Path) -> Option<RunMeta> {
209    let text = std::fs::read_to_string(run_dir.join("meta.json")).ok()?;
210    serde_json::from_str(&text).ok()
211}
212
213/// The cumulative token totals recorded in a run's metadata.
214fn totals_from(meta: &RunMeta) -> TokenTotals {
215    TokenTotals {
216        prompt_tokens: meta.prompt_tokens,
217        completion_tokens: meta.completion_tokens,
218        cached_tokens: meta.cached_tokens,
219        cache_write_tokens: meta.cache_write_tokens,
220        tool_calls: meta.tool_calls,
221    }
222}
223
224/// Record a run that could not be reloaded as terminally errored.
225///
226/// The daemon is the sole owner of these runs, so anything still marked
227/// `running` at startup is by definition not running. Runs that *can* be
228/// reloaded are resumed (that is the whole point of this module); this is only
229/// for the ones that can't. Logging the failure without this write would leave
230/// them claiming `"status": "running"` on disk forever, so `lev ps` and the
231/// dashboard would show a live run that no longer exists.
232///
233/// Best-effort: a write failure here is logged, never fatal - the daemon is
234/// mid-startup and the rest of the recovery pass must still run.
235fn mark_crashed(run_dir: &Path, meta: RunMeta, reason: &str, now_secs: i64) {
236    let crashed = RunMeta {
237        status: RunStatus::Error,
238        error: Some(format!(
239            "the daemon exited while this run was active and it could not be recovered: {reason}"
240        )),
241        updated_at: now_secs,
242        ..meta
243    };
244    if let Err(e) = crate::runstate::write_meta_to(run_dir, &crashed) {
245        tracing::warn!(
246            run_id = %crashed.run_id,
247            error = %e,
248            "could not record an un-reloadable run as crashed"
249        );
250    }
251}
252
253/// Whether a run's status means it should not be resumed.
254fn is_terminal(status: &RunStatus) -> bool {
255    matches!(
256        status,
257        RunStatus::Complete | RunStatus::Cancelled | RunStatus::Error
258    )
259}
260
261/// Reload one run: spawn it fresh from its blueprint, then overlay the persisted
262/// context / stage / totals / per-stage ledger and preserve the original run
263/// metadata.
264fn reload_one(
265    world: &mut PipelineWorld,
266    deps: SpawnDeps<'_>,
267    meta: &RunMeta,
268    run_dir: &Path,
269) -> Result<Entity, String> {
270    let args = SpawnArgs {
271        run_id: meta.run_id.clone(),
272        blueprint_path: meta.agent_path.clone(),
273        task: meta.task.clone(),
274        // Region seed content isn't replayed on reload: the window is restored
275        // from the persisted context snapshot after build_agent, so re-seeding
276        // would be redundant (and could double up content).
277        regions: Default::default(),
278        model: meta.model.clone(),
279        workdir: meta.workdir.clone(),
280        metadata: meta.metadata.clone(),
281        callback_url: meta.callback_url.clone(),
282        callback_secret: meta.callback_secret.clone(),
283        // `--yolo` is the one launch override that survives a reload, because
284        // it is the one whose loss strands the run. Dropping it looked like the
285        // safe choice - forgetting an override can only prompt more, never less
286        // - but "more prompting" for an unattended run means stopping forever on
287        // a prompt nobody is watching for. The operator gave this consent at
288        // launch and never withdrew it; a daemon restart is not a withdrawal.
289        // Runs written before `yolo` was persisted default to `false`.
290        //
291        // `--allow` and `--max-depth` stay unpersisted: losing them narrows what
292        // the run may do, which is the harmless direction.
293        yolo: meta.yolo,
294        // Belt and braces: seeds aren't replayed on reload at all (see above),
295        // so a resumed run can never re-execute a command seed.
296        no_seed_commands: true,
297        allow: Vec::new(),
298        max_depth: None,
299        parent_run_id: meta.parent_run_id.clone(),
300        // Restored for the same reason `yolo` is: a reload that dropped the
301        // caller's requested shape would silently revert the run to the
302        // blueprint's partway through, and the caller would never see why.
303        output: meta.output_request.clone(),
304    };
305    let entity = build_agent_for_reload(world.world_mut(), deps, &args)?;
306
307    // Restore the persisted context, stage, iteration, and token totals.
308    //
309    // Prefer the run's atomic journal (`run.lvr`): it records meta + context
310    // together, so a crash between the separate `meta.json` and `context.json`
311    // writes can't leave us with a mismatched pair (new stage/iteration + stale
312    // context). The archive is appended *before* either JSON file, so in that exact
313    // crash window it already holds the newer generation and folds to a consistent
314    // `{meta, context}`. Fall back to the separate JSON files only for runs written
315    // before the archive existed, or an archive that couldn't be read at all - that
316    // pair may be one tick out of sync, but it's the pre-existing behavior.
317    let folded = std::fs::read(run_dir.join("run.lvr"))
318        .ok()
319        .and_then(|bytes| run_archive::read_archive_lenient(&mut bytes.as_slice()).ok())
320        .and_then(|(_version, records)| run_archive::fold(&records));
321    let (snapshot, stage_index, iteration, totals, pending_batch) = match folded {
322        Some(folded) => {
323            let totals = totals_from(&folded.meta);
324            (
325                folded.context,
326                folded.meta.stage_index,
327                folded.meta.iteration,
328                totals,
329                folded.pending_batch,
330            )
331        }
332        None => {
333            let snapshot = std::fs::read_to_string(run_dir.join("context.json"))
334                .ok()
335                .and_then(|s| serde_json::from_str::<ContextSnapshot>(&s).ok())
336                .unwrap_or_else(|| ContextSnapshot {
337                    stage_name: meta.current_stage.clone(),
338                    total_tokens: 0,
339                    max_tokens: 0,
340                    regions: Vec::new(),
341                });
342            (
343                snapshot,
344                meta.stage_index,
345                meta.iteration,
346                totals_from(meta),
347                // No journal ⇒ no batch record ⇒ the pre-journal behavior
348                // (plain re-inference).
349                None,
350            )
351        }
352    };
353    restore_agent(
354        world.world_mut(),
355        entity,
356        &snapshot,
357        stage_index,
358        iteration,
359        totals,
360    );
361
362    // Put the per-stage ledger back too. `build_agent_for_reload` seeds one
363    // all-zero record per blueprint stage, and the persist tick rewrites
364    // `stages.json` whole, so skipping this did not merely fail to restore the
365    // run's stage history - the next tick wrote zeros over the file that held
366    // it, while `meta.json`'s run totals went on looking correct (issue #415).
367    // No file (a run from before the ledger, or one that stopped before its
368    // first persist) leaves the seeded records as they are.
369    leviath_runtime::restore::restore_stage_ledger(
370        world.world_mut(),
371        entity,
372        &crate::runstate::read_stages_index_from(run_dir),
373    );
374
375    // A tool batch was in flight when the daemon died and its results never
376    // reached the window: replay what the journal recorded - real results for
377    // completed calls, verify-first errors for interrupted ones - so the
378    // re-issued inference sees what already ran instead of re-executing the
379    // batch's side effects (issue #96). fold() only surfaces a batch that is
380    // genuinely unapplied (same iteration, turn absent from the window).
381    if let Some(batch) = pending_batch {
382        leviath_runtime::restore::restore_pending_batch(
383            world.world_mut(),
384            entity,
385            &batch,
386            &meta.children,
387        );
388    }
389
390    // `build_agent` stamps fresh run metadata; preserve the original identity.
391    {
392        let mut md = world
393            .world_mut()
394            .get_mut::<RunMetadata>(entity)
395            .expect("build_agent attached run metadata");
396        md.started_at = meta.started_at;
397        md.title = meta.title.clone();
398        md.callback_url = meta.callback_url.clone();
399        md.callback_secret = meta.callback_secret.clone();
400        // `parent_run_id` was already restored via `args` into build_agent's metadata.
401    }
402
403    // Carry the run's productivity flags across the restart, so a resumed run
404    // doesn't report itself as having modified nothing (issue #107).
405    {
406        let mut flags = world
407            .world_mut()
408            .get_mut::<leviath_runtime::persistence::RunOutcomeFlags>(entity)
409            .expect("build_agent attached run outcome flags");
410        flags.0 = meta.flags.clone();
411    }
412
413    // Put back an answer the run had already submitted, content and all: the
414    // descriptor is in `meta.json`, the bytes in the sidecar beside it. Without
415    // this the component is absent after a reload, and the very next persist
416    // tick writes a `meta.json` with no `final_output` - so a restart would not
417    // merely fail to restore the answer, it would erase the one on disk. It
418    // also re-arms the required-output gate correctly: a stage that submitted
419    // before the restart is not asked to do it again.
420    if let Some(output) = read_final_output_from(run_dir, meta) {
421        world
422            .world_mut()
423            .entity_mut(entity)
424            .insert(leviath_runtime::persistence::FinalOutput(output));
425    }
426
427    // If this run was parked at a stage-boundary interaction point (e.g.
428    // plan_approval), re-present it in the *waiting* state rather than the default
429    // `Active` + `ReadyToInfer` restore - so the open prompt survives the restart
430    // instead of being dropped and re-inferred (issue #38). A missing/malformed
431    // sidecar, or a blueprint that no longer matches, leaves the default restore.
432    if let Some(state) = std::fs::read_to_string(run_dir.join("interactions.json"))
433        .ok()
434        .and_then(|s| serde_json::from_str::<InteractionPointState>(&s).ok())
435    {
436        // Built against this world's ECS a few lines above, so it is ours.
437        let agent = world.own_agent(entity);
438        leviath_runtime::interaction_points::restore_interaction_point(
439            world.world_mut(),
440            agent,
441            state,
442        );
443    }
444
445    // A run the user paused stays paused across the restart: the default
446    // restore presents it `Active`, which would silently resume it.
447    if meta.status == RunStatus::Paused {
448        // Built against this world's ECS, so it is ours.
449        world.pause(world.own_agent(entity));
450    }
451
452    Ok(entity)
453}
454
455/// Rebuild a run's submitted answer from its descriptor plus the sidecar in
456/// `dir`.
457///
458/// Recovery works from its configured runs directory rather than the home one,
459/// so it cannot use `runstate::read_final_output`, which resolves the path
460/// itself. A missing sidecar yields `None`: a run written before the answer
461/// moved out of `meta.json`, or one whose directory was pruned.
462fn read_final_output_from(dir: &Path, meta: &RunMeta) -> Option<leviath_core::FinalOutput> {
463    let descriptor = meta.final_output.clone()?;
464    let content = std::fs::read_to_string(dir.join(leviath_core::FINAL_OUTPUT_FILE)).ok()?;
465    Some(leviath_core::FinalOutput {
466        content,
467        format: descriptor.format,
468        stage: descriptor.stage,
469        submitted_at: descriptor.submitted_at,
470        truncated: descriptor.truncated,
471        artifacts: descriptor.artifacts,
472    })
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    // Named here rather than inherited from the parent: production now spells
479    // these seven as one `SpawnDeps`, so importing them above would mean seven
480    // imports the module itself does not use.
481    use std::sync::Arc;
482
483    use leviath_mcp::ToolExecutor;
484    use leviath_runtime::host::SubAgentOp;
485    use leviath_runtime::interaction_hub::InteractionHub;
486    use tokio::sync::Mutex;
487    use tokio::sync::mpsc::UnboundedSender;
488
489    use crate::config::Config;
490    use crate::daemon::tool_service::CliToolService;
491
492    use leviath_runtime::ProviderRegistry;
493    use leviath_runtime::components::AgentStatus;
494    use leviath_runtime::inference_pool::InferencePoolConfig;
495    use tokio::runtime::Handle;
496
497    fn sub_tx() -> UnboundedSender<SubAgentOp> {
498        tokio::sync::mpsc::unbounded_channel().0
499    }
500
501    struct FakeProvider;
502    #[async_trait::async_trait]
503    impl leviath_providers::Provider for FakeProvider {
504        async fn infer(
505            &self,
506            _r: &leviath_providers::InferenceRequest,
507        ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
508            Err(leviath_providers::ProviderError::Other("t".to_string()))
509        }
510        async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
511            1
512        }
513        fn max_context_tokens(&self, _m: &str) -> usize {
514            1000
515        }
516        fn name(&self) -> &str {
517            "fake"
518        }
519        fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
520            leviath_providers::ModelCapabilities::default()
521        }
522    }
523
524    fn test_world() -> (PipelineWorld, Arc<CliToolService>) {
525        let cli = Arc::new(CliToolService::new());
526        let mut registry = ProviderRegistry::new();
527        for p in ["anthropic", "openai", "ollama"] {
528            registry.register(p.to_string(), Arc::new(FakeProvider));
529        }
530        let world = PipelineWorld::new(
531            registry,
532            cli.clone(),
533            InferencePoolConfig::new(),
534            1,
535            None,
536            Handle::current(),
537        );
538        (world, cli)
539    }
540
541    fn coder_manifest() -> String {
542        // Self-contained fixture - not the shipped blueprint (see test_support).
543        crate::test_support::inline_coder_manifest()
544    }
545
546    /// Write a `<runs_dir>/<run_id>/meta.json` (+ optional context.json) for a run
547    /// whose blueprint lives at `agent_path`.
548    fn write_run(
549        runs_dir: &Path,
550        run_id: &str,
551        agent_path: &str,
552        status: RunStatus,
553        context: Option<&ContextSnapshot>,
554    ) {
555        write_run_tree(RunFixture {
556            runs_dir,
557            run_id,
558            agent_path,
559            status,
560            context,
561            parent_run_id: None,
562            children: &[],
563            depth: 0,
564            max_child_depth: 0,
565        });
566    }
567
568    /// Like [`write_run`], but with explicit tree links so recovery's re-linking
569    /// pass can be exercised.
570    /// One persisted run, as a fixture writes it.
571    ///
572    /// A struct because every field is a column of the record being written, and
573    /// a nine-argument call in which three are `&str` and two are `usize` is a
574    /// transposition waiting to happen in a file whose whole job is asserting on
575    /// what got written.
576    struct RunFixture<'a> {
577        runs_dir: &'a Path,
578        run_id: &'a str,
579        agent_path: &'a str,
580        status: RunStatus,
581        context: Option<&'a ContextSnapshot>,
582        parent_run_id: Option<&'a str>,
583        children: &'a [&'a str],
584        depth: usize,
585        max_child_depth: usize,
586    }
587
588    fn write_run_tree(f: RunFixture<'_>) {
589        let RunFixture {
590            runs_dir,
591            run_id,
592            agent_path,
593            status,
594            context,
595            parent_run_id,
596            children,
597            depth,
598            max_child_depth,
599        } = f;
600        let dir = runs_dir.join(run_id);
601        std::fs::create_dir_all(&dir).unwrap();
602        let meta = RunMeta {
603            run_id: run_id.to_string(),
604            agent_name: "coder".to_string(),
605            agent_path: agent_path.to_string(),
606            task: "resume me".to_string(),
607            model: None,
608            pid: 0,
609            status,
610            current_stage: "implement".to_string(),
611            stage_index: 0,
612            num_stages: 1,
613            iteration: 5,
614            prompt_tokens: 42,
615            completion_tokens: 7,
616            cached_tokens: 0,
617            cache_write_tokens: 0,
618            tool_calls: 3,
619            workdir: std::env::temp_dir().to_string_lossy().to_string(),
620            started_at: 111,
621            updated_at: 222,
622            last_progress_at: None,
623            error: None,
624            title: Some("Resume Me".to_string()),
625            metadata: std::collections::HashMap::new(),
626            callback_url: Some("http://cb".to_string()),
627            callback_secret: None,
628            parent_run_id: parent_run_id.map(str::to_string),
629            children: children.iter().map(|s| s.to_string()).collect(),
630            depth,
631            max_child_depth,
632            // Non-default on purpose: proves reload restores the run's
633            // productivity flags rather than starting them over (issue #107).
634            flags: leviath_core::run_meta::RunFlags {
635                modified_files: vec!["src/a.rs".to_string()],
636                modified_file_count: 1,
637                // Contradicts what this manifest would compute on a fresh
638                // spawn (it advertises `write_file`), which is the point: the
639                // flags describe how the run actually executed, so the
640                // persisted answer wins over a re-derived one (issue #192).
641                no_output_tools: true,
642                ..Default::default()
643            },
644            yolo: false,
645            read_paths: None,
646            // Non-default on purpose, like `flags` above: proves a reload puts
647            // the run's answer back rather than dropping it (and then erasing
648            // the copy already on disk at the next persist tick).
649            final_output: Some(
650                leviath_core::output::FinalOutput::new(
651                    "already answered",
652                    Some("markdown".to_string()),
653                    "implement".to_string(),
654                    777,
655                )
656                .descriptor(),
657            ),
658            output_request: Some(leviath_core::output::OutputSpec {
659                format: Some("a2ui".to_string()),
660                ..Default::default()
661            }),
662        };
663        std::fs::write(dir.join("meta.json"), serde_json::to_string(&meta).unwrap()).unwrap();
664        // The answer's bytes live beside the descriptor, so a reload has
665        // something to restore.
666        std::fs::write(
667            dir.join(leviath_core::FINAL_OUTPUT_FILE),
668            "already answered",
669        )
670        .unwrap();
671        if let Some(ctx) = context {
672            std::fs::write(
673                dir.join("context.json"),
674                serde_json::to_string(ctx).unwrap(),
675            )
676            .unwrap();
677        }
678    }
679
680    fn agent_dir() -> tempfile::TempDir {
681        let dir = tempfile::tempdir().unwrap();
682        std::fs::write(dir.path().join("agent.leviath"), coder_manifest()).unwrap();
683        dir
684    }
685
686    /// Write a `<runs_dir>/<run_id>/run.lvr` that folds to the given `stage_index`,
687    /// `iteration`, `prompt_tokens`, and `context` - the run's atomic journal. Used
688    /// to prove recovery prefers this consistent pair over a stale `context.json`.
689    fn write_run_archive(
690        runs_dir: &Path,
691        run_id: &str,
692        agent_path: &str,
693        stage_index: usize,
694        iteration: usize,
695        prompt_tokens: usize,
696        context: &ContextSnapshot,
697    ) {
698        use leviath_core::run_archive::{self, RunIdentity, RunRecord};
699        let dir = runs_dir.join(run_id);
700        std::fs::create_dir_all(&dir).unwrap();
701        let mut meta = RunMeta::new(
702            run_id.to_string(),
703            "coder".to_string(),
704            agent_path.to_string(),
705            "resume me".to_string(),
706            None,
707            std::env::temp_dir().to_string_lossy().to_string(),
708            1,
709        );
710        meta.status = RunStatus::Running;
711        meta.current_stage = "implement".to_string();
712        meta.stage_index = stage_index;
713        meta.iteration = iteration;
714        meta.prompt_tokens = prompt_tokens;
715        let mut buf = Vec::new();
716        run_archive::write_archive_start(&mut buf, run_archive::RUN_ARCHIVE_VERSION).unwrap();
717        run_archive::write_record(
718            &mut buf,
719            &RunRecord::Header {
720                identity: RunIdentity {
721                    run_id: run_id.to_string(),
722                    machine_id: "m".to_string(),
723                    world_id: "w".to_string(),
724                    created_at: 1,
725                },
726                meta: Box::new(meta),
727            },
728        )
729        .unwrap();
730        run_archive::write_record(
731            &mut buf,
732            &RunRecord::ContextCheckpoint {
733                snapshot: context.clone(),
734                at: 2,
735            },
736        )
737        .unwrap();
738        std::fs::write(dir.join("run.lvr"), &buf).unwrap();
739    }
740
741    /// A run the user paused before the restart comes back paused, not the
742    /// default `Active` restore - a daemon restart must not silently resume it.
743    #[tokio::test]
744    async fn reload_keeps_a_paused_run_paused() {
745        let agent = agent_dir();
746        let manifest = agent.path().join("agent.leviath");
747        let runs = tempfile::tempdir().unwrap();
748        write_run(
749            runs.path(),
750            "run-paused",
751            manifest.to_str().unwrap(),
752            RunStatus::Paused,
753            None,
754        );
755
756        let (mut world, cli) = test_world();
757        let hub = InteractionHub::new();
758        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
759        let restored = reload_persisted_agents(
760            &mut world,
761            crate::daemon::spawn::SpawnDeps {
762                tool_service: cli.as_ref(),
763                config: &Config::default(),
764                shared_mcp: mcp,
765                mcp_tool_defs: &[],
766                hub: &hub,
767                now_secs: 999,
768                subagent_tx: sub_tx().clone(),
769            },
770            runs.path(),
771        );
772
773        assert_eq!(restored.len(), 1);
774        let (run_id, entity) = &restored[0];
775        assert_eq!(run_id, "run-paused");
776        assert_eq!(world.agent_status(*entity), Some(AgentStatus::Paused));
777    }
778
779    /// Reload one run from `runs_dir` and hand back the world plus its entity.
780    /// A descriptor with no sidecar beside it is no answer. That is a run
781    /// written before the answer moved out of `meta.json`, or one whose
782    /// directory was pruned, and restoring half of it would be worse than
783    /// restoring none: the next persist tick would write the half back.
784    #[test]
785    fn a_descriptor_without_its_sidecar_restores_nothing() {
786        let dir = tempfile::tempdir().unwrap();
787        let mut meta = RunMeta::new(
788            "run-1".to_string(),
789            "a".to_string(),
790            "/p".to_string(),
791            "t".to_string(),
792            None,
793            "/w".to_string(),
794            1,
795        );
796
797        // No descriptor at all.
798        assert!(read_final_output_from(dir.path(), &meta).is_none());
799
800        // A descriptor, but nothing on disk to go with it.
801        let answer = leviath_core::output::FinalOutput::new(
802            "already answered",
803            Some("markdown".to_string()),
804            "implement".to_string(),
805            777,
806        );
807        meta.final_output = Some(answer.descriptor());
808        assert!(read_final_output_from(dir.path(), &meta).is_none());
809
810        // And with both, the answer comes back whole.
811        std::fs::write(
812            dir.path().join(leviath_core::FINAL_OUTPUT_FILE),
813            &answer.content,
814        )
815        .unwrap();
816        let restored = read_final_output_from(dir.path(), &meta).expect("both halves");
817        assert_eq!(restored.content, "already answered");
818        assert_eq!(restored.stage, "implement");
819    }
820
821    async fn reload_single(runs: &Path, run_id: &str) -> (PipelineWorld, Entity) {
822        let (mut world, cli) = test_world();
823        let restored = reload_persisted_agents(
824            &mut world,
825            crate::daemon::spawn::SpawnDeps {
826                tool_service: cli.as_ref(),
827                config: &Config::default(),
828                shared_mcp: Arc::new(Mutex::new(ToolExecutor::new())),
829                mcp_tool_defs: &[],
830                hub: &InteractionHub::new(),
831                now_secs: 999,
832                subagent_tx: sub_tx().clone(),
833            },
834            runs,
835        );
836        assert_eq!(restored.len(), 1);
837        assert_eq!(restored[0].0, run_id);
838        let entity = restored[0].1;
839        (world, entity.entity())
840    }
841
842    /// An unattended run comes back unattended. Dropping `--yolo` on reload was
843    /// meant as the safe side, but it converted a running unattended job into
844    /// one parked on a prompt nobody was watching for (issue #184).
845    #[tokio::test]
846    async fn reload_keeps_an_unattended_run_unattended() {
847        let agent = agent_dir();
848        let manifest = agent.path().join("agent.leviath");
849        let runs = tempfile::tempdir().unwrap();
850        write_run(
851            runs.path(),
852            "run-yolo",
853            manifest.to_str().unwrap(),
854            RunStatus::Running,
855            None,
856        );
857        // Flip the persisted flag the way a `--yolo` launch would have.
858        let meta_path = runs.path().join("run-yolo").join("meta.json");
859        let mut meta: RunMeta =
860            serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
861        meta.yolo = true;
862        std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
863
864        let (world, entity) = reload_single(runs.path(), "run-yolo").await;
865        assert!(
866            world
867                .world()
868                .get::<RunMetadata>(entity)
869                .expect("reloaded run has metadata")
870                .unattended
871        );
872        assert!(
873            world
874                .world()
875                .get::<leviath_runtime::components::InteractionAutoApprove>(entity)
876                .is_some(),
877            "an unattended reload still auto-approves its checkpoints"
878        );
879    }
880
881    /// A run launched without `--yolo` must not acquire it on reload, and a
882    /// `meta.json` written before the field existed reads as attended.
883    #[tokio::test]
884    async fn reload_does_not_invent_unattended() {
885        let agent = agent_dir();
886        let manifest = agent.path().join("agent.leviath");
887        let runs = tempfile::tempdir().unwrap();
888        write_run(
889            runs.path(),
890            "run-plain",
891            manifest.to_str().unwrap(),
892            RunStatus::Running,
893            None,
894        );
895        // Strip the field entirely: exactly what an older binary wrote.
896        let meta_path = runs.path().join("run-plain").join("meta.json");
897        let mut raw: serde_json::Value =
898            serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
899        raw.as_object_mut().unwrap().remove("yolo");
900        std::fs::write(&meta_path, serde_json::to_string(&raw).unwrap()).unwrap();
901
902        let (world, entity) = reload_single(runs.path(), "run-plain").await;
903        assert!(
904            !world
905                .world()
906                .get::<RunMetadata>(entity)
907                .expect("reloaded run has metadata")
908                .unattended
909        );
910    }
911
912    #[tokio::test]
913    async fn reloads_nonterminal_runs_and_restores_state() {
914        let agent = agent_dir();
915        let manifest = agent.path().join("agent.leviath");
916        let runs = tempfile::tempdir().unwrap();
917
918        // A running snapshot with real context.
919        let ctx = ContextSnapshot {
920            stage_name: "implement".to_string(),
921            total_tokens: 4,
922            max_tokens: 100_000,
923            regions: vec![leviath_core::run_meta::RegionSnapshot {
924                name: "conversation".to_string(),
925                kind: "clearable".to_string(),
926                current_tokens: 4,
927                max_tokens: 100_000,
928                entries: vec![leviath_core::run_meta::RegionEntrySnapshot {
929                    content: "earlier turn".to_string(),
930                    tokens: 4,
931                    kind: leviath_core::region::EntryKind::UserMessage,
932                    metadata: None,
933                    key: None,
934                    taint: Default::default(),
935                }],
936            }],
937        };
938        write_run(
939            runs.path(),
940            "run-live",
941            manifest.to_str().unwrap(),
942            RunStatus::Running,
943            Some(&ctx),
944        );
945        // A completed run - must be skipped.
946        write_run(
947            runs.path(),
948            "run-done",
949            manifest.to_str().unwrap(),
950            RunStatus::Complete,
951            None,
952        );
953
954        let (mut world, cli) = test_world();
955        let hub = InteractionHub::new();
956        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
957        let restored = reload_persisted_agents(
958            &mut world,
959            crate::daemon::spawn::SpawnDeps {
960                tool_service: cli.as_ref(),
961                config: &Config::default(),
962                shared_mcp: mcp,
963                mcp_tool_defs: &[],
964                hub: &hub,
965                now_secs: 999,
966                subagent_tx: sub_tx().clone(),
967            },
968            runs.path(),
969        );
970
971        assert_eq!(restored.len(), 1);
972        let (run_id, entity) = &restored[0];
973        assert_eq!(run_id, "run-live");
974        assert_eq!(world.agent_status(*entity), Some(AgentStatus::Active));
975        // Iteration + preserved metadata restored.
976        let md = world.world().get::<RunMetadata>(entity.entity()).unwrap();
977        assert_eq!(md.started_at, 111);
978        assert_eq!(md.title.as_deref(), Some("Resume Me"));
979        assert_eq!(md.callback_url.as_deref(), Some("http://cb"));
980        let totals = world.world().get::<TokenTotals>(entity.entity()).unwrap();
981        assert_eq!(totals.prompt_tokens, 42);
982        assert_eq!(totals.tool_calls, 3);
983        // ...as are the run's productivity flags, so a resumed run doesn't report
984        // itself as having modified nothing.
985        let flags = world
986            .world()
987            .get::<leviath_runtime::persistence::RunOutcomeFlags>(entity.entity())
988            .unwrap();
989        assert_eq!(flags.0.modified_files, vec!["src/a.rs".to_string()]);
990        assert_eq!(flags.0.modified_file_count, 1);
991        // Including the capability answer, which the blueprint on disk would
992        // now compute differently - the run is judged as it ran (issue #192).
993        assert!(flags.0.no_output_tools);
994        // The answer the run had already given is put back on the entity. Were
995        // it not, the next persist tick would write a meta.json without it and
996        // erase the copy already on disk.
997        let output = world
998            .world()
999            .get::<leviath_runtime::persistence::FinalOutput>(entity.entity())
1000            .expect("a submitted answer survives the restart");
1001        assert_eq!(output.0.content, "already answered");
1002        assert_eq!(output.0.stage, "implement");
1003        // As does the shape the caller asked for at launch, so the resumed run
1004        // does not silently revert to the blueprint's partway through.
1005        assert_eq!(
1006            md.output_request.as_ref().and_then(|s| s.format.as_deref()),
1007            Some("a2ui")
1008        );
1009    }
1010
1011    /// Fresh + stale differ on every observable field, so the assertions below
1012    /// pin down exactly which source recovery restored from.
1013    fn assert_restored_from_archive(world: &PipelineWorld, entity: Entity) {
1014        use leviath_runtime::components::AgentState;
1015        let state = world.world().get::<AgentState>(entity).unwrap();
1016        // stage_name comes from the archive's context (not the stale context.json),
1017        // iteration from the archive's meta (not meta.json's 5).
1018        assert_eq!(state.current_stage, "fresh-stage");
1019        assert_eq!(state.iteration, 9);
1020        // token totals come from the archive's meta (not meta.json's 42).
1021        let totals = world.world().get::<TokenTotals>(entity).unwrap();
1022        assert_eq!(totals.prompt_tokens, 99);
1023    }
1024
1025    /// Torn-snapshot pairing: when the atomic journal (`run.lvr`) and the separate
1026    /// `context.json` disagree - the crash-window state where a new `meta.json`
1027    /// sits next to a stale `context.json` - resume restores the journal's
1028    /// consistent `{meta, context}` pair, not the stale JSON.
1029    #[tokio::test]
1030    async fn reload_prefers_the_atomic_journal_over_a_stale_context_json() {
1031        let agent = agent_dir();
1032        let manifest = agent.path().join("agent.leviath");
1033        let mpath = manifest.to_str().unwrap();
1034        let runs = tempfile::tempdir().unwrap();
1035
1036        // A STALE context.json (older generation) alongside a meta.json whose
1037        // iteration/totals are also older than the journal - write_run stamps
1038        // iteration 5 / prompt_tokens 42.
1039        let stale = ContextSnapshot {
1040            stage_name: "stale-stage".to_string(),
1041            total_tokens: 1,
1042            max_tokens: 100,
1043            regions: vec![],
1044        };
1045        write_run(
1046            runs.path(),
1047            "run-torn",
1048            mpath,
1049            RunStatus::Running,
1050            Some(&stale),
1051        );
1052        // The journal at the newer generation: iteration 9, prompt_tokens 99,
1053        // context stage "fresh-stage".
1054        let fresh = ContextSnapshot {
1055            stage_name: "fresh-stage".to_string(),
1056            total_tokens: 4,
1057            max_tokens: 100_000,
1058            regions: vec![],
1059        };
1060        write_run_archive(runs.path(), "run-torn", mpath, 0, 9, 99, &fresh);
1061
1062        let (mut world, cli) = test_world();
1063        let hub = InteractionHub::new();
1064        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1065        let restored = reload_persisted_agents(
1066            &mut world,
1067            crate::daemon::spawn::SpawnDeps {
1068                tool_service: cli.as_ref(),
1069                config: &Config::default(),
1070                shared_mcp: mcp,
1071                mcp_tool_defs: &[],
1072                hub: &hub,
1073                now_secs: 999,
1074                subagent_tx: sub_tx().clone(),
1075            },
1076            runs.path(),
1077        );
1078
1079        assert_eq!(restored.len(), 1);
1080        assert_restored_from_archive(&world, restored[0].1.entity());
1081    }
1082
1083    /// A crash *during* the journal append can leave a torn trailing frame. Recovery
1084    /// reads the journal leniently, so the valid prefix still resolves the resume
1085    /// state (rather than silently falling back to the possibly-mismatched JSON).
1086    #[tokio::test]
1087    async fn reload_tolerates_a_torn_journal_tail() {
1088        let agent = agent_dir();
1089        let manifest = agent.path().join("agent.leviath");
1090        let mpath = manifest.to_str().unwrap();
1091        let runs = tempfile::tempdir().unwrap();
1092
1093        let stale = ContextSnapshot {
1094            stage_name: "stale-stage".to_string(),
1095            total_tokens: 1,
1096            max_tokens: 100,
1097            regions: vec![],
1098        };
1099        write_run(
1100            runs.path(),
1101            "run-torn2",
1102            mpath,
1103            RunStatus::Running,
1104            Some(&stale),
1105        );
1106        let fresh = ContextSnapshot {
1107            stage_name: "fresh-stage".to_string(),
1108            total_tokens: 4,
1109            max_tokens: 100_000,
1110            regions: vec![],
1111        };
1112        write_run_archive(runs.path(), "run-torn2", mpath, 0, 9, 99, &fresh);
1113        // Append a torn frame (length prefix promising bytes that aren't there).
1114        {
1115            use std::io::Write;
1116            let mut f = std::fs::OpenOptions::new()
1117                .append(true)
1118                .open(runs.path().join("run-torn2/run.lvr"))
1119                .unwrap();
1120            f.write_all(&[0, 0, 0, 0, 0, 0, 0, 10, 1, 2]).unwrap();
1121        }
1122
1123        let (mut world, cli) = test_world();
1124        let hub = InteractionHub::new();
1125        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1126        let restored = reload_persisted_agents(
1127            &mut world,
1128            crate::daemon::spawn::SpawnDeps {
1129                tool_service: cli.as_ref(),
1130                config: &Config::default(),
1131                shared_mcp: mcp,
1132                mcp_tool_defs: &[],
1133                hub: &hub,
1134                now_secs: 999,
1135                subagent_tx: sub_tx().clone(),
1136            },
1137            runs.path(),
1138        );
1139
1140        assert_eq!(restored.len(), 1);
1141        // The valid prefix folds → resume still uses the journal's fresh state.
1142        assert_restored_from_archive(&world, restored[0].1.entity());
1143    }
1144
1145    /// Append raw journal records to an existing `run.lvr`, the way the live
1146    /// lane journals a batch dispatch and its per-call completions.
1147    fn append_archive_records(
1148        runs_dir: &Path,
1149        run_id: &str,
1150        records: &[leviath_core::run_archive::RunRecord],
1151    ) {
1152        use std::io::Write;
1153        let mut buf = Vec::new();
1154        for r in records {
1155            leviath_core::run_archive::write_record(&mut buf, r).unwrap();
1156        }
1157        let mut f = std::fs::OpenOptions::new()
1158            .append(true)
1159            .open(runs_dir.join(run_id).join("run.lvr"))
1160            .unwrap();
1161        f.write_all(&buf).unwrap();
1162    }
1163
1164    fn batch_call(
1165        id: &str,
1166        name: &str,
1167        result: Option<&str>,
1168    ) -> leviath_core::run_archive::ToolCallRecord {
1169        leviath_core::run_archive::ToolCallRecord {
1170            id: id.to_string(),
1171            name: name.to_string(),
1172            arguments: "{}".to_string(),
1173            result: result.map(str::to_string),
1174            thought_signature: None,
1175        }
1176    }
1177
1178    /// The conversation entries of a reloaded agent's window.
1179    fn conversation_of(world: &PipelineWorld, entity: Entity) -> Vec<leviath_core::RegionEntry> {
1180        world
1181            .world()
1182            .get::<leviath_runtime::components::ContextWindow>(entity)
1183            .unwrap()
1184            .get_region("conversation")
1185            .unwrap()
1186            .content
1187            .clone()
1188    }
1189
1190    /// The #96 crash-resume path end to end: a batch was dispatched (journaled),
1191    /// one call completed (journaled), one didn't, and the daemon died before
1192    /// the batch applied. Reload replays the recorded result and synthesizes a
1193    /// verify-first error for the lost one - and the agent re-infers from there
1194    /// instead of re-executing the batch.
1195    #[tokio::test]
1196    async fn reload_replays_a_pending_tool_batch_instead_of_reexecuting() {
1197        use leviath_core::run_archive::RunRecord;
1198        let agent = agent_dir();
1199        let manifest = agent.path().join("agent.leviath");
1200        let mpath = manifest.to_str().unwrap();
1201        let runs = tempfile::tempdir().unwrap();
1202
1203        write_run(runs.path(), "run-batch", mpath, RunStatus::Running, None);
1204        let ctx = ContextSnapshot {
1205            stage_name: "implement".to_string(),
1206            total_tokens: 0,
1207            max_tokens: 100_000,
1208            regions: vec![],
1209        };
1210        write_run_archive(runs.path(), "run-batch", mpath, 0, 9, 99, &ctx);
1211        append_archive_records(
1212            runs.path(),
1213            "run-batch",
1214            &[
1215                RunRecord::ToolBatch {
1216                    calls: vec![
1217                        batch_call("c_done", "write_file", None),
1218                        batch_call("c_lost", "shell", None),
1219                    ],
1220                    at: 3,
1221                    stage_index: 0,
1222                    iteration: 9,
1223                    response: "writing then running".to_string(),
1224                },
1225                RunRecord::ToolCallDone {
1226                    iteration: 9,
1227                    call_id: "c_done".to_string(),
1228                    result: "Wrote 42 bytes to x.txt".to_string(),
1229                    at: 4,
1230                },
1231            ],
1232        );
1233
1234        let (mut world, cli) = test_world();
1235        let hub = InteractionHub::new();
1236        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1237        let restored = reload_persisted_agents(
1238            &mut world,
1239            crate::daemon::spawn::SpawnDeps {
1240                tool_service: cli.as_ref(),
1241                config: &Config::default(),
1242                shared_mcp: mcp,
1243                mcp_tool_defs: &[],
1244                hub: &hub,
1245                now_secs: 999,
1246                subagent_tx: sub_tx().clone(),
1247            },
1248            runs.path(),
1249        );
1250
1251        assert_eq!(restored.len(), 1);
1252        let entity = restored[0].1;
1253        let entries = conversation_of(&world, entity.entity());
1254        // The assistant turn landed with both calls...
1255        assert!(entries.iter().any(|e| matches!(
1256            &e.kind,
1257            leviath_core::region::EntryKind::AssistantTurn { tool_calls } if tool_calls.len() == 2
1258        )));
1259        // ...the completed call keeps its real journaled result...
1260        assert!(
1261            entries
1262                .iter()
1263                .any(|e| e.content == "Wrote 42 bytes to x.txt")
1264        );
1265        // ...and the lost call gets the verify-first synthesis.
1266        assert!(entries.iter().any(|e| e.content.contains("interrupted")
1267            && e.content.contains("Verify whether it took effect")));
1268        // The agent re-infers from the reconstructed window.
1269        assert!(
1270            world
1271                .world()
1272                .get::<leviath_runtime::pipeline::ReadyToInfer>(entity.entity())
1273                .is_some()
1274        );
1275    }
1276
1277    /// The batch's assistant turn already reached the persisted window before
1278    /// the crash (apply_tool_results ran; the Progress record landed): fold
1279    /// clears the pending batch, so reload appends nothing a second time.
1280    #[tokio::test]
1281    async fn reload_does_not_replay_a_batch_already_in_the_window() {
1282        use leviath_core::region::EntryKind;
1283        use leviath_core::run_archive::RunRecord;
1284        let agent = agent_dir();
1285        let manifest = agent.path().join("agent.leviath");
1286        let mpath = manifest.to_str().unwrap();
1287        let runs = tempfile::tempdir().unwrap();
1288
1289        write_run(runs.path(), "run-applied", mpath, RunStatus::Running, None);
1290        // The archived window already holds the batch's turn + paired result.
1291        let ctx = ContextSnapshot {
1292            stage_name: "implement".to_string(),
1293            total_tokens: 2,
1294            max_tokens: 100_000,
1295            regions: vec![leviath_core::run_meta::RegionSnapshot {
1296                name: "conversation".to_string(),
1297                kind: "clearable".to_string(),
1298                current_tokens: 2,
1299                max_tokens: 100_000,
1300                entries: vec![
1301                    leviath_core::run_meta::RegionEntrySnapshot {
1302                        content: "done".to_string(),
1303                        tokens: 1,
1304                        kind: EntryKind::AssistantTurn {
1305                            tool_calls: vec![leviath_core::region::SerializedToolCall {
1306                                id: "c1".to_string(),
1307                                name: "write_file".to_string(),
1308                                arguments: serde_json::Value::Null,
1309                                thought_signature: None,
1310                            }],
1311                        },
1312                        metadata: None,
1313                        key: None,
1314                        taint: Default::default(),
1315                    },
1316                    leviath_core::run_meta::RegionEntrySnapshot {
1317                        content: "Wrote it".to_string(),
1318                        tokens: 1,
1319                        kind: EntryKind::ToolResult {
1320                            tool_call_id: "c1".to_string(),
1321                            tool_name: "write_file".to_string(),
1322                            is_error: false,
1323                        },
1324                        metadata: None,
1325                        key: None,
1326                        taint: Default::default(),
1327                    },
1328                ],
1329            }],
1330        };
1331        write_run_archive(runs.path(), "run-applied", mpath, 0, 9, 99, &ctx);
1332        append_archive_records(
1333            runs.path(),
1334            "run-applied",
1335            &[RunRecord::ToolBatch {
1336                calls: vec![batch_call("c1", "write_file", None)],
1337                at: 3,
1338                stage_index: 0,
1339                iteration: 9,
1340                response: "done".to_string(),
1341            }],
1342        );
1343
1344        let (mut world, cli) = test_world();
1345        let hub = InteractionHub::new();
1346        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1347        let restored = reload_persisted_agents(
1348            &mut world,
1349            crate::daemon::spawn::SpawnDeps {
1350                tool_service: cli.as_ref(),
1351                config: &Config::default(),
1352                shared_mcp: mcp,
1353                mcp_tool_defs: &[],
1354                hub: &hub,
1355                now_secs: 999,
1356                subagent_tx: sub_tx().clone(),
1357            },
1358            runs.path(),
1359        );
1360
1361        assert_eq!(restored.len(), 1);
1362        let entries = conversation_of(&world, restored[0].1.entity());
1363        // Exactly the persisted turn - no second copy, no interrupted synthesis.
1364        assert_eq!(
1365            entries
1366                .iter()
1367                .filter(|e| matches!(&e.kind, EntryKind::AssistantTurn { tool_calls } if !tool_calls.is_empty()))
1368                .count(),
1369            1
1370        );
1371        assert!(!entries.iter().any(|e| e.content.contains("interrupted")));
1372    }
1373
1374    /// A temp agent dir holding the interactive planning manifest, whose stage 0
1375    /// (`plan`) is an `interactive_points` stage with a `plan_approval` point.
1376    fn interactive_agent_dir() -> tempfile::TempDir {
1377        let dir = tempfile::tempdir().unwrap();
1378        std::fs::write(
1379            dir.path().join("agent.leviath"),
1380            crate::test_support::inline_interactive_manifest(),
1381        )
1382        .unwrap();
1383        dir
1384    }
1385
1386    #[tokio::test]
1387    async fn reload_resumes_a_blocked_interaction_point_in_the_waiting_state() {
1388        let agent = interactive_agent_dir();
1389        let manifest = agent.path().join("agent.leviath");
1390        let runs = tempfile::tempdir().unwrap();
1391
1392        // A run parked at the plan_approval interaction point (stage 0 = plan)...
1393        write_run(
1394            runs.path(),
1395            "run-await",
1396            manifest.to_str().unwrap(),
1397            RunStatus::WaitingInput,
1398            None,
1399        );
1400        // ...plus the interaction sidecar the daemon wrote while it was blocked.
1401        std::fs::write(
1402            runs.path().join("run-await/interactions.json"),
1403            serde_json::to_string(&InteractionPointState {
1404                cursor: 0,
1405                round: 0,
1406                body: "## Plan\n1. do it".to_string(),
1407            })
1408            .unwrap(),
1409        )
1410        .unwrap();
1411
1412        let (mut world, cli) = test_world();
1413        let hub = InteractionHub::new();
1414        world.insert_interaction_hub(hub.clone()); // restore reads the hub resource
1415        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1416        let restored = reload_persisted_agents(
1417            &mut world,
1418            crate::daemon::spawn::SpawnDeps {
1419                tool_service: cli.as_ref(),
1420                config: &Config::default(),
1421                shared_mcp: mcp,
1422                mcp_tool_defs: &[],
1423                hub: &hub,
1424                now_secs: 999,
1425                subagent_tx: sub_tx().clone(),
1426            },
1427            runs.path(),
1428        );
1429
1430        assert_eq!(restored.len(), 1);
1431        let (run_id, entity) = &restored[0];
1432        assert_eq!(run_id, "run-await");
1433        // Re-armed in the *waiting* state (not the default Active), so no inference
1434        // re-issues and the open prompt isn't dropped - the issue #38 fix.
1435        assert_eq!(world.agent_status(*entity), Some(AgentStatus::Waiting));
1436        assert!(
1437            world
1438                .world()
1439                .get::<leviath_runtime::interaction_points::AwaitingInteractionPoint>(
1440                    entity.entity()
1441                )
1442                .is_some()
1443        );
1444        assert!(
1445            world
1446                .world()
1447                .get::<leviath_runtime::pipeline::ReadyToInfer>(entity.entity())
1448                .is_none(),
1449            "the spawn-set ReadyToInfer is cleared so the inference lane won't fire"
1450        );
1451
1452        // The prompt was re-opened in the hub, carrying the reviewed plan.
1453        for _ in 0..8 {
1454            tokio::task::yield_now().await;
1455        }
1456        let pending = hub.pending();
1457        assert_eq!(pending.len(), 1);
1458        assert_eq!(pending[0].0, "run-await");
1459        assert_eq!(pending[0].1.body.as_deref(), Some("## Plan\n1. do it"));
1460    }
1461
1462    #[tokio::test]
1463    async fn reload_restores_actionable_runs_before_blocked_and_skips_terminal() {
1464        let agent = agent_dir();
1465        let mpath = agent.path().join("agent.leviath");
1466        let mpath = mpath.to_str().unwrap();
1467        let runs = tempfile::tempdir().unwrap();
1468        // Directory iteration order is unspecified; name the blocked run so it would
1469        // sort ahead alphabetically, proving the triage (not the filesystem) decides.
1470        write_run(
1471            runs.path(),
1472            "aaa-blocked",
1473            mpath,
1474            RunStatus::WaitingInput,
1475            None,
1476        );
1477        write_run(runs.path(), "zzz-active", mpath, RunStatus::Running, None);
1478        write_run(runs.path(), "mmm-done", mpath, RunStatus::Complete, None);
1479
1480        let (mut world, cli) = test_world();
1481        let hub = InteractionHub::new();
1482        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1483        let restored = reload_persisted_agents(
1484            &mut world,
1485            crate::daemon::spawn::SpawnDeps {
1486                tool_service: cli.as_ref(),
1487                config: &Config::default(),
1488                shared_mcp: mcp,
1489                mcp_tool_defs: &[],
1490                hub: &hub,
1491                now_secs: 999,
1492                subagent_tx: sub_tx().clone(),
1493            },
1494            runs.path(),
1495        );
1496
1497        // Terminal run skipped; the actionable (Running) run is restored first.
1498        let order: Vec<&str> = restored.iter().map(|(id, _)| id.as_str()).collect();
1499        assert_eq!(order, vec!["zzz-active", "aaa-blocked"]);
1500    }
1501
1502    #[tokio::test]
1503    async fn reload_run_pages_in_nonterminal_only() {
1504        let agent = agent_dir();
1505        let manifest = agent.path().join("agent.leviath");
1506        let mpath = manifest.to_str().unwrap();
1507        let runs = tempfile::tempdir().unwrap();
1508        write_run(runs.path(), "live", mpath, RunStatus::Running, None);
1509        write_run(runs.path(), "done", mpath, RunStatus::Complete, None);
1510
1511        let (mut world, cli) = test_world();
1512        let hub = InteractionHub::new();
1513        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1514
1515        // A non-terminal run is paged in.
1516        assert!(
1517            reload_run(
1518                &mut world,
1519                crate::daemon::spawn::SpawnDeps {
1520                    tool_service: cli.as_ref(),
1521                    config: &Config::default(),
1522                    shared_mcp: mcp.clone(),
1523                    mcp_tool_defs: &[],
1524                    hub: &hub,
1525                    now_secs: 1,
1526                    subagent_tx: sub_tx().clone(),
1527                },
1528                "live",
1529                runs.path(),
1530            )
1531            .is_some()
1532        );
1533        // A terminal run is not.
1534        assert!(
1535            reload_run(
1536                &mut world,
1537                crate::daemon::spawn::SpawnDeps {
1538                    tool_service: cli.as_ref(),
1539                    config: &Config::default(),
1540                    shared_mcp: mcp.clone(),
1541                    mcp_tool_defs: &[],
1542                    hub: &hub,
1543                    now_secs: 1,
1544                    subagent_tx: sub_tx().clone(),
1545                },
1546                "done",
1547                runs.path(),
1548            )
1549            .is_none()
1550        );
1551        // A run with no meta on disk is not.
1552        assert!(
1553            reload_run(
1554                &mut world,
1555                crate::daemon::spawn::SpawnDeps {
1556                    tool_service: cli.as_ref(),
1557                    config: &Config::default(),
1558                    shared_mcp: mcp,
1559                    mcp_tool_defs: &[],
1560                    hub: &hub,
1561                    now_secs: 1,
1562                    subagent_tx: sub_tx().clone(),
1563                },
1564                "no-such-run",
1565                runs.path(),
1566            )
1567            .is_none()
1568        );
1569    }
1570
1571    #[tokio::test]
1572    async fn resumes_a_parent_parked_mid_fan_out() {
1573        use leviath_core::blueprint::{FanOutConfig, WorkerFailurePolicy};
1574        use leviath_runtime::fanout::{FanOutState, FanOutWaiting};
1575
1576        let agent = agent_dir();
1577        let manifest = agent.path().join("agent.leviath");
1578        let mpath = manifest.to_str().unwrap();
1579        let runs = tempfile::tempdir().unwrap();
1580
1581        // A parent parked mid fan-out: a valid fanout.json alongside its meta.
1582        write_run(
1583            runs.path(),
1584            "parent-fo",
1585            mpath,
1586            RunStatus::WaitingInput,
1587            None,
1588        );
1589        let state = FanOutState {
1590            config: FanOutConfig {
1591                worker_agent: None,
1592                worker_stage: Some("w".to_string()),
1593                worker_query: None,
1594                merge_stage: None,
1595                max_workers: 1,
1596                on_worker_failure: WorkerFailurePolicy::Continue,
1597                split_prompt: "s".to_string(),
1598                results_region: None,
1599                max_items: None,
1600            },
1601            max_workers: 1,
1602            pending: vec![],
1603            // One in-flight worker, referenced by the run-id of another reloaded
1604            // run so the resolver maps it back to an entity on restore.
1605            active: vec![("item-1".to_string(), "worker-fo".to_string())],
1606            summaries: vec![],
1607            failures: vec![],
1608        };
1609        std::fs::write(
1610            runs.path().join("parent-fo").join("fanout.json"),
1611            serde_json::to_string(&state).unwrap(),
1612        )
1613        .unwrap();
1614        // The referenced worker run, so the active worker re-links to a real entity.
1615        write_run(runs.path(), "worker-fo", mpath, RunStatus::Running, None);
1616
1617        // A run with a malformed fanout.json → skipped (no FanOutWaiting).
1618        write_run(runs.path(), "bad-fo", mpath, RunStatus::WaitingInput, None);
1619        std::fs::write(runs.path().join("bad-fo").join("fanout.json"), b"garbage").unwrap();
1620
1621        let (mut world, cli) = test_world();
1622        let hub = InteractionHub::new();
1623        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1624        let restored = reload_persisted_agents(
1625            &mut world,
1626            crate::daemon::spawn::SpawnDeps {
1627                tool_service: cli.as_ref(),
1628                config: &Config::default(),
1629                shared_mcp: mcp,
1630                mcp_tool_defs: &[],
1631                hub: &hub,
1632                now_secs: 999,
1633                subagent_tx: sub_tx().clone(),
1634            },
1635            runs.path(),
1636        );
1637        let by_id: std::collections::HashMap<_, _> =
1638            restored.iter().map(|(r, e)| (r.clone(), *e)).collect();
1639
1640        // The parent's fan-out waiting state was rebuilt; the malformed one wasn't.
1641        assert!(
1642            world
1643                .world()
1644                .get::<FanOutWaiting>(by_id["parent-fo"].entity())
1645                .is_some()
1646        );
1647        assert!(
1648            world
1649                .world()
1650                .get::<FanOutWaiting>(by_id["bad-fo"].entity())
1651                .is_none()
1652        );
1653    }
1654
1655    #[tokio::test]
1656    async fn rebuilds_parent_child_tree_on_reload() {
1657        use leviath_runtime::components::{ParentRef, SubAgentChildren};
1658
1659        let agent = agent_dir();
1660        let manifest = agent.path().join("agent.leviath");
1661        let mpath = manifest.to_str().unwrap();
1662        let runs = tempfile::tempdir().unwrap();
1663
1664        // A parent with two children + a child that records its parent + depth.
1665        write_run_tree(RunFixture {
1666            runs_dir: runs.path(),
1667            run_id: "parent",
1668            agent_path: mpath,
1669            status: RunStatus::WaitingInput,
1670            context: None,
1671            parent_run_id: None,
1672            children: &["child-a", "child-b"],
1673            depth: 0,
1674            max_child_depth: 4,
1675        });
1676        write_run_tree(RunFixture {
1677            runs_dir: runs.path(),
1678            run_id: "child-a",
1679            agent_path: mpath,
1680            status: RunStatus::Running,
1681            context: None,
1682            parent_run_id: Some("parent"),
1683            children: &[],
1684            depth: 1,
1685            max_child_depth: 0,
1686        });
1687        write_run_tree(RunFixture {
1688            runs_dir: runs.path(),
1689            run_id: "child-b",
1690            agent_path: mpath,
1691            status: RunStatus::Running,
1692            context: None,
1693            parent_run_id: Some("parent"),
1694            children: &[],
1695            depth: 1,
1696            max_child_depth: 0,
1697        });
1698
1699        let (mut world, cli) = test_world();
1700        let hub = InteractionHub::new();
1701        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1702        let restored = reload_persisted_agents(
1703            &mut world,
1704            crate::daemon::spawn::SpawnDeps {
1705                tool_service: cli.as_ref(),
1706                config: &Config::default(),
1707                shared_mcp: mcp,
1708                mcp_tool_defs: &[],
1709                hub: &hub,
1710                now_secs: 999,
1711                subagent_tx: sub_tx().clone(),
1712            },
1713            runs.path(),
1714        );
1715        assert_eq!(restored.len(), 3);
1716        let by_id: std::collections::HashMap<_, _> =
1717            restored.iter().map(|(r, e)| (r.clone(), *e)).collect();
1718        let parent = by_id["parent"];
1719        let child_a = by_id["child-a"];
1720        let child_b = by_id["child-b"];
1721
1722        // Parent's SubAgentChildren rebuilt with both children + the depth cap.
1723        let kids = world
1724            .world()
1725            .get::<SubAgentChildren>(parent.entity())
1726            .unwrap();
1727        assert_eq!(kids.max_child_depth, 4);
1728        assert_eq!(kids.children.len(), 2);
1729        assert!(
1730            kids.children.contains(&child_a.entity()) && kids.children.contains(&child_b.entity())
1731        );
1732        // Each child's ParentRef points back at the parent, at its stored depth.
1733        let pr = world.world().get::<ParentRef>(child_a.entity()).unwrap();
1734        assert_eq!(pr.parent_entity, parent.entity());
1735        assert_eq!(pr.parent_agent_id, "parent");
1736        assert_eq!(pr.depth, 1);
1737        // The serializable child list is kept in sync for the next snapshot.
1738        let state = world
1739            .world()
1740            .get::<leviath_runtime::components::AgentState>(parent.entity())
1741            .unwrap();
1742        assert_eq!(state.spawned_children_ids, vec!["child-a", "child-b"]);
1743    }
1744
1745    #[tokio::test]
1746    async fn relink_skips_children_and_parents_that_did_not_reload() {
1747        use leviath_runtime::components::{ParentRef, SubAgentChildren};
1748
1749        let agent = agent_dir();
1750        let manifest = agent.path().join("agent.leviath");
1751        let mpath = manifest.to_str().unwrap();
1752        let runs = tempfile::tempdir().unwrap();
1753
1754        // Parent lists a child that is terminal (won't reload) → no SubAgentChildren.
1755        write_run_tree(RunFixture {
1756            runs_dir: runs.path(),
1757            run_id: "lonely-parent",
1758            agent_path: mpath,
1759            status: RunStatus::WaitingInput,
1760            context: None,
1761            parent_run_id: None,
1762            children: &["gone-child"],
1763            depth: 0,
1764            max_child_depth: 2,
1765        });
1766        write_run_tree(RunFixture {
1767            runs_dir: runs.path(),
1768            run_id: "gone-child",
1769            agent_path: mpath,
1770            status: RunStatus::Complete,
1771            context: // terminal → skipped by recovery
1772            None,
1773            parent_run_id: Some("lonely-parent"),
1774            children: &[],
1775            depth: 1,
1776            max_child_depth: 0,
1777        });
1778        // Child whose parent is terminal (won't reload) → left unlinked.
1779        write_run_tree(RunFixture {
1780            runs_dir: runs.path(),
1781            run_id: "orphan",
1782            agent_path: mpath,
1783            status: RunStatus::Running,
1784            context: None,
1785            parent_run_id: Some("gone-parent"),
1786            children: &[],
1787            depth: 1,
1788            max_child_depth: 0,
1789        });
1790        write_run_tree(RunFixture {
1791            runs_dir: runs.path(),
1792            run_id: "gone-parent",
1793            agent_path: mpath,
1794            status: RunStatus::Error,
1795            context: None,
1796            parent_run_id: None,
1797            children: &["orphan"],
1798            depth: 0,
1799            max_child_depth: 2,
1800        });
1801
1802        let (mut world, cli) = test_world();
1803        let hub = InteractionHub::new();
1804        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1805        let restored = reload_persisted_agents(
1806            &mut world,
1807            crate::daemon::spawn::SpawnDeps {
1808                tool_service: cli.as_ref(),
1809                config: &Config::default(),
1810                shared_mcp: mcp,
1811                mcp_tool_defs: &[],
1812                hub: &hub,
1813                now_secs: 999,
1814                subagent_tx: sub_tx().clone(),
1815            },
1816            runs.path(),
1817        );
1818        // Only the two non-terminal runs reload.
1819        assert_eq!(restored.len(), 2);
1820        let by_id: std::collections::HashMap<_, _> =
1821            restored.iter().map(|(r, e)| (r.clone(), *e)).collect();
1822        // Parent listed a child that didn't reload → no SubAgentChildren attached.
1823        assert!(
1824            world
1825                .world()
1826                .get::<SubAgentChildren>(by_id["lonely-parent"].entity())
1827                .is_none()
1828        );
1829        // Orphan's parent didn't reload → no ParentRef attached.
1830        assert!(
1831            world
1832                .world()
1833                .get::<ParentRef>(by_id["orphan"].entity())
1834                .is_none()
1835        );
1836    }
1837
1838    #[tokio::test]
1839    async fn reload_without_context_json_still_resumes() {
1840        let agent = agent_dir();
1841        let manifest = agent.path().join("agent.leviath");
1842        let runs = tempfile::tempdir().unwrap();
1843        write_run(
1844            runs.path(),
1845            "run-nocontext",
1846            manifest.to_str().unwrap(),
1847            RunStatus::WaitingInput,
1848            None, // no context.json
1849        );
1850
1851        let (mut world, cli) = test_world();
1852        let hub = InteractionHub::new();
1853        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1854        let restored = reload_persisted_agents(
1855            &mut world,
1856            crate::daemon::spawn::SpawnDeps {
1857                tool_service: cli.as_ref(),
1858                config: &Config::default(),
1859                shared_mcp: mcp,
1860                mcp_tool_defs: &[],
1861                hub: &hub,
1862                now_secs: 999,
1863                subagent_tx: sub_tx().clone(),
1864            },
1865            runs.path(),
1866        );
1867        assert_eq!(restored.len(), 1);
1868        assert!(
1869            world
1870                .world()
1871                .get::<TokenTotals>(restored[0].1.entity())
1872                .is_some()
1873        );
1874    }
1875
1876    /// A restart used to bring every stage back at zero: nothing rebuilt the
1877    /// ledger from `stages.json`, so the blueprint-seeded (all-zero) one was
1878    /// written straight over the real file on the next persist tick, and the
1879    /// run's whole per-stage history went with it while `meta.json` still
1880    /// looked healthy (issue #415).
1881    #[tokio::test]
1882    async fn reload_restores_the_persisted_stage_ledger() {
1883        use leviath_core::run_meta::{StageRecord, StageRunStatus};
1884        use leviath_runtime::pipeline::StageLedger;
1885
1886        let agent = agent_dir();
1887        let manifest = agent.path().join("agent.leviath");
1888        let runs = tempfile::tempdir().unwrap();
1889        write_run(
1890            runs.path(),
1891            "run-stages",
1892            manifest.to_str().unwrap(),
1893            RunStatus::Running,
1894            None,
1895        );
1896        // The ledger as the run left it: `analyze` ran and finished, the run
1897        // stopped in `implement`, `review` was never reached. The trailing
1898        // record names a stage this blueprint no longer has.
1899        let mut analyze = StageRecord::new("analyze".to_string(), 0);
1900        analyze.status = StageRunStatus::Complete;
1901        analyze.entered = true;
1902        analyze.prompt_tokens = 1_234;
1903        analyze.completion_tokens = 56;
1904        analyze.cached_tokens = 7;
1905        analyze.cache_write_tokens = 8;
1906        analyze.first_call_prompt_tokens = Some(400);
1907        analyze.runaway_warned = true;
1908        analyze
1909            .region_tokens
1910            .insert("conversation".to_string(), 900);
1911        analyze.started_at = Some(10);
1912        analyze.ended_at = Some(20);
1913        let mut implement = StageRecord::new("implement".to_string(), 1);
1914        implement.status = StageRunStatus::Active;
1915        implement.entered = true;
1916        implement.prompt_tokens = 77;
1917        implement.started_at = Some(20);
1918        let removed = StageRecord::new("removed_stage".to_string(), 7);
1919        std::fs::write(
1920            runs.path().join("run-stages").join("stages.json"),
1921            serde_json::to_string(&vec![analyze, implement, removed]).unwrap(),
1922        )
1923        .unwrap();
1924
1925        let (mut world, cli) = test_world();
1926        let hub = InteractionHub::new();
1927        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1928        let restored = reload_persisted_agents(
1929            &mut world,
1930            crate::daemon::spawn::SpawnDeps {
1931                tool_service: cli.as_ref(),
1932                config: &Config::default(),
1933                shared_mcp: mcp,
1934                mcp_tool_defs: &[],
1935                hub: &hub,
1936                now_secs: 999,
1937                subagent_tx: sub_tx().clone(),
1938            },
1939            runs.path(),
1940        );
1941        assert_eq!(restored.len(), 1);
1942
1943        let ledger = world
1944            .world()
1945            .get::<StageLedger>(restored[0].1.entity())
1946            .expect("a reloaded agent carries a stage ledger");
1947        // One record per blueprint stage, in blueprint order: the record for a
1948        // stage that no longer exists is dropped rather than appended.
1949        let names: Vec<&str> = ledger.0.iter().map(|r| r.name.as_str()).collect();
1950        assert_eq!(names, vec!["analyze", "implement", "review"]);
1951        assert_eq!(ledger.0[0].prompt_tokens, 1_234);
1952        assert_eq!(ledger.0[0].completion_tokens, 56);
1953        assert_eq!(ledger.0[0].cached_tokens, 7);
1954        assert_eq!(ledger.0[0].cache_write_tokens, 8);
1955        assert_eq!(ledger.0[0].first_call_prompt_tokens, Some(400));
1956        assert!(ledger.0[0].runaway_warned);
1957        assert_eq!(ledger.0[0].region_tokens.get("conversation"), Some(&900));
1958        assert_eq!(ledger.0[0].started_at, Some(10));
1959        assert_eq!(ledger.0[0].ended_at, Some(20));
1960        assert_eq!(ledger.0[0].status, StageRunStatus::Complete);
1961        assert!(ledger.0[0].entered);
1962        assert_eq!(ledger.0[1].prompt_tokens, 77);
1963        assert!(ledger.0[1].entered);
1964        // A stage with nothing persisted against it keeps the seeded record.
1965        assert_eq!(ledger.0[2].prompt_tokens, 0);
1966        assert!(!ledger.0[2].entered);
1967        assert_eq!(ledger.0[2].index, 2);
1968    }
1969
1970    #[tokio::test]
1971    async fn skips_missing_dir_junk_and_unreloadable_runs() {
1972        // A runs dir that doesn't exist → empty.
1973        let (mut world, cli) = test_world();
1974        let hub = InteractionHub::new();
1975        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1976        assert!(
1977            reload_persisted_agents(
1978                &mut world,
1979                crate::daemon::spawn::SpawnDeps {
1980                    tool_service: cli.as_ref(),
1981                    config: &Config::default(),
1982                    shared_mcp: mcp.clone(),
1983                    mcp_tool_defs: &[],
1984                    hub: &hub,
1985                    now_secs: 1,
1986                    subagent_tx: sub_tx().clone(),
1987                },
1988                std::path::Path::new("/no/such/runs/dir"),
1989            )
1990            .is_empty()
1991        );
1992
1993        // A runs dir with junk: a dir without meta.json, a dir with corrupt
1994        // meta.json, and a non-terminal run pointing at a missing blueprint.
1995        let runs = tempfile::tempdir().unwrap();
1996        std::fs::create_dir_all(runs.path().join("no-meta")).unwrap();
1997        let corrupt = runs.path().join("corrupt");
1998        std::fs::create_dir_all(&corrupt).unwrap();
1999        std::fs::write(corrupt.join("meta.json"), "not json").unwrap();
2000        write_run(
2001            runs.path(),
2002            "run-badpath",
2003            "/no/such/agent.leviath",
2004            RunStatus::Running,
2005            None,
2006        );
2007
2008        let restored = reload_persisted_agents(
2009            &mut world,
2010            crate::daemon::spawn::SpawnDeps {
2011                tool_service: cli.as_ref(),
2012                config: &Config::default(),
2013                shared_mcp: mcp,
2014                mcp_tool_defs: &[],
2015                hub: &hub,
2016                now_secs: 1,
2017                subagent_tx: sub_tx().clone(),
2018            },
2019            runs.path(),
2020        );
2021        assert!(restored.is_empty()); // all skipped, none fatal
2022
2023        // The un-reloadable run is recorded as crashed rather than left claiming
2024        // it is still running (issue #109) - `lev ps` and the dashboard would
2025        // otherwise show a live run that no longer exists.
2026        let meta: RunMeta = serde_json::from_str(
2027            &std::fs::read_to_string(runs.path().join("run-badpath").join("meta.json")).unwrap(),
2028        )
2029        .unwrap();
2030        assert_eq!(meta.status, RunStatus::Error);
2031        let error = meta.error.unwrap_or_default();
2032        assert!(error.contains("could not be recovered"), "got: {error}");
2033        assert_eq!(meta.updated_at, 1);
2034        // Junk that never parsed as a run has nothing to rewrite.
2035        assert!(!runs.path().join("no-meta").join("meta.json").exists());
2036        assert_eq!(
2037            std::fs::read_to_string(corrupt.join("meta.json")).unwrap(),
2038            "not json"
2039        );
2040    }
2041
2042    #[test]
2043    fn marking_a_crash_is_best_effort() {
2044        // The run directory can vanish between the scan and the rewrite (a
2045        // concurrent `lev rm`, a wiped runs dir). Recovery must log and carry
2046        // on - the daemon is mid-startup and the other runs still need it.
2047        let runs = tempfile::tempdir().unwrap();
2048        write_run(
2049            runs.path(),
2050            "run-x",
2051            "/no/such/agent.leviath",
2052            RunStatus::Running,
2053            None,
2054        );
2055        let meta = read_meta(&runs.path().join("run-x")).expect("written above");
2056        mark_crashed(&runs.path().join("gone"), meta, "boom", 7);
2057        assert!(!runs.path().join("gone").exists());
2058    }
2059
2060    #[tokio::test]
2061    async fn fake_provider_methods_are_exercised() {
2062        use leviath_providers::Provider;
2063        let p = FakeProvider;
2064        assert_eq!(p.name(), "fake");
2065        assert_eq!(p.count_tokens("t", "m").await, 1);
2066        assert_eq!(p.max_context_tokens("m"), 1000);
2067        let _ = p.capabilities("m");
2068        assert!(
2069            p.infer(&leviath_providers::InferenceRequest {
2070                system: vec![],
2071                messages: vec![],
2072                model: "m".to_string(),
2073                max_tokens: 1,
2074                temperature: 0.0,
2075                tools: vec![],
2076                extra: serde_json::Value::Null,
2077                request_timeout_secs: None,
2078            })
2079            .await
2080            .is_err()
2081        );
2082    }
2083
2084    #[test]
2085    fn is_terminal_covers_all_statuses() {
2086        assert!(is_terminal(&RunStatus::Complete));
2087        assert!(is_terminal(&RunStatus::Cancelled));
2088        assert!(is_terminal(&RunStatus::Error));
2089        assert!(!is_terminal(&RunStatus::Running));
2090        assert!(!is_terminal(&RunStatus::WaitingInput));
2091    }
2092}