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 run parked until the machine is fixed keeps its reason across a
376    // restart. Without this the marker is a live component nothing rebuilds,
377    // so the run returns as a bare `Paused` and the next persist tick computes
378    // `waiting_on` from markers that are no longer there and writes `null`
379    // over the recorded reason - the same shape as the stage ledger above,
380    // where not restoring did not merely lose the value but erased the file
381    // that held it. The run is not re-dispatched while paused, so nothing
382    // would recompute it either.
383    if let Some(leviath_core::run_meta::WaitReason::NeedsSetup { blocker, remedy }) =
384        &meta.waiting_on
385    {
386        world
387            .world_mut()
388            .entity_mut(entity)
389            .insert(leviath_runtime::pipeline::PausedForSetup {
390                blocker: *blocker,
391                remedy: remedy.clone(),
392            });
393    }
394
395    // A tool batch was in flight when the daemon died and its results never
396    // reached the window: replay what the journal recorded - real results for
397    // completed calls, verify-first errors for interrupted ones - so the
398    // re-issued inference sees what already ran instead of re-executing the
399    // batch's side effects (issue #96). fold() only surfaces a batch that is
400    // genuinely unapplied (same iteration, turn absent from the window).
401    if let Some(batch) = pending_batch {
402        leviath_runtime::restore::restore_pending_batch(
403            world.world_mut(),
404            entity,
405            &batch,
406            &meta.children,
407        );
408    }
409
410    // `build_agent` stamps fresh run metadata; preserve the original identity.
411    {
412        let mut md = world
413            .world_mut()
414            .get_mut::<RunMetadata>(entity)
415            .expect("build_agent attached run metadata");
416        md.started_at = meta.started_at;
417        md.title = meta.title.clone();
418        md.callback_url = meta.callback_url.clone();
419        md.callback_secret = meta.callback_secret.clone();
420        // `parent_run_id` was already restored via `args` into build_agent's metadata.
421    }
422
423    // Carry the run's productivity flags across the restart, so a resumed run
424    // doesn't report itself as having modified nothing (issue #107).
425    {
426        let mut flags = world
427            .world_mut()
428            .get_mut::<leviath_runtime::persistence::RunOutcomeFlags>(entity)
429            .expect("build_agent attached run outcome flags");
430        flags.0 = meta.flags.clone();
431    }
432
433    // Put back an answer the run had already submitted, content and all: the
434    // descriptor is in `meta.json`, the bytes in the sidecar beside it. Without
435    // this the component is absent after a reload, and the very next persist
436    // tick writes a `meta.json` with no `final_output` - so a restart would not
437    // merely fail to restore the answer, it would erase the one on disk. It
438    // also re-arms the required-output gate correctly: a stage that submitted
439    // before the restart is not asked to do it again.
440    if let Some(output) = read_final_output_from(run_dir, meta) {
441        world
442            .world_mut()
443            .entity_mut(entity)
444            .insert(leviath_runtime::persistence::FinalOutput(output));
445    }
446
447    // If this run was parked at a stage-boundary interaction point (e.g.
448    // plan_approval), re-present it in the *waiting* state rather than the default
449    // `Active` + `ReadyToInfer` restore - so the open prompt survives the restart
450    // instead of being dropped and re-inferred (issue #38). A missing/malformed
451    // sidecar, or a blueprint that no longer matches, leaves the default restore.
452    if let Some(state) = std::fs::read_to_string(run_dir.join("interactions.json"))
453        .ok()
454        .and_then(|s| serde_json::from_str::<InteractionPointState>(&s).ok())
455    {
456        // Built against this world's ECS a few lines above, so it is ours.
457        let agent = world.own_agent(entity);
458        leviath_runtime::interaction_points::restore_interaction_point(
459            world.world_mut(),
460            agent,
461            state,
462        );
463    }
464
465    // A run the user paused stays paused across the restart: the default
466    // restore presents it `Active`, which would silently resume it.
467    if meta.status == RunStatus::Paused {
468        // Built against this world's ECS, so it is ours.
469        world.pause(world.own_agent(entity));
470    }
471
472    Ok(entity)
473}
474
475/// Rebuild a run's submitted answer from its descriptor plus the sidecar in
476/// `dir`.
477///
478/// Recovery works from its configured runs directory rather than the home one,
479/// so it cannot use `runstate::read_final_output`, which resolves the path
480/// itself. A missing sidecar yields `None`: a run written before the answer
481/// moved out of `meta.json`, or one whose directory was pruned.
482fn read_final_output_from(dir: &Path, meta: &RunMeta) -> Option<leviath_core::FinalOutput> {
483    let descriptor = meta.final_output.clone()?;
484    let content = std::fs::read_to_string(dir.join(leviath_core::FINAL_OUTPUT_FILE)).ok()?;
485    Some(leviath_core::FinalOutput {
486        content,
487        format: descriptor.format,
488        stage: descriptor.stage,
489        submitted_at: descriptor.submitted_at,
490        truncated: descriptor.truncated,
491        artifacts: descriptor.artifacts,
492    })
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    // Named here rather than inherited from the parent: production now spells
499    // these seven as one `SpawnDeps`, so importing them above would mean seven
500    // imports the module itself does not use.
501    use std::sync::Arc;
502
503    use leviath_mcp::ToolExecutor;
504    use leviath_runtime::host::SubAgentOp;
505    use leviath_runtime::interaction_hub::InteractionHub;
506    use tokio::sync::Mutex;
507    use tokio::sync::mpsc::UnboundedSender;
508
509    use crate::config::Config;
510    use crate::daemon::tool_service::CliToolService;
511
512    use leviath_runtime::ProviderRegistry;
513    use leviath_runtime::components::AgentStatus;
514    use leviath_runtime::inference_pool::InferencePoolConfig;
515    use tokio::runtime::Handle;
516
517    fn sub_tx() -> UnboundedSender<SubAgentOp> {
518        tokio::sync::mpsc::unbounded_channel().0
519    }
520
521    struct FakeProvider;
522    #[async_trait::async_trait]
523    impl leviath_providers::Provider for FakeProvider {
524        async fn infer(
525            &self,
526            _r: &leviath_providers::InferenceRequest,
527        ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
528            Err(leviath_providers::ProviderError::Other("t".to_string()))
529        }
530        async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
531            1
532        }
533        fn max_context_tokens(&self, _m: &str) -> usize {
534            1000
535        }
536        fn name(&self) -> &str {
537            "fake"
538        }
539        fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
540            leviath_providers::ModelCapabilities::default()
541        }
542    }
543
544    fn test_world() -> (PipelineWorld, Arc<CliToolService>) {
545        let cli = Arc::new(CliToolService::new());
546        let mut registry = ProviderRegistry::new();
547        for p in ["anthropic", "openai", "ollama"] {
548            registry.register(p.to_string(), Arc::new(FakeProvider));
549        }
550        let world = PipelineWorld::new(
551            registry,
552            cli.clone(),
553            InferencePoolConfig::new(),
554            1,
555            None,
556            Handle::current(),
557        );
558        (world, cli)
559    }
560
561    fn coder_manifest() -> String {
562        // Self-contained fixture - not the shipped blueprint (see test_support).
563        crate::test_support::inline_coder_manifest()
564    }
565
566    /// Write a `<runs_dir>/<run_id>/meta.json` (+ optional context.json) for a run
567    /// whose blueprint lives at `agent_path`.
568    fn write_run(
569        runs_dir: &Path,
570        run_id: &str,
571        agent_path: &str,
572        status: RunStatus,
573        context: Option<&ContextSnapshot>,
574    ) {
575        write_run_tree(RunFixture {
576            runs_dir,
577            run_id,
578            agent_path,
579            status,
580            context,
581            parent_run_id: None,
582            children: &[],
583            depth: 0,
584            max_child_depth: 0,
585        });
586    }
587
588    /// Like [`write_run`], but with explicit tree links so recovery's re-linking
589    /// pass can be exercised.
590    /// One persisted run, as a fixture writes it.
591    ///
592    /// A struct because every field is a column of the record being written, and
593    /// a nine-argument call in which three are `&str` and two are `usize` is a
594    /// transposition waiting to happen in a file whose whole job is asserting on
595    /// what got written.
596    struct RunFixture<'a> {
597        runs_dir: &'a Path,
598        run_id: &'a str,
599        agent_path: &'a str,
600        status: RunStatus,
601        context: Option<&'a ContextSnapshot>,
602        parent_run_id: Option<&'a str>,
603        children: &'a [&'a str],
604        depth: usize,
605        max_child_depth: usize,
606    }
607
608    fn write_run_tree(f: RunFixture<'_>) {
609        let RunFixture {
610            runs_dir,
611            run_id,
612            agent_path,
613            status,
614            context,
615            parent_run_id,
616            children,
617            depth,
618            max_child_depth,
619        } = f;
620        let dir = runs_dir.join(run_id);
621        std::fs::create_dir_all(&dir).unwrap();
622        let meta = RunMeta {
623            run_id: run_id.to_string(),
624            agent_name: "coder".to_string(),
625            agent_path: agent_path.to_string(),
626            task: "resume me".to_string(),
627            model: None,
628            pid: 0,
629            status,
630            current_stage: "implement".to_string(),
631            stage_index: 0,
632            num_stages: 1,
633            iteration: 5,
634            prompt_tokens: 42,
635            completion_tokens: 7,
636            cached_tokens: 0,
637            cache_write_tokens: 0,
638            tool_calls: 3,
639            workdir: std::env::temp_dir().to_string_lossy().to_string(),
640            started_at: 111,
641            updated_at: 222,
642            last_progress_at: None,
643            error: None,
644            title: Some("Resume Me".to_string()),
645            metadata: std::collections::HashMap::new(),
646            callback_url: Some("http://cb".to_string()),
647            callback_secret: None,
648            parent_run_id: parent_run_id.map(str::to_string),
649            children: children.iter().map(|s| s.to_string()).collect(),
650            depth,
651            max_child_depth,
652            // Non-default on purpose: proves reload restores the run's
653            // productivity flags rather than starting them over (issue #107).
654            flags: leviath_core::run_meta::RunFlags {
655                modified_files: vec!["src/a.rs".to_string()],
656                modified_file_count: 1,
657                // Contradicts what this manifest would compute on a fresh
658                // spawn (it advertises `write_file`), which is the point: the
659                // flags describe how the run actually executed, so the
660                // persisted answer wins over a re-derived one (issue #192).
661                no_output_tools: true,
662                ..Default::default()
663            },
664            yolo: false,
665            read_paths: None,
666            // Non-default on purpose, like `flags` above: proves a reload puts
667            // the run's answer back rather than dropping it (and then erasing
668            // the copy already on disk at the next persist tick).
669            final_output: Some(
670                leviath_core::output::FinalOutput::new(
671                    "already answered",
672                    Some("markdown".to_string()),
673                    "implement".to_string(),
674                    777,
675                )
676                .descriptor(),
677            ),
678            output_request: Some(leviath_core::output::OutputSpec {
679                format: Some("a2ui".to_string()),
680                ..Default::default()
681            }),
682            waiting_on: None,
683        };
684        std::fs::write(dir.join("meta.json"), serde_json::to_string(&meta).unwrap()).unwrap();
685        // The answer's bytes live beside the descriptor, so a reload has
686        // something to restore.
687        std::fs::write(
688            dir.join(leviath_core::FINAL_OUTPUT_FILE),
689            "already answered",
690        )
691        .unwrap();
692        if let Some(ctx) = context {
693            std::fs::write(
694                dir.join("context.json"),
695                serde_json::to_string(ctx).unwrap(),
696            )
697            .unwrap();
698        }
699    }
700
701    fn agent_dir() -> tempfile::TempDir {
702        let dir = tempfile::tempdir().unwrap();
703        std::fs::write(dir.path().join("agent.leviath"), coder_manifest()).unwrap();
704        dir
705    }
706
707    /// Write a `<runs_dir>/<run_id>/run.lvr` that folds to the given `stage_index`,
708    /// `iteration`, `prompt_tokens`, and `context` - the run's atomic journal. Used
709    /// to prove recovery prefers this consistent pair over a stale `context.json`.
710    fn write_run_archive(
711        runs_dir: &Path,
712        run_id: &str,
713        agent_path: &str,
714        stage_index: usize,
715        iteration: usize,
716        prompt_tokens: usize,
717        context: &ContextSnapshot,
718    ) {
719        use leviath_core::run_archive::{self, RunIdentity, RunRecord};
720        let dir = runs_dir.join(run_id);
721        std::fs::create_dir_all(&dir).unwrap();
722        let mut meta = RunMeta::new(
723            run_id.to_string(),
724            "coder".to_string(),
725            agent_path.to_string(),
726            "resume me".to_string(),
727            None,
728            std::env::temp_dir().to_string_lossy().to_string(),
729            1,
730        );
731        meta.status = RunStatus::Running;
732        meta.current_stage = "implement".to_string();
733        meta.stage_index = stage_index;
734        meta.iteration = iteration;
735        meta.prompt_tokens = prompt_tokens;
736        let mut buf = Vec::new();
737        run_archive::write_archive_start(&mut buf, run_archive::RUN_ARCHIVE_VERSION).unwrap();
738        run_archive::write_record(
739            &mut buf,
740            &RunRecord::Header {
741                identity: RunIdentity {
742                    run_id: run_id.to_string(),
743                    machine_id: "m".to_string(),
744                    world_id: "w".to_string(),
745                    created_at: 1,
746                },
747                meta: Box::new(meta),
748            },
749        )
750        .unwrap();
751        run_archive::write_record(
752            &mut buf,
753            &RunRecord::ContextCheckpoint {
754                snapshot: context.clone(),
755                at: 2,
756            },
757        )
758        .unwrap();
759        std::fs::write(dir.join("run.lvr"), &buf).unwrap();
760    }
761
762    /// A run the user paused before the restart comes back paused, not the
763    /// default `Active` restore - a daemon restart must not silently resume it.
764    #[tokio::test]
765    async fn reload_keeps_a_paused_run_paused() {
766        let agent = agent_dir();
767        let manifest = agent.path().join("agent.leviath");
768        let runs = tempfile::tempdir().unwrap();
769        write_run(
770            runs.path(),
771            "run-paused",
772            manifest.to_str().unwrap(),
773            RunStatus::Paused,
774            None,
775        );
776
777        let (mut world, cli) = test_world();
778        let hub = InteractionHub::new();
779        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
780        let restored = reload_persisted_agents(
781            &mut world,
782            crate::daemon::spawn::SpawnDeps {
783                tool_service: cli.as_ref(),
784                config: &Config::default(),
785                shared_mcp: mcp,
786                mcp_tool_defs: &[],
787                hub: &hub,
788                now_secs: 999,
789                subagent_tx: sub_tx().clone(),
790            },
791            runs.path(),
792        );
793
794        assert_eq!(restored.len(), 1);
795        let (run_id, entity) = &restored[0];
796        assert_eq!(run_id, "run-paused");
797        assert_eq!(world.agent_status(*entity), Some(AgentStatus::Paused));
798    }
799
800    /// Reload one run from `runs_dir` and hand back the world plus its entity.
801    /// A descriptor with no sidecar beside it is no answer. That is a run
802    /// written before the answer moved out of `meta.json`, or one whose
803    /// directory was pruned, and restoring half of it would be worse than
804    /// restoring none: the next persist tick would write the half back.
805    #[test]
806    fn a_descriptor_without_its_sidecar_restores_nothing() {
807        let dir = tempfile::tempdir().unwrap();
808        let mut meta = RunMeta::new(
809            "run-1".to_string(),
810            "a".to_string(),
811            "/p".to_string(),
812            "t".to_string(),
813            None,
814            "/w".to_string(),
815            1,
816        );
817
818        // No descriptor at all.
819        assert!(read_final_output_from(dir.path(), &meta).is_none());
820
821        // A descriptor, but nothing on disk to go with it.
822        let answer = leviath_core::output::FinalOutput::new(
823            "already answered",
824            Some("markdown".to_string()),
825            "implement".to_string(),
826            777,
827        );
828        meta.final_output = Some(answer.descriptor());
829        assert!(read_final_output_from(dir.path(), &meta).is_none());
830
831        // And with both, the answer comes back whole.
832        std::fs::write(
833            dir.path().join(leviath_core::FINAL_OUTPUT_FILE),
834            &answer.content,
835        )
836        .unwrap();
837        let restored = read_final_output_from(dir.path(), &meta).expect("both halves");
838        assert_eq!(restored.content, "already answered");
839        assert_eq!(restored.stage, "implement");
840    }
841
842    async fn reload_single(runs: &Path, run_id: &str) -> (PipelineWorld, Entity) {
843        let (mut world, cli) = test_world();
844        let restored = reload_persisted_agents(
845            &mut world,
846            crate::daemon::spawn::SpawnDeps {
847                tool_service: cli.as_ref(),
848                config: &Config::default(),
849                shared_mcp: Arc::new(Mutex::new(ToolExecutor::new())),
850                mcp_tool_defs: &[],
851                hub: &InteractionHub::new(),
852                now_secs: 999,
853                subagent_tx: sub_tx().clone(),
854            },
855            runs,
856        );
857        assert_eq!(restored.len(), 1);
858        assert_eq!(restored[0].0, run_id);
859        let entity = restored[0].1;
860        (world, entity.entity())
861    }
862
863    /// An unattended run comes back unattended. Dropping `--yolo` on reload was
864    /// meant as the safe side, but it converted a running unattended job into
865    /// one parked on a prompt nobody was watching for (issue #184).
866    #[tokio::test]
867    async fn reload_keeps_an_unattended_run_unattended() {
868        let agent = agent_dir();
869        let manifest = agent.path().join("agent.leviath");
870        let runs = tempfile::tempdir().unwrap();
871        write_run(
872            runs.path(),
873            "run-yolo",
874            manifest.to_str().unwrap(),
875            RunStatus::Running,
876            None,
877        );
878        // Flip the persisted flag the way a `--yolo` launch would have.
879        let meta_path = runs.path().join("run-yolo").join("meta.json");
880        let mut meta: RunMeta =
881            serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
882        meta.yolo = true;
883        std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
884
885        let (world, entity) = reload_single(runs.path(), "run-yolo").await;
886        assert!(
887            world
888                .world()
889                .get::<RunMetadata>(entity)
890                .expect("reloaded run has metadata")
891                .unattended
892        );
893        assert!(
894            world
895                .world()
896                .get::<leviath_runtime::components::InteractionAutoApprove>(entity)
897                .is_some(),
898            "an unattended reload still auto-approves its checkpoints"
899        );
900    }
901
902    /// A run launched without `--yolo` must not acquire it on reload, and a
903    /// `meta.json` written before the field existed reads as attended.
904    #[tokio::test]
905    async fn reload_does_not_invent_unattended() {
906        let agent = agent_dir();
907        let manifest = agent.path().join("agent.leviath");
908        let runs = tempfile::tempdir().unwrap();
909        write_run(
910            runs.path(),
911            "run-plain",
912            manifest.to_str().unwrap(),
913            RunStatus::Running,
914            None,
915        );
916        // Strip the field entirely: exactly what an older binary wrote.
917        let meta_path = runs.path().join("run-plain").join("meta.json");
918        let mut raw: serde_json::Value =
919            serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
920        raw.as_object_mut().unwrap().remove("yolo");
921        std::fs::write(&meta_path, serde_json::to_string(&raw).unwrap()).unwrap();
922
923        let (world, entity) = reload_single(runs.path(), "run-plain").await;
924        assert!(
925            !world
926                .world()
927                .get::<RunMetadata>(entity)
928                .expect("reloaded run has metadata")
929                .unattended
930        );
931    }
932
933    #[tokio::test]
934    async fn reloads_nonterminal_runs_and_restores_state() {
935        let agent = agent_dir();
936        let manifest = agent.path().join("agent.leviath");
937        let runs = tempfile::tempdir().unwrap();
938
939        // A running snapshot with real context.
940        let ctx = ContextSnapshot {
941            stage_name: "implement".to_string(),
942            total_tokens: 4,
943            max_tokens: 100_000,
944            regions: vec![leviath_core::run_meta::RegionSnapshot {
945                name: "conversation".to_string(),
946                kind: "clearable".to_string(),
947                current_tokens: 4,
948                max_tokens: 100_000,
949                entries: vec![leviath_core::run_meta::RegionEntrySnapshot {
950                    content: "earlier turn".to_string(),
951                    tokens: 4,
952                    kind: leviath_core::region::EntryKind::UserMessage,
953                    metadata: None,
954                    key: None,
955                    taint: Default::default(),
956                }],
957            }],
958        };
959        write_run(
960            runs.path(),
961            "run-live",
962            manifest.to_str().unwrap(),
963            RunStatus::Running,
964            Some(&ctx),
965        );
966        // A completed run - must be skipped.
967        write_run(
968            runs.path(),
969            "run-done",
970            manifest.to_str().unwrap(),
971            RunStatus::Complete,
972            None,
973        );
974
975        let (mut world, cli) = test_world();
976        let hub = InteractionHub::new();
977        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
978        let restored = reload_persisted_agents(
979            &mut world,
980            crate::daemon::spawn::SpawnDeps {
981                tool_service: cli.as_ref(),
982                config: &Config::default(),
983                shared_mcp: mcp,
984                mcp_tool_defs: &[],
985                hub: &hub,
986                now_secs: 999,
987                subagent_tx: sub_tx().clone(),
988            },
989            runs.path(),
990        );
991
992        assert_eq!(restored.len(), 1);
993        let (run_id, entity) = &restored[0];
994        assert_eq!(run_id, "run-live");
995        assert_eq!(world.agent_status(*entity), Some(AgentStatus::Active));
996        // Iteration + preserved metadata restored.
997        let md = world.world().get::<RunMetadata>(entity.entity()).unwrap();
998        assert_eq!(md.started_at, 111);
999        assert_eq!(md.title.as_deref(), Some("Resume Me"));
1000        assert_eq!(md.callback_url.as_deref(), Some("http://cb"));
1001        let totals = world.world().get::<TokenTotals>(entity.entity()).unwrap();
1002        assert_eq!(totals.prompt_tokens, 42);
1003        assert_eq!(totals.tool_calls, 3);
1004        // ...as are the run's productivity flags, so a resumed run doesn't report
1005        // itself as having modified nothing.
1006        let flags = world
1007            .world()
1008            .get::<leviath_runtime::persistence::RunOutcomeFlags>(entity.entity())
1009            .unwrap();
1010        assert_eq!(flags.0.modified_files, vec!["src/a.rs".to_string()]);
1011        assert_eq!(flags.0.modified_file_count, 1);
1012        // Including the capability answer, which the blueprint on disk would
1013        // now compute differently - the run is judged as it ran (issue #192).
1014        assert!(flags.0.no_output_tools);
1015        // The answer the run had already given is put back on the entity. Were
1016        // it not, the next persist tick would write a meta.json without it and
1017        // erase the copy already on disk.
1018        let output = world
1019            .world()
1020            .get::<leviath_runtime::persistence::FinalOutput>(entity.entity())
1021            .expect("a submitted answer survives the restart");
1022        assert_eq!(output.0.content, "already answered");
1023        assert_eq!(output.0.stage, "implement");
1024        // As does the shape the caller asked for at launch, so the resumed run
1025        // does not silently revert to the blueprint's partway through.
1026        assert_eq!(
1027            md.output_request.as_ref().and_then(|s| s.format.as_deref()),
1028            Some("a2ui")
1029        );
1030    }
1031
1032    /// Fresh + stale differ on every observable field, so the assertions below
1033    /// pin down exactly which source recovery restored from.
1034    fn assert_restored_from_archive(world: &PipelineWorld, entity: Entity) {
1035        use leviath_runtime::components::AgentState;
1036        let state = world.world().get::<AgentState>(entity).unwrap();
1037        // stage_name comes from the archive's context (not the stale context.json),
1038        // iteration from the archive's meta (not meta.json's 5).
1039        assert_eq!(state.current_stage, "fresh-stage");
1040        assert_eq!(state.iteration, 9);
1041        // token totals come from the archive's meta (not meta.json's 42).
1042        let totals = world.world().get::<TokenTotals>(entity).unwrap();
1043        assert_eq!(totals.prompt_tokens, 99);
1044    }
1045
1046    /// Torn-snapshot pairing: when the atomic journal (`run.lvr`) and the separate
1047    /// `context.json` disagree - the crash-window state where a new `meta.json`
1048    /// sits next to a stale `context.json` - resume restores the journal's
1049    /// consistent `{meta, context}` pair, not the stale JSON.
1050    #[tokio::test]
1051    async fn reload_prefers_the_atomic_journal_over_a_stale_context_json() {
1052        let agent = agent_dir();
1053        let manifest = agent.path().join("agent.leviath");
1054        let mpath = manifest.to_str().unwrap();
1055        let runs = tempfile::tempdir().unwrap();
1056
1057        // A STALE context.json (older generation) alongside a meta.json whose
1058        // iteration/totals are also older than the journal - write_run stamps
1059        // iteration 5 / prompt_tokens 42.
1060        let stale = ContextSnapshot {
1061            stage_name: "stale-stage".to_string(),
1062            total_tokens: 1,
1063            max_tokens: 100,
1064            regions: vec![],
1065        };
1066        write_run(
1067            runs.path(),
1068            "run-torn",
1069            mpath,
1070            RunStatus::Running,
1071            Some(&stale),
1072        );
1073        // The journal at the newer generation: iteration 9, prompt_tokens 99,
1074        // context stage "fresh-stage".
1075        let fresh = ContextSnapshot {
1076            stage_name: "fresh-stage".to_string(),
1077            total_tokens: 4,
1078            max_tokens: 100_000,
1079            regions: vec![],
1080        };
1081        write_run_archive(runs.path(), "run-torn", mpath, 0, 9, 99, &fresh);
1082
1083        let (mut world, cli) = test_world();
1084        let hub = InteractionHub::new();
1085        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1086        let restored = reload_persisted_agents(
1087            &mut world,
1088            crate::daemon::spawn::SpawnDeps {
1089                tool_service: cli.as_ref(),
1090                config: &Config::default(),
1091                shared_mcp: mcp,
1092                mcp_tool_defs: &[],
1093                hub: &hub,
1094                now_secs: 999,
1095                subagent_tx: sub_tx().clone(),
1096            },
1097            runs.path(),
1098        );
1099
1100        assert_eq!(restored.len(), 1);
1101        assert_restored_from_archive(&world, restored[0].1.entity());
1102    }
1103
1104    /// A crash *during* the journal append can leave a torn trailing frame. Recovery
1105    /// reads the journal leniently, so the valid prefix still resolves the resume
1106    /// state (rather than silently falling back to the possibly-mismatched JSON).
1107    #[tokio::test]
1108    async fn reload_tolerates_a_torn_journal_tail() {
1109        let agent = agent_dir();
1110        let manifest = agent.path().join("agent.leviath");
1111        let mpath = manifest.to_str().unwrap();
1112        let runs = tempfile::tempdir().unwrap();
1113
1114        let stale = ContextSnapshot {
1115            stage_name: "stale-stage".to_string(),
1116            total_tokens: 1,
1117            max_tokens: 100,
1118            regions: vec![],
1119        };
1120        write_run(
1121            runs.path(),
1122            "run-torn2",
1123            mpath,
1124            RunStatus::Running,
1125            Some(&stale),
1126        );
1127        let fresh = ContextSnapshot {
1128            stage_name: "fresh-stage".to_string(),
1129            total_tokens: 4,
1130            max_tokens: 100_000,
1131            regions: vec![],
1132        };
1133        write_run_archive(runs.path(), "run-torn2", mpath, 0, 9, 99, &fresh);
1134        // Append a torn frame (length prefix promising bytes that aren't there).
1135        {
1136            use std::io::Write;
1137            let mut f = std::fs::OpenOptions::new()
1138                .append(true)
1139                .open(runs.path().join("run-torn2/run.lvr"))
1140                .unwrap();
1141            f.write_all(&[0, 0, 0, 0, 0, 0, 0, 10, 1, 2]).unwrap();
1142        }
1143
1144        let (mut world, cli) = test_world();
1145        let hub = InteractionHub::new();
1146        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1147        let restored = reload_persisted_agents(
1148            &mut world,
1149            crate::daemon::spawn::SpawnDeps {
1150                tool_service: cli.as_ref(),
1151                config: &Config::default(),
1152                shared_mcp: mcp,
1153                mcp_tool_defs: &[],
1154                hub: &hub,
1155                now_secs: 999,
1156                subagent_tx: sub_tx().clone(),
1157            },
1158            runs.path(),
1159        );
1160
1161        assert_eq!(restored.len(), 1);
1162        // The valid prefix folds → resume still uses the journal's fresh state.
1163        assert_restored_from_archive(&world, restored[0].1.entity());
1164    }
1165
1166    /// Append raw journal records to an existing `run.lvr`, the way the live
1167    /// lane journals a batch dispatch and its per-call completions.
1168    fn append_archive_records(
1169        runs_dir: &Path,
1170        run_id: &str,
1171        records: &[leviath_core::run_archive::RunRecord],
1172    ) {
1173        use std::io::Write;
1174        let mut buf = Vec::new();
1175        for r in records {
1176            leviath_core::run_archive::write_record(&mut buf, r).unwrap();
1177        }
1178        let mut f = std::fs::OpenOptions::new()
1179            .append(true)
1180            .open(runs_dir.join(run_id).join("run.lvr"))
1181            .unwrap();
1182        f.write_all(&buf).unwrap();
1183    }
1184
1185    fn batch_call(
1186        id: &str,
1187        name: &str,
1188        result: Option<&str>,
1189    ) -> leviath_core::run_archive::ToolCallRecord {
1190        leviath_core::run_archive::ToolCallRecord {
1191            id: id.to_string(),
1192            name: name.to_string(),
1193            arguments: "{}".to_string(),
1194            result: result.map(str::to_string),
1195            thought_signature: None,
1196        }
1197    }
1198
1199    /// The conversation entries of a reloaded agent's window.
1200    fn conversation_of(world: &PipelineWorld, entity: Entity) -> Vec<leviath_core::RegionEntry> {
1201        world
1202            .world()
1203            .get::<leviath_runtime::components::ContextWindow>(entity)
1204            .unwrap()
1205            .get_region("conversation")
1206            .unwrap()
1207            .content
1208            .clone()
1209    }
1210
1211    /// The #96 crash-resume path end to end: a batch was dispatched (journaled),
1212    /// one call completed (journaled), one didn't, and the daemon died before
1213    /// the batch applied. Reload replays the recorded result and synthesizes a
1214    /// verify-first error for the lost one - and the agent re-infers from there
1215    /// instead of re-executing the batch.
1216    #[tokio::test]
1217    async fn reload_replays_a_pending_tool_batch_instead_of_reexecuting() {
1218        use leviath_core::run_archive::RunRecord;
1219        let agent = agent_dir();
1220        let manifest = agent.path().join("agent.leviath");
1221        let mpath = manifest.to_str().unwrap();
1222        let runs = tempfile::tempdir().unwrap();
1223
1224        write_run(runs.path(), "run-batch", mpath, RunStatus::Running, None);
1225        let ctx = ContextSnapshot {
1226            stage_name: "implement".to_string(),
1227            total_tokens: 0,
1228            max_tokens: 100_000,
1229            regions: vec![],
1230        };
1231        write_run_archive(runs.path(), "run-batch", mpath, 0, 9, 99, &ctx);
1232        append_archive_records(
1233            runs.path(),
1234            "run-batch",
1235            &[
1236                RunRecord::ToolBatch {
1237                    calls: vec![
1238                        batch_call("c_done", "write_file", None),
1239                        batch_call("c_lost", "shell", None),
1240                    ],
1241                    at: 3,
1242                    stage_index: 0,
1243                    iteration: 9,
1244                    response: "writing then running".to_string(),
1245                },
1246                RunRecord::ToolCallDone {
1247                    iteration: 9,
1248                    call_id: "c_done".to_string(),
1249                    result: "Wrote 42 bytes to x.txt".to_string(),
1250                    at: 4,
1251                },
1252            ],
1253        );
1254
1255        let (mut world, cli) = test_world();
1256        let hub = InteractionHub::new();
1257        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1258        let restored = reload_persisted_agents(
1259            &mut world,
1260            crate::daemon::spawn::SpawnDeps {
1261                tool_service: cli.as_ref(),
1262                config: &Config::default(),
1263                shared_mcp: mcp,
1264                mcp_tool_defs: &[],
1265                hub: &hub,
1266                now_secs: 999,
1267                subagent_tx: sub_tx().clone(),
1268            },
1269            runs.path(),
1270        );
1271
1272        assert_eq!(restored.len(), 1);
1273        let entity = restored[0].1;
1274        let entries = conversation_of(&world, entity.entity());
1275        // The assistant turn landed with both calls...
1276        assert!(entries.iter().any(|e| matches!(
1277            &e.kind,
1278            leviath_core::region::EntryKind::AssistantTurn { tool_calls } if tool_calls.len() == 2
1279        )));
1280        // ...the completed call keeps its real journaled result...
1281        assert!(
1282            entries
1283                .iter()
1284                .any(|e| e.content == "Wrote 42 bytes to x.txt")
1285        );
1286        // ...and the lost call gets the verify-first synthesis.
1287        assert!(entries.iter().any(|e| e.content.contains("interrupted")
1288            && e.content.contains("Verify whether it took effect")));
1289        // The agent re-infers from the reconstructed window.
1290        assert!(
1291            world
1292                .world()
1293                .get::<leviath_runtime::pipeline::ReadyToInfer>(entity.entity())
1294                .is_some()
1295        );
1296    }
1297
1298    /// The batch's assistant turn already reached the persisted window before
1299    /// the crash (apply_tool_results ran; the Progress record landed): fold
1300    /// clears the pending batch, so reload appends nothing a second time.
1301    #[tokio::test]
1302    async fn reload_does_not_replay_a_batch_already_in_the_window() {
1303        use leviath_core::region::EntryKind;
1304        use leviath_core::run_archive::RunRecord;
1305        let agent = agent_dir();
1306        let manifest = agent.path().join("agent.leviath");
1307        let mpath = manifest.to_str().unwrap();
1308        let runs = tempfile::tempdir().unwrap();
1309
1310        write_run(runs.path(), "run-applied", mpath, RunStatus::Running, None);
1311        // The archived window already holds the batch's turn + paired result.
1312        let ctx = ContextSnapshot {
1313            stage_name: "implement".to_string(),
1314            total_tokens: 2,
1315            max_tokens: 100_000,
1316            regions: vec![leviath_core::run_meta::RegionSnapshot {
1317                name: "conversation".to_string(),
1318                kind: "clearable".to_string(),
1319                current_tokens: 2,
1320                max_tokens: 100_000,
1321                entries: vec![
1322                    leviath_core::run_meta::RegionEntrySnapshot {
1323                        content: "done".to_string(),
1324                        tokens: 1,
1325                        kind: EntryKind::AssistantTurn {
1326                            tool_calls: vec![leviath_core::region::SerializedToolCall {
1327                                id: "c1".to_string(),
1328                                name: "write_file".to_string(),
1329                                arguments: serde_json::Value::Null,
1330                                thought_signature: None,
1331                            }],
1332                        },
1333                        metadata: None,
1334                        key: None,
1335                        taint: Default::default(),
1336                    },
1337                    leviath_core::run_meta::RegionEntrySnapshot {
1338                        content: "Wrote it".to_string(),
1339                        tokens: 1,
1340                        kind: EntryKind::ToolResult {
1341                            tool_call_id: "c1".to_string(),
1342                            tool_name: "write_file".to_string(),
1343                            is_error: false,
1344                        },
1345                        metadata: None,
1346                        key: None,
1347                        taint: Default::default(),
1348                    },
1349                ],
1350            }],
1351        };
1352        write_run_archive(runs.path(), "run-applied", mpath, 0, 9, 99, &ctx);
1353        append_archive_records(
1354            runs.path(),
1355            "run-applied",
1356            &[RunRecord::ToolBatch {
1357                calls: vec![batch_call("c1", "write_file", None)],
1358                at: 3,
1359                stage_index: 0,
1360                iteration: 9,
1361                response: "done".to_string(),
1362            }],
1363        );
1364
1365        let (mut world, cli) = test_world();
1366        let hub = InteractionHub::new();
1367        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1368        let restored = reload_persisted_agents(
1369            &mut world,
1370            crate::daemon::spawn::SpawnDeps {
1371                tool_service: cli.as_ref(),
1372                config: &Config::default(),
1373                shared_mcp: mcp,
1374                mcp_tool_defs: &[],
1375                hub: &hub,
1376                now_secs: 999,
1377                subagent_tx: sub_tx().clone(),
1378            },
1379            runs.path(),
1380        );
1381
1382        assert_eq!(restored.len(), 1);
1383        let entries = conversation_of(&world, restored[0].1.entity());
1384        // Exactly the persisted turn - no second copy, no interrupted synthesis.
1385        assert_eq!(
1386            entries
1387                .iter()
1388                .filter(|e| matches!(&e.kind, EntryKind::AssistantTurn { tool_calls } if !tool_calls.is_empty()))
1389                .count(),
1390            1
1391        );
1392        assert!(!entries.iter().any(|e| e.content.contains("interrupted")));
1393    }
1394
1395    /// A temp agent dir holding the interactive planning manifest, whose stage 0
1396    /// (`plan`) is an `interactive_points` stage with a `plan_approval` point.
1397    fn interactive_agent_dir() -> tempfile::TempDir {
1398        let dir = tempfile::tempdir().unwrap();
1399        std::fs::write(
1400            dir.path().join("agent.leviath"),
1401            crate::test_support::inline_interactive_manifest(),
1402        )
1403        .unwrap();
1404        dir
1405    }
1406
1407    #[tokio::test]
1408    async fn reload_resumes_a_blocked_interaction_point_in_the_waiting_state() {
1409        let agent = interactive_agent_dir();
1410        let manifest = agent.path().join("agent.leviath");
1411        let runs = tempfile::tempdir().unwrap();
1412
1413        // A run parked at the plan_approval interaction point (stage 0 = plan)...
1414        write_run(
1415            runs.path(),
1416            "run-await",
1417            manifest.to_str().unwrap(),
1418            RunStatus::WaitingInput,
1419            None,
1420        );
1421        // ...plus the interaction sidecar the daemon wrote while it was blocked.
1422        std::fs::write(
1423            runs.path().join("run-await/interactions.json"),
1424            serde_json::to_string(&InteractionPointState {
1425                cursor: 0,
1426                round: 0,
1427                body: "## Plan\n1. do it".to_string(),
1428            })
1429            .unwrap(),
1430        )
1431        .unwrap();
1432
1433        let (mut world, cli) = test_world();
1434        let hub = InteractionHub::new();
1435        world.insert_interaction_hub(hub.clone()); // restore reads the hub resource
1436        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1437        let restored = reload_persisted_agents(
1438            &mut world,
1439            crate::daemon::spawn::SpawnDeps {
1440                tool_service: cli.as_ref(),
1441                config: &Config::default(),
1442                shared_mcp: mcp,
1443                mcp_tool_defs: &[],
1444                hub: &hub,
1445                now_secs: 999,
1446                subagent_tx: sub_tx().clone(),
1447            },
1448            runs.path(),
1449        );
1450
1451        assert_eq!(restored.len(), 1);
1452        let (run_id, entity) = &restored[0];
1453        assert_eq!(run_id, "run-await");
1454        // Re-armed in the *waiting* state (not the default Active), so no inference
1455        // re-issues and the open prompt isn't dropped - the issue #38 fix.
1456        assert_eq!(world.agent_status(*entity), Some(AgentStatus::Waiting));
1457        assert!(
1458            world
1459                .world()
1460                .get::<leviath_runtime::interaction_points::AwaitingInteractionPoint>(
1461                    entity.entity()
1462                )
1463                .is_some()
1464        );
1465        assert!(
1466            world
1467                .world()
1468                .get::<leviath_runtime::pipeline::ReadyToInfer>(entity.entity())
1469                .is_none(),
1470            "the spawn-set ReadyToInfer is cleared so the inference lane won't fire"
1471        );
1472
1473        // The prompt was re-opened in the hub, carrying the reviewed plan.
1474        for _ in 0..8 {
1475            tokio::task::yield_now().await;
1476        }
1477        let pending = hub.pending();
1478        assert_eq!(pending.len(), 1);
1479        assert_eq!(pending[0].0, "run-await");
1480        assert_eq!(pending[0].1.body.as_deref(), Some("## Plan\n1. do it"));
1481    }
1482
1483    #[tokio::test]
1484    async fn reload_restores_actionable_runs_before_blocked_and_skips_terminal() {
1485        let agent = agent_dir();
1486        let mpath = agent.path().join("agent.leviath");
1487        let mpath = mpath.to_str().unwrap();
1488        let runs = tempfile::tempdir().unwrap();
1489        // Directory iteration order is unspecified; name the blocked run so it would
1490        // sort ahead alphabetically, proving the triage (not the filesystem) decides.
1491        write_run(
1492            runs.path(),
1493            "aaa-blocked",
1494            mpath,
1495            RunStatus::WaitingInput,
1496            None,
1497        );
1498        write_run(runs.path(), "zzz-active", mpath, RunStatus::Running, None);
1499        write_run(runs.path(), "mmm-done", mpath, RunStatus::Complete, None);
1500
1501        let (mut world, cli) = test_world();
1502        let hub = InteractionHub::new();
1503        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1504        let restored = reload_persisted_agents(
1505            &mut world,
1506            crate::daemon::spawn::SpawnDeps {
1507                tool_service: cli.as_ref(),
1508                config: &Config::default(),
1509                shared_mcp: mcp,
1510                mcp_tool_defs: &[],
1511                hub: &hub,
1512                now_secs: 999,
1513                subagent_tx: sub_tx().clone(),
1514            },
1515            runs.path(),
1516        );
1517
1518        // Terminal run skipped; the actionable (Running) run is restored first.
1519        let order: Vec<&str> = restored.iter().map(|(id, _)| id.as_str()).collect();
1520        assert_eq!(order, vec!["zzz-active", "aaa-blocked"]);
1521    }
1522
1523    #[tokio::test]
1524    async fn reload_run_pages_in_nonterminal_only() {
1525        let agent = agent_dir();
1526        let manifest = agent.path().join("agent.leviath");
1527        let mpath = manifest.to_str().unwrap();
1528        let runs = tempfile::tempdir().unwrap();
1529        write_run(runs.path(), "live", mpath, RunStatus::Running, None);
1530        write_run(runs.path(), "done", mpath, RunStatus::Complete, None);
1531
1532        let (mut world, cli) = test_world();
1533        let hub = InteractionHub::new();
1534        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1535
1536        // A non-terminal run is paged in.
1537        assert!(
1538            reload_run(
1539                &mut world,
1540                crate::daemon::spawn::SpawnDeps {
1541                    tool_service: cli.as_ref(),
1542                    config: &Config::default(),
1543                    shared_mcp: mcp.clone(),
1544                    mcp_tool_defs: &[],
1545                    hub: &hub,
1546                    now_secs: 1,
1547                    subagent_tx: sub_tx().clone(),
1548                },
1549                "live",
1550                runs.path(),
1551            )
1552            .is_some()
1553        );
1554        // A terminal run is not.
1555        assert!(
1556            reload_run(
1557                &mut world,
1558                crate::daemon::spawn::SpawnDeps {
1559                    tool_service: cli.as_ref(),
1560                    config: &Config::default(),
1561                    shared_mcp: mcp.clone(),
1562                    mcp_tool_defs: &[],
1563                    hub: &hub,
1564                    now_secs: 1,
1565                    subagent_tx: sub_tx().clone(),
1566                },
1567                "done",
1568                runs.path(),
1569            )
1570            .is_none()
1571        );
1572        // A run with no meta on disk is not.
1573        assert!(
1574            reload_run(
1575                &mut world,
1576                crate::daemon::spawn::SpawnDeps {
1577                    tool_service: cli.as_ref(),
1578                    config: &Config::default(),
1579                    shared_mcp: mcp,
1580                    mcp_tool_defs: &[],
1581                    hub: &hub,
1582                    now_secs: 1,
1583                    subagent_tx: sub_tx().clone(),
1584                },
1585                "no-such-run",
1586                runs.path(),
1587            )
1588            .is_none()
1589        );
1590    }
1591
1592    #[tokio::test]
1593    async fn resumes_a_parent_parked_mid_fan_out() {
1594        use leviath_core::blueprint::{FanOutConfig, WorkerFailurePolicy};
1595        use leviath_runtime::fanout::{FanOutState, FanOutWaiting};
1596
1597        let agent = agent_dir();
1598        let manifest = agent.path().join("agent.leviath");
1599        let mpath = manifest.to_str().unwrap();
1600        let runs = tempfile::tempdir().unwrap();
1601
1602        // A parent parked mid fan-out: a valid fanout.json alongside its meta.
1603        write_run(
1604            runs.path(),
1605            "parent-fo",
1606            mpath,
1607            RunStatus::WaitingInput,
1608            None,
1609        );
1610        let state = FanOutState {
1611            config: FanOutConfig {
1612                worker_agent: None,
1613                worker_stage: Some("w".to_string()),
1614                worker_query: None,
1615                merge_stage: None,
1616                max_workers: 1,
1617                on_worker_failure: WorkerFailurePolicy::Continue,
1618                split_prompt: "s".to_string(),
1619                results_region: None,
1620                max_items: None,
1621            },
1622            max_workers: 1,
1623            pending: vec![],
1624            // One in-flight worker, referenced by the run-id of another reloaded
1625            // run so the resolver maps it back to an entity on restore.
1626            active: vec![("item-1".to_string(), "worker-fo".to_string())],
1627            summaries: vec![],
1628            failures: vec![],
1629        };
1630        std::fs::write(
1631            runs.path().join("parent-fo").join("fanout.json"),
1632            serde_json::to_string(&state).unwrap(),
1633        )
1634        .unwrap();
1635        // The referenced worker run, so the active worker re-links to a real entity.
1636        write_run(runs.path(), "worker-fo", mpath, RunStatus::Running, None);
1637
1638        // A run with a malformed fanout.json → skipped (no FanOutWaiting).
1639        write_run(runs.path(), "bad-fo", mpath, RunStatus::WaitingInput, None);
1640        std::fs::write(runs.path().join("bad-fo").join("fanout.json"), b"garbage").unwrap();
1641
1642        let (mut world, cli) = test_world();
1643        let hub = InteractionHub::new();
1644        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1645        let restored = reload_persisted_agents(
1646            &mut world,
1647            crate::daemon::spawn::SpawnDeps {
1648                tool_service: cli.as_ref(),
1649                config: &Config::default(),
1650                shared_mcp: mcp,
1651                mcp_tool_defs: &[],
1652                hub: &hub,
1653                now_secs: 999,
1654                subagent_tx: sub_tx().clone(),
1655            },
1656            runs.path(),
1657        );
1658        let by_id: std::collections::HashMap<_, _> =
1659            restored.iter().map(|(r, e)| (r.clone(), *e)).collect();
1660
1661        // The parent's fan-out waiting state was rebuilt; the malformed one wasn't.
1662        assert!(
1663            world
1664                .world()
1665                .get::<FanOutWaiting>(by_id["parent-fo"].entity())
1666                .is_some()
1667        );
1668        assert!(
1669            world
1670                .world()
1671                .get::<FanOutWaiting>(by_id["bad-fo"].entity())
1672                .is_none()
1673        );
1674    }
1675
1676    #[tokio::test]
1677    async fn rebuilds_parent_child_tree_on_reload() {
1678        use leviath_runtime::components::{ParentRef, SubAgentChildren};
1679
1680        let agent = agent_dir();
1681        let manifest = agent.path().join("agent.leviath");
1682        let mpath = manifest.to_str().unwrap();
1683        let runs = tempfile::tempdir().unwrap();
1684
1685        // A parent with two children + a child that records its parent + depth.
1686        write_run_tree(RunFixture {
1687            runs_dir: runs.path(),
1688            run_id: "parent",
1689            agent_path: mpath,
1690            status: RunStatus::WaitingInput,
1691            context: None,
1692            parent_run_id: None,
1693            children: &["child-a", "child-b"],
1694            depth: 0,
1695            max_child_depth: 4,
1696        });
1697        write_run_tree(RunFixture {
1698            runs_dir: runs.path(),
1699            run_id: "child-a",
1700            agent_path: mpath,
1701            status: RunStatus::Running,
1702            context: None,
1703            parent_run_id: Some("parent"),
1704            children: &[],
1705            depth: 1,
1706            max_child_depth: 0,
1707        });
1708        write_run_tree(RunFixture {
1709            runs_dir: runs.path(),
1710            run_id: "child-b",
1711            agent_path: mpath,
1712            status: RunStatus::Running,
1713            context: None,
1714            parent_run_id: Some("parent"),
1715            children: &[],
1716            depth: 1,
1717            max_child_depth: 0,
1718        });
1719
1720        let (mut world, cli) = test_world();
1721        let hub = InteractionHub::new();
1722        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1723        let restored = reload_persisted_agents(
1724            &mut world,
1725            crate::daemon::spawn::SpawnDeps {
1726                tool_service: cli.as_ref(),
1727                config: &Config::default(),
1728                shared_mcp: mcp,
1729                mcp_tool_defs: &[],
1730                hub: &hub,
1731                now_secs: 999,
1732                subagent_tx: sub_tx().clone(),
1733            },
1734            runs.path(),
1735        );
1736        assert_eq!(restored.len(), 3);
1737        let by_id: std::collections::HashMap<_, _> =
1738            restored.iter().map(|(r, e)| (r.clone(), *e)).collect();
1739        let parent = by_id["parent"];
1740        let child_a = by_id["child-a"];
1741        let child_b = by_id["child-b"];
1742
1743        // Parent's SubAgentChildren rebuilt with both children + the depth cap.
1744        let kids = world
1745            .world()
1746            .get::<SubAgentChildren>(parent.entity())
1747            .unwrap();
1748        assert_eq!(kids.max_child_depth, 4);
1749        assert_eq!(kids.children.len(), 2);
1750        assert!(
1751            kids.children.contains(&child_a.entity()) && kids.children.contains(&child_b.entity())
1752        );
1753        // Each child's ParentRef points back at the parent, at its stored depth.
1754        let pr = world.world().get::<ParentRef>(child_a.entity()).unwrap();
1755        assert_eq!(pr.parent_entity, parent.entity());
1756        assert_eq!(pr.parent_agent_id, "parent");
1757        assert_eq!(pr.depth, 1);
1758        // The serializable child list is kept in sync for the next snapshot.
1759        let state = world
1760            .world()
1761            .get::<leviath_runtime::components::AgentState>(parent.entity())
1762            .unwrap();
1763        assert_eq!(state.spawned_children_ids, vec!["child-a", "child-b"]);
1764    }
1765
1766    #[tokio::test]
1767    async fn relink_skips_children_and_parents_that_did_not_reload() {
1768        use leviath_runtime::components::{ParentRef, SubAgentChildren};
1769
1770        let agent = agent_dir();
1771        let manifest = agent.path().join("agent.leviath");
1772        let mpath = manifest.to_str().unwrap();
1773        let runs = tempfile::tempdir().unwrap();
1774
1775        // Parent lists a child that is terminal (won't reload) → no SubAgentChildren.
1776        write_run_tree(RunFixture {
1777            runs_dir: runs.path(),
1778            run_id: "lonely-parent",
1779            agent_path: mpath,
1780            status: RunStatus::WaitingInput,
1781            context: None,
1782            parent_run_id: None,
1783            children: &["gone-child"],
1784            depth: 0,
1785            max_child_depth: 2,
1786        });
1787        write_run_tree(RunFixture {
1788            runs_dir: runs.path(),
1789            run_id: "gone-child",
1790            agent_path: mpath,
1791            status: RunStatus::Complete,
1792            context: // terminal → skipped by recovery
1793            None,
1794            parent_run_id: Some("lonely-parent"),
1795            children: &[],
1796            depth: 1,
1797            max_child_depth: 0,
1798        });
1799        // Child whose parent is terminal (won't reload) → left unlinked.
1800        write_run_tree(RunFixture {
1801            runs_dir: runs.path(),
1802            run_id: "orphan",
1803            agent_path: mpath,
1804            status: RunStatus::Running,
1805            context: None,
1806            parent_run_id: Some("gone-parent"),
1807            children: &[],
1808            depth: 1,
1809            max_child_depth: 0,
1810        });
1811        write_run_tree(RunFixture {
1812            runs_dir: runs.path(),
1813            run_id: "gone-parent",
1814            agent_path: mpath,
1815            status: RunStatus::Error,
1816            context: None,
1817            parent_run_id: None,
1818            children: &["orphan"],
1819            depth: 0,
1820            max_child_depth: 2,
1821        });
1822
1823        let (mut world, cli) = test_world();
1824        let hub = InteractionHub::new();
1825        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1826        let restored = reload_persisted_agents(
1827            &mut world,
1828            crate::daemon::spawn::SpawnDeps {
1829                tool_service: cli.as_ref(),
1830                config: &Config::default(),
1831                shared_mcp: mcp,
1832                mcp_tool_defs: &[],
1833                hub: &hub,
1834                now_secs: 999,
1835                subagent_tx: sub_tx().clone(),
1836            },
1837            runs.path(),
1838        );
1839        // Only the two non-terminal runs reload.
1840        assert_eq!(restored.len(), 2);
1841        let by_id: std::collections::HashMap<_, _> =
1842            restored.iter().map(|(r, e)| (r.clone(), *e)).collect();
1843        // Parent listed a child that didn't reload → no SubAgentChildren attached.
1844        assert!(
1845            world
1846                .world()
1847                .get::<SubAgentChildren>(by_id["lonely-parent"].entity())
1848                .is_none()
1849        );
1850        // Orphan's parent didn't reload → no ParentRef attached.
1851        assert!(
1852            world
1853                .world()
1854                .get::<ParentRef>(by_id["orphan"].entity())
1855                .is_none()
1856        );
1857    }
1858
1859    #[tokio::test]
1860    async fn reload_without_context_json_still_resumes() {
1861        let agent = agent_dir();
1862        let manifest = agent.path().join("agent.leviath");
1863        let runs = tempfile::tempdir().unwrap();
1864        write_run(
1865            runs.path(),
1866            "run-nocontext",
1867            manifest.to_str().unwrap(),
1868            RunStatus::WaitingInput,
1869            None, // no context.json
1870        );
1871
1872        let (mut world, cli) = test_world();
1873        let hub = InteractionHub::new();
1874        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1875        let restored = reload_persisted_agents(
1876            &mut world,
1877            crate::daemon::spawn::SpawnDeps {
1878                tool_service: cli.as_ref(),
1879                config: &Config::default(),
1880                shared_mcp: mcp,
1881                mcp_tool_defs: &[],
1882                hub: &hub,
1883                now_secs: 999,
1884                subagent_tx: sub_tx().clone(),
1885            },
1886            runs.path(),
1887        );
1888        assert_eq!(restored.len(), 1);
1889        assert!(
1890            world
1891                .world()
1892                .get::<TokenTotals>(restored[0].1.entity())
1893                .is_some()
1894        );
1895    }
1896
1897    /// A restart used to bring every stage back at zero: nothing rebuilt the
1898    /// ledger from `stages.json`, so the blueprint-seeded (all-zero) one was
1899    /// written straight over the real file on the next persist tick, and the
1900    /// run's whole per-stage history went with it while `meta.json` still
1901    /// looked healthy (issue #415).
1902    /// A run parked until the machine is fixed keeps its reason across a
1903    /// daemon restart.
1904    ///
1905    /// The marker is a live component, so without restoring it the run comes
1906    /// back as a bare `Paused` and the next persist tick recomputes
1907    /// `waiting_on` from markers that are gone - writing `null` over the
1908    /// reason rather than merely failing to show it. Nothing recomputes it
1909    /// either, because a paused run is never dispatched.
1910    #[tokio::test]
1911    async fn reload_keeps_the_reason_a_parked_run_was_parked_for() {
1912        use leviath_core::run_meta::{SetupBlocker, WaitReason};
1913
1914        let agent = agent_dir();
1915        let manifest = agent.path().join("agent.leviath");
1916        let runs = tempfile::tempdir().unwrap();
1917        write_run(
1918            runs.path(),
1919            "run-parked",
1920            manifest.to_str().unwrap(),
1921            RunStatus::Paused,
1922            None,
1923        );
1924        // Rewrite meta with the reason the run stopped for.
1925        let meta_path = runs.path().join("run-parked").join("meta.json");
1926        let mut meta: leviath_core::run_meta::RunMeta =
1927            serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
1928        meta.waiting_on = Some(WaitReason::NeedsSetup {
1929            blocker: SetupBlocker::ProviderMissing,
1930            remedy: "add it to config.toml".to_string(),
1931        });
1932        std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
1933
1934        let (mut world, cli) = test_world();
1935        let hub = InteractionHub::new();
1936        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1937        let restored = reload_persisted_agents(
1938            &mut world,
1939            crate::daemon::spawn::SpawnDeps {
1940                tool_service: cli.as_ref(),
1941                config: &Config::default(),
1942                shared_mcp: mcp,
1943                mcp_tool_defs: &[],
1944                hub: &hub,
1945                now_secs: 999,
1946                subagent_tx: sub_tx().clone(),
1947            },
1948            runs.path(),
1949        );
1950        assert_eq!(restored.len(), 1);
1951
1952        let parked = world
1953            .world()
1954            .get::<leviath_runtime::pipeline::PausedForSetup>(restored[0].1.entity())
1955            .expect("the reason came back with the run");
1956        assert_eq!(parked.blocker, SetupBlocker::ProviderMissing);
1957        assert_eq!(parked.remedy, "add it to config.toml");
1958    }
1959
1960    #[tokio::test]
1961    async fn reload_restores_the_persisted_stage_ledger() {
1962        use leviath_core::run_meta::{StageRecord, StageRunStatus};
1963        use leviath_runtime::pipeline::StageLedger;
1964
1965        let agent = agent_dir();
1966        let manifest = agent.path().join("agent.leviath");
1967        let runs = tempfile::tempdir().unwrap();
1968        write_run(
1969            runs.path(),
1970            "run-stages",
1971            manifest.to_str().unwrap(),
1972            RunStatus::Running,
1973            None,
1974        );
1975        // The ledger as the run left it: `analyze` ran and finished, the run
1976        // stopped in `implement`, `review` was never reached. The trailing
1977        // record names a stage this blueprint no longer has.
1978        let mut analyze = StageRecord::new("analyze".to_string(), 0);
1979        analyze.status = StageRunStatus::Complete;
1980        analyze.entered = true;
1981        analyze.prompt_tokens = 1_234;
1982        analyze.completion_tokens = 56;
1983        analyze.cached_tokens = 7;
1984        analyze.cache_write_tokens = 8;
1985        analyze.first_call_prompt_tokens = Some(400);
1986        analyze.runaway_warned = true;
1987        analyze
1988            .region_tokens
1989            .insert("conversation".to_string(), 900);
1990        analyze.started_at = Some(10);
1991        analyze.ended_at = Some(20);
1992        let mut implement = StageRecord::new("implement".to_string(), 1);
1993        implement.status = StageRunStatus::Active;
1994        implement.entered = true;
1995        implement.prompt_tokens = 77;
1996        implement.started_at = Some(20);
1997        let removed = StageRecord::new("removed_stage".to_string(), 7);
1998        std::fs::write(
1999            runs.path().join("run-stages").join("stages.json"),
2000            serde_json::to_string(&vec![analyze, implement, removed]).unwrap(),
2001        )
2002        .unwrap();
2003
2004        let (mut world, cli) = test_world();
2005        let hub = InteractionHub::new();
2006        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
2007        let restored = reload_persisted_agents(
2008            &mut world,
2009            crate::daemon::spawn::SpawnDeps {
2010                tool_service: cli.as_ref(),
2011                config: &Config::default(),
2012                shared_mcp: mcp,
2013                mcp_tool_defs: &[],
2014                hub: &hub,
2015                now_secs: 999,
2016                subagent_tx: sub_tx().clone(),
2017            },
2018            runs.path(),
2019        );
2020        assert_eq!(restored.len(), 1);
2021
2022        let ledger = world
2023            .world()
2024            .get::<StageLedger>(restored[0].1.entity())
2025            .expect("a reloaded agent carries a stage ledger");
2026        // One record per blueprint stage, in blueprint order: the record for a
2027        // stage that no longer exists is dropped rather than appended.
2028        let names: Vec<&str> = ledger.0.iter().map(|r| r.name.as_str()).collect();
2029        assert_eq!(names, vec!["analyze", "implement", "review"]);
2030        assert_eq!(ledger.0[0].prompt_tokens, 1_234);
2031        assert_eq!(ledger.0[0].completion_tokens, 56);
2032        assert_eq!(ledger.0[0].cached_tokens, 7);
2033        assert_eq!(ledger.0[0].cache_write_tokens, 8);
2034        assert_eq!(ledger.0[0].first_call_prompt_tokens, Some(400));
2035        assert!(ledger.0[0].runaway_warned);
2036        assert_eq!(ledger.0[0].region_tokens.get("conversation"), Some(&900));
2037        assert_eq!(ledger.0[0].started_at, Some(10));
2038        assert_eq!(ledger.0[0].ended_at, Some(20));
2039        assert_eq!(ledger.0[0].status, StageRunStatus::Complete);
2040        assert!(ledger.0[0].entered);
2041        assert_eq!(ledger.0[1].prompt_tokens, 77);
2042        assert!(ledger.0[1].entered);
2043        // A stage with nothing persisted against it keeps the seeded record.
2044        assert_eq!(ledger.0[2].prompt_tokens, 0);
2045        assert!(!ledger.0[2].entered);
2046        assert_eq!(ledger.0[2].index, 2);
2047    }
2048
2049    #[tokio::test]
2050    async fn skips_missing_dir_junk_and_unreloadable_runs() {
2051        // A runs dir that doesn't exist → empty.
2052        let (mut world, cli) = test_world();
2053        let hub = InteractionHub::new();
2054        let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
2055        assert!(
2056            reload_persisted_agents(
2057                &mut world,
2058                crate::daemon::spawn::SpawnDeps {
2059                    tool_service: cli.as_ref(),
2060                    config: &Config::default(),
2061                    shared_mcp: mcp.clone(),
2062                    mcp_tool_defs: &[],
2063                    hub: &hub,
2064                    now_secs: 1,
2065                    subagent_tx: sub_tx().clone(),
2066                },
2067                std::path::Path::new("/no/such/runs/dir"),
2068            )
2069            .is_empty()
2070        );
2071
2072        // A runs dir with junk: a dir without meta.json, a dir with corrupt
2073        // meta.json, and a non-terminal run pointing at a missing blueprint.
2074        let runs = tempfile::tempdir().unwrap();
2075        std::fs::create_dir_all(runs.path().join("no-meta")).unwrap();
2076        let corrupt = runs.path().join("corrupt");
2077        std::fs::create_dir_all(&corrupt).unwrap();
2078        std::fs::write(corrupt.join("meta.json"), "not json").unwrap();
2079        write_run(
2080            runs.path(),
2081            "run-badpath",
2082            "/no/such/agent.leviath",
2083            RunStatus::Running,
2084            None,
2085        );
2086
2087        let restored = reload_persisted_agents(
2088            &mut world,
2089            crate::daemon::spawn::SpawnDeps {
2090                tool_service: cli.as_ref(),
2091                config: &Config::default(),
2092                shared_mcp: mcp,
2093                mcp_tool_defs: &[],
2094                hub: &hub,
2095                now_secs: 1,
2096                subagent_tx: sub_tx().clone(),
2097            },
2098            runs.path(),
2099        );
2100        assert!(restored.is_empty()); // all skipped, none fatal
2101
2102        // The un-reloadable run is recorded as crashed rather than left claiming
2103        // it is still running (issue #109) - `lev ps` and the dashboard would
2104        // otherwise show a live run that no longer exists.
2105        let meta: RunMeta = serde_json::from_str(
2106            &std::fs::read_to_string(runs.path().join("run-badpath").join("meta.json")).unwrap(),
2107        )
2108        .unwrap();
2109        assert_eq!(meta.status, RunStatus::Error);
2110        let error = meta.error.unwrap_or_default();
2111        assert!(error.contains("could not be recovered"), "got: {error}");
2112        assert_eq!(meta.updated_at, 1);
2113        // Junk that never parsed as a run has nothing to rewrite.
2114        assert!(!runs.path().join("no-meta").join("meta.json").exists());
2115        assert_eq!(
2116            std::fs::read_to_string(corrupt.join("meta.json")).unwrap(),
2117            "not json"
2118        );
2119    }
2120
2121    #[test]
2122    fn marking_a_crash_is_best_effort() {
2123        // The run directory can vanish between the scan and the rewrite (a
2124        // concurrent `lev rm`, a wiped runs dir). Recovery must log and carry
2125        // on - the daemon is mid-startup and the other runs still need it.
2126        let runs = tempfile::tempdir().unwrap();
2127        write_run(
2128            runs.path(),
2129            "run-x",
2130            "/no/such/agent.leviath",
2131            RunStatus::Running,
2132            None,
2133        );
2134        let meta = read_meta(&runs.path().join("run-x")).expect("written above");
2135        mark_crashed(&runs.path().join("gone"), meta, "boom", 7);
2136        assert!(!runs.path().join("gone").exists());
2137    }
2138
2139    #[tokio::test]
2140    async fn fake_provider_methods_are_exercised() {
2141        use leviath_providers::Provider;
2142        let p = FakeProvider;
2143        assert_eq!(p.name(), "fake");
2144        assert_eq!(p.count_tokens("t", "m").await, 1);
2145        assert_eq!(p.max_context_tokens("m"), 1000);
2146        let _ = p.capabilities("m");
2147        assert!(
2148            p.infer(&leviath_providers::InferenceRequest {
2149                system: vec![],
2150                messages: vec![],
2151                model: "m".to_string(),
2152                max_tokens: 1,
2153                temperature: 0.0,
2154                tools: vec![],
2155                extra: serde_json::Value::Null,
2156                request_timeout_secs: None,
2157            })
2158            .await
2159            .is_err()
2160        );
2161    }
2162
2163    #[test]
2164    fn is_terminal_covers_all_statuses() {
2165        assert!(is_terminal(&RunStatus::Complete));
2166        assert!(is_terminal(&RunStatus::Cancelled));
2167        assert!(is_terminal(&RunStatus::Error));
2168        assert!(!is_terminal(&RunStatus::Running));
2169        assert!(!is_terminal(&RunStatus::WaitingInput));
2170    }
2171}