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