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