Skip to main content

leviath_runtime/
restore.rs

1//! Restart recovery: bring a freshly-spawned agent back to its persisted running
2//! state so the daemon resumes it where it stopped.
3//!
4//! When the daemon restarts, the CLI reloads each non-terminal run's blueprint
5//! and spawns a fresh agent, then calls [`restore_agent`] to overlay the persisted
6//! context, jump to the persisted stage + iteration, and restore token totals. The
7//! agent keeps the `ReadyToInfer` marker `spawn_agent` set, so **any inference
8//! that was in flight when the daemon stopped is re-issued** on the next tick -
9//! nothing is left stuck awaiting a job that died with the old process.
10//!
11//! A tool batch that was in flight is not blindly re-issued, though: when the run
12//! journal holds a dispatched-but-unapplied batch, [`restore_pending_batch`]
13//! reconstructs its assistant turn in the window first - real journaled results
14//! for calls that completed, a verify-first [`INTERRUPTED_TOOL_RESULT`] for calls
15//! that didn't - so the re-issued inference sees exactly what already ran and
16//! completed side effects never run twice (issue #96).
17
18use bevy_ecs::prelude::*;
19use leviath_core::region::RegionEntry;
20use leviath_core::run_meta::{ContextSnapshot, RunMeta, RunStatus};
21
22use crate::components::{AgentState, AgentStatus, ContextWindow};
23use crate::persistence::TokenTotals;
24use crate::pipeline::{StageCursor, StageInferences, StageSetups};
25
26/// How urgently a persisted run should be brought back on restart. Ordered so a
27/// higher value restores first (see [`triage_restores`]).
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
29pub enum RestorePriority {
30    /// Restorable, but can make no immediate progress: blocked on user input,
31    /// done-but-interactive (awaiting optional follow-up), or a parent parked mid
32    /// fan-out waiting on its children. Brought back after the actionable runs.
33    Blocked,
34    /// Actionable now: an in-flight inference to re-dispatch or pending tool
35    /// results to process. These resume real work the moment they're reloaded, so
36    /// they come back first.
37    Active,
38}
39
40/// Classify one persisted run for restart recovery from its on-disk status and
41/// whether it is parked mid fan-out (a `<run_dir>/fanout.json` is present).
42///
43/// Returns `None` for a **terminal** run (`Complete` / `Error` / `Cancelled`) -
44/// those are never resumed. A run parked on a fan-out is [`Blocked`] regardless of
45/// its status: it can't progress until its children finish.
46///
47/// [`Blocked`]: RestorePriority::Blocked
48pub fn classify_restore(status: &RunStatus, parked_on_fanout: bool) -> Option<RestorePriority> {
49    match status {
50        RunStatus::Complete | RunStatus::Error | RunStatus::Cancelled => None,
51        _ if parked_on_fanout => Some(RestorePriority::Blocked),
52        RunStatus::Starting | RunStatus::Running => Some(RestorePriority::Active),
53        RunStatus::WaitingInput | RunStatus::CompleteInteractive | RunStatus::Paused => {
54            Some(RestorePriority::Blocked)
55        }
56    }
57}
58
59/// Triage a set of persisted runs into the order they should be restored on
60/// restart: drop terminal runs, then rank the rest **actionable-first**
61/// ([`RestorePriority::Active`] before [`Blocked`]), breaking ties by most-recently
62/// updated. Each input is `(meta, parked_on_fanout)` where `parked_on_fanout` is
63/// whether the run has a `fanout.json` (see [`classify_restore`]); the returned
64/// [`RunMeta`]s are ready to reload in order.
65///
66/// This lets a resource- or time-constrained caller restore only a prefix (the most
67/// actionable agents) and still make the most progress possible.
68///
69/// [`Blocked`]: RestorePriority::Blocked
70pub fn triage_restores(candidates: Vec<(RunMeta, bool)>) -> Vec<RunMeta> {
71    let mut ranked: Vec<(RestorePriority, RunMeta)> = candidates
72        .into_iter()
73        .filter_map(|(meta, parked)| {
74            classify_restore(&meta.status, parked).map(|prio| (prio, meta))
75        })
76        .collect();
77    // Higher priority first; within a tier, most-recently updated first. `sort_by`
78    // is stable, so equal keys keep their scan order.
79    ranked.sort_by(|(a_prio, a), (b_prio, b)| {
80        b_prio
81            .cmp(a_prio)
82            .then_with(|| b.updated_at.cmp(&a.updated_at))
83    });
84    ranked.into_iter().map(|(_, meta)| meta).collect()
85}
86
87/// Restore a just-spawned `entity` to the persisted state captured in `snapshot`
88/// (its context), `stage_index` + `iteration` (its position), and `totals` (its
89/// running token/tool counts). The agent stays `Active` + `ReadyToInfer` so it
90/// resumes on the next tick.
91///
92/// Context is overlaid by region **name**: each persisted region replaces the
93/// matching window region's entries (rebuilt from the blueprint layout, so region
94/// kinds/limits are correct). A persisted region with no matching window region
95/// is skipped. An out-of-range `stage_index` (e.g. the blueprint gained/lost
96/// stages) leaves the spawned stage-0 config in place.
97pub fn restore_agent(
98    world: &mut World,
99    entity: Entity,
100    snapshot: &ContextSnapshot,
101    stage_index: usize,
102    iteration: usize,
103    totals: TokenTotals,
104) {
105    // 1. Overlay the persisted context onto the (blueprint-built) window.
106    {
107        let mut window = world
108            .get_mut::<ContextWindow>(entity)
109            .expect("a spawned agent has a context window");
110        for snap_region in &snapshot.regions {
111            if let Some(region) = window
112                .regions
113                .iter_mut()
114                .find(|r| r.name == snap_region.name)
115            {
116                region.content = snap_region
117                    .entries
118                    .iter()
119                    .map(|e| RegionEntry {
120                        content: e.content.clone(),
121                        tokens: e.tokens,
122                        timestamp: 0,
123                        metadata: e.metadata.clone(),
124                        kind: e.kind.clone(),
125                        key: e.key.clone(),
126                    })
127                    .collect();
128                // Rebuild the taint alongside the content. Assigning `content`
129                // directly bypasses `add_tainted_entry`, which is the only thing
130                // that records per-entry taint - so without this the region came
131                // back `Public` no matter how sensitive it had been, while the
132                // gate reported itself armed.
133                // Only where the region already tracks taint: restoring it onto
134                // a region with tracking off would invent a level nothing reads.
135                if region.taint.is_some() {
136                    region.taint = Some(leviath_core::taint::RegionTaint::from_entry_taints(
137                        snap_region.entries.iter().map(|e| e.taint).collect(),
138                    ));
139                }
140                region.current_tokens = region.content.iter().map(|e| e.tokens).sum();
141            }
142        }
143        window.current_tokens = window.calculate_tokens();
144    }
145
146    // 2. Jump to the persisted stage, swapping in its inference config and
147    //    tool-result routing.
148    if let Some(inf) = world
149        .get::<StageInferences>(entity)
150        .expect("a spawned agent has stage inferences")
151        .0
152        .get(stage_index)
153        .cloned()
154    {
155        let setup = &world
156            .get::<StageSetups>(entity)
157            .expect("a spawned agent has stage setups")
158            .0[stage_index];
159        let cfg = setup.inference_config.clone();
160        let routing = setup.routing.clone();
161        world.entity_mut(entity).insert((inf, cfg));
162        // Mirror `attach_stage_components`' routing arm: present ⇒ insert,
163        // absent ⇒ clear the stale one. Without this a reloaded agent kept the
164        // spawn stage's routing (or none) for every future tool batch.
165        match routing {
166            Some(routing) => {
167                world
168                    .entity_mut(entity)
169                    .insert(crate::components::ToolResultRoutingComponent { routing });
170            }
171            None => {
172                world
173                    .entity_mut(entity)
174                    .remove::<crate::components::ToolResultRoutingComponent>();
175            }
176        }
177        world
178            .get_mut::<StageCursor>(entity)
179            .expect("a spawned agent has a stage cursor")
180            .index = stage_index;
181    }
182
183    // 3. Restore the agent's running state + token totals.
184    {
185        let mut state = world
186            .get_mut::<AgentState>(entity)
187            .expect("a spawned agent has state");
188        state.current_stage = snapshot.stage_name.clone();
189        state.iteration = iteration;
190        state.status = AgentStatus::Active;
191    }
192    world.entity_mut(entity).insert(totals);
193}
194
195/// The synthesized result for a call whose completion never reached the journal.
196/// It tells the model plainly that the effect may or may not have landed, so the
197/// re-issued turn verifies before re-running side-effecting work.
198pub const INTERRUPTED_TOOL_RESULT: &str = "[error] interrupted: the daemon restarted while this tool call was executing and its \
199     result was lost. Verify whether it took effect before re-running side-effecting work.";
200
201/// The synthesized result for one interrupted call: the base text, plus - for a
202/// sub-agent tool on a run with known children - the child runs to check before
203/// spawning again. Mechanical dedupe is impossible here (the model mints a fresh
204/// call id when it re-issues), so informed re-issue is the guarantee.
205fn interrupted_result(tool_name: &str, children: &[String]) -> String {
206    if leviath_tools::is_subagent_tool(tool_name) && !children.is_empty() {
207        format!(
208            "{INTERRUPTED_TOOL_RESULT} This run already has child agent runs: {}; check them \
209             with check_agent before spawning again.",
210            children.join(", ")
211        )
212    } else {
213        INTERRUPTED_TOOL_RESULT.to_string()
214    }
215}
216
217/// Replay a tool batch that was dispatched but never applied before the crash
218/// (folded from the run journal as a
219/// [`PendingToolBatch`](leviath_core::run_archive::PendingToolBatch)): land the
220/// assistant turn plus one result per call in the context window, exactly as
221/// `apply_tool_results` would have - real journaled results for calls that
222/// finished, [`INTERRUPTED_TOOL_RESULT`] for calls that didn't. The turn is
223/// always fully paired, so the request assembler's orphan sanitizer keeps it,
224/// and the re-issued inference sees precisely what already ran instead of
225/// blindly re-executing the whole batch (issue #96).
226///
227/// Call after [`restore_agent`], which swaps the restored stage's
228/// `ToolResultRoutingComponent` in - the routing and per-tool sensitivities are
229/// read off the entity so replayed results route and taint like live ones.
230/// `children` is the run's known child-run ids (`meta.children`), folded into
231/// the synthesized text of interrupted sub-agent calls. Secondary bookkeeping
232/// (modification counters, telemetry, file tracking, log lines) is deliberately
233/// skipped: totals and outcome flags are already restored from the persisted
234/// metadata, and the dead process's calls have no live stage to report to.
235pub fn restore_pending_batch(
236    world: &mut World,
237    entity: Entity,
238    batch: &leviath_core::run_archive::PendingToolBatch,
239    children: &[String],
240) {
241    let calls: Vec<crate::components::ToolCall> = batch
242        .calls
243        .iter()
244        .map(|c| crate::components::ToolCall {
245            tool_id: c.id.clone(),
246            name: c.name.clone(),
247            // Journaled arguments are stringified JSON; a record that doesn't
248            // parse (torn write) survives as a raw string rather than dropping
249            // the call and orphaning the turn.
250            arguments: serde_json::from_str(&c.arguments)
251                .unwrap_or_else(|_| serde_json::Value::String(c.arguments.clone())),
252            thought_signature: c.thought_signature.clone(),
253        })
254        .collect();
255    let merged: Vec<(String, String)> = batch
256        .calls
257        .iter()
258        .map(|c| {
259            let result = c
260                .result
261                .clone()
262                .unwrap_or_else(|| interrupted_result(&c.name, children));
263            (c.id.clone(), result)
264        })
265        .collect();
266    let routing = world
267        .get::<crate::components::ToolResultRoutingComponent>(entity)
268        .map(|c| c.routing.clone());
269    let sensitivities = world
270        .get::<crate::pipeline::ToolSensitivities>(entity)
271        .map(|s| s.0.clone());
272    let mut window = world
273        .get_mut::<ContextWindow>(entity)
274        .expect("a spawned agent has a context window");
275    crate::pipeline::apply_tool_results(
276        &mut window,
277        &batch.response,
278        &calls,
279        &merged,
280        routing.as_ref(),
281        sensitivities.as_ref(),
282    );
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::components::InferenceConfig;
289    use crate::pipeline::{ReadyToInfer, StageInference, StageSetup};
290    use leviath_core::region::EntryKind;
291    use leviath_core::run_meta::{RegionEntrySnapshot, RegionSnapshot};
292    use leviath_core::{Region, RegionKind};
293
294    fn setup(temp: Option<f32>) -> StageSetup {
295        StageSetup {
296            inference_config: InferenceConfig {
297                temperature: temp,
298                max_output_tokens: None,
299                extra_params: Default::default(),
300                batch_tool_hint: false,
301                shell_hint: false,
302                request_timeout_secs: None,
303            },
304            routing: None,
305            accepts_messages: true,
306            context_layout: None,
307            system_prompt: None,
308            output: None,
309        }
310    }
311
312    fn si(model: &str) -> StageInference {
313        StageInference {
314            provider_name: "p".to_string(),
315            model: model.to_string(),
316            tools: vec![],
317            tool_filter: None,
318            fallbacks: Vec::new(),
319            output: None,
320        }
321    }
322
323    /// A world with one spawned-looking agent: a `conversation` region window,
324    /// two stages, cursor at 0, `ReadyToInfer`.
325    fn agent_world() -> (World, Entity) {
326        let mut world = World::new();
327        let mut window = ContextWindow::new(10_000);
328        window.add_region(Region::new(
329            "conversation".to_string(),
330            RegionKind::Clearable,
331            10_000,
332        ));
333        let _ = window.add_to_region("conversation", "fresh task seed".to_string(), 3);
334        let entity = world
335            .spawn((
336                window,
337                StageCursor { index: 0 },
338                AgentState {
339                    agent_id: "a".to_string(),
340                    current_stage: "s0".to_string(),
341                    iteration: 0,
342                    status: AgentStatus::Active,
343                    spawned_children_ids: vec![],
344                    pending_wait: None,
345                    accepts_messages: true,
346                },
347                StageInferences(vec![si("m0"), si("m1")]),
348                StageSetups(vec![setup(None), setup(Some(0.5))]),
349                si("m0"),
350                setup(None).inference_config,
351                TokenTotals::default(),
352                ReadyToInfer,
353            ))
354            .id();
355        (world, entity)
356    }
357
358    fn snapshot() -> ContextSnapshot {
359        ContextSnapshot {
360            stage_name: "s1".to_string(),
361            total_tokens: 8,
362            max_tokens: 10_000,
363            regions: vec![
364                RegionSnapshot {
365                    name: "conversation".to_string(),
366                    kind: "clearable".to_string(),
367                    current_tokens: 8,
368                    max_tokens: 10_000,
369                    entries: vec![
370                        RegionEntrySnapshot {
371                            content: "prior user turn".to_string(),
372                            tokens: 5,
373                            kind: EntryKind::UserMessage,
374                            metadata: None,
375                            key: None,
376                            taint: Default::default(),
377                        },
378                        RegionEntrySnapshot {
379                            content: "prior assistant".to_string(),
380                            tokens: 3,
381                            kind: EntryKind::AssistantTurn { tool_calls: vec![] },
382                            metadata: None,
383                            key: None,
384                            taint: Default::default(),
385                        },
386                    ],
387                },
388                // A region that no longer exists in the window - skipped.
389                RegionSnapshot {
390                    name: "ghost".to_string(),
391                    kind: "pinned".to_string(),
392                    current_tokens: 1,
393                    max_tokens: 10,
394                    entries: vec![RegionEntrySnapshot {
395                        content: "orphan".to_string(),
396                        tokens: 1,
397                        kind: EntryKind::Text,
398                        metadata: None,
399                        key: None,
400                        taint: Default::default(),
401                    }],
402                },
403            ],
404        }
405    }
406
407    /// Taint was not persisted at all, so a restart, resume or page-in brought
408    /// every region back `Public` no matter how sensitive it had been - while
409    /// the gate went on reporting itself armed. It is rebuilt from the entries,
410    /// and only where the region already tracks taint: restoring a level onto a
411    /// region with tracking off would invent one nothing reads.
412    #[test]
413    fn restore_rebuilds_region_taint_from_the_persisted_entries() {
414        use leviath_core::taint::TaintLevel;
415
416        let mut snap = snapshot();
417        snap.regions[0].entries[0].taint = TaintLevel::Private;
418        snap.regions[0].entries[1].taint = TaintLevel::Public;
419
420        // Tracking off: the region stays untainted rather than gaining a level.
421        let (mut world, entity) = agent_world();
422        restore_agent(&mut world, entity, &snap, 1, 7, TokenTotals::default());
423        assert!(
424            world
425                .get::<ContextWindow>(entity)
426                .unwrap()
427                .get_region("conversation")
428                .unwrap()
429                .taint
430                .is_none()
431        );
432
433        // Tracking on: the level comes back, per entry and in aggregate.
434        let (mut world, entity) = agent_world();
435        world
436            .get_mut::<ContextWindow>(entity)
437            .unwrap()
438            .get_region_mut("conversation")
439            .unwrap()
440            .enable_taint_tracking();
441        restore_agent(&mut world, entity, &snap, 1, 7, TokenTotals::default());
442
443        let window = world.get::<ContextWindow>(entity).unwrap();
444        let region = window.get_region("conversation").unwrap();
445        assert_eq!(region.taint_level(), Some(TaintLevel::Private));
446        let taint = region.taint.as_ref().unwrap();
447        assert_eq!(taint.entry_taint(0), Some(TaintLevel::Private));
448        assert_eq!(taint.entry_taint(1), Some(TaintLevel::Public));
449    }
450
451    #[test]
452    fn restore_overlays_context_and_jumps_to_stage() {
453        let (mut world, entity) = agent_world();
454        restore_agent(
455            &mut world,
456            entity,
457            &snapshot(),
458            1,
459            7,
460            TokenTotals {
461                prompt_tokens: 100,
462                ..Default::default()
463            },
464        );
465
466        // Context replaced by the persisted entries (with kinds), not the seed.
467        let window = world.get::<ContextWindow>(entity).unwrap();
468        let region = window.get_region("conversation").unwrap();
469        assert_eq!(region.content.len(), 2);
470        assert_eq!(region.content[0].content, "prior user turn");
471        assert_eq!(region.content[0].kind, EntryKind::UserMessage);
472        assert_eq!(region.current_tokens, 8);
473
474        // Jumped to stage 1 (its config swapped in) + iteration restored.
475        assert_eq!(world.get::<StageCursor>(entity).unwrap().index, 1);
476        let state = world.get::<AgentState>(entity).unwrap();
477        assert_eq!(state.current_stage, "s1");
478        assert_eq!(state.iteration, 7);
479        assert_eq!(state.status, AgentStatus::Active);
480        assert_eq!(
481            world.get::<InferenceConfig>(entity).unwrap().temperature,
482            Some(0.5)
483        );
484        assert_eq!(world.get::<StageInference>(entity).unwrap().model, "m1");
485        assert_eq!(world.get::<TokenTotals>(entity).unwrap().prompt_tokens, 100);
486        // Still ready to (re-)infer.
487        assert!(world.get::<ReadyToInfer>(entity).is_some());
488    }
489
490    // ── pending-batch replay (#96) ──
491
492    fn pending_call(
493        id: &str,
494        name: &str,
495        result: Option<&str>,
496    ) -> leviath_core::run_archive::ToolCallRecord {
497        leviath_core::run_archive::ToolCallRecord {
498            id: id.to_string(),
499            name: name.to_string(),
500            arguments: r#"{"path":"x.txt"}"#.to_string(),
501            result: result.map(str::to_string),
502            thought_signature: None,
503        }
504    }
505
506    fn pending_batch(
507        calls: Vec<leviath_core::run_archive::ToolCallRecord>,
508    ) -> leviath_core::run_archive::PendingToolBatch {
509        leviath_core::run_archive::PendingToolBatch {
510            stage_index: 1,
511            iteration: 7,
512            response: "writing then checking".to_string(),
513            calls,
514        }
515    }
516
517    /// The `conversation` entries of `entity`'s window.
518    fn conv_entries(world: &World, entity: Entity) -> Vec<RegionEntry> {
519        world
520            .get::<ContextWindow>(entity)
521            .unwrap()
522            .get_region("conversation")
523            .unwrap()
524            .content
525            .clone()
526    }
527
528    #[test]
529    fn pending_batch_replays_real_results_and_synthesizes_interrupted_ones() {
530        let (mut world, entity) = agent_world();
531        restore_agent(
532            &mut world,
533            entity,
534            &snapshot(),
535            1,
536            7,
537            TokenTotals::default(),
538        );
539        restore_pending_batch(
540            &mut world,
541            entity,
542            &pending_batch(vec![
543                pending_call("c1", "write_file", Some("Wrote 42 bytes to x.txt")),
544                pending_call("c2", "shell", None),
545            ]),
546            &[],
547        );
548
549        let entries = conv_entries(&world, entity);
550        // The assistant turn landed with both calls, then one result per call:
551        // the journaled real result and the synthesized interrupted one.
552        let turn = entries
553            .iter()
554            .find_map(|e| match &e.kind {
555                EntryKind::AssistantTurn { tool_calls } if !tool_calls.is_empty() => {
556                    Some(tool_calls.clone())
557                }
558                _ => None,
559            })
560            .expect("assistant turn appended");
561        assert_eq!(turn.len(), 2);
562        assert_eq!(turn[0].id, "c1");
563        assert_eq!(
564            turn[0].arguments,
565            serde_json::json!({"path": "x.txt"}),
566            "journaled arguments parsed back to JSON"
567        );
568        let result_of = |id: &str| {
569            entries
570                .iter()
571                .find(|e| {
572                    matches!(&e.kind, EntryKind::ToolResult { tool_call_id, .. } if tool_call_id == id)
573                })
574                .map(|e| e.content.clone())
575                .expect("a result per call")
576        };
577        assert_eq!(result_of("c1"), "Wrote 42 bytes to x.txt");
578        assert!(result_of("c2").contains("interrupted"));
579        assert!(result_of("c2").contains("Verify whether it took effect"));
580    }
581
582    #[test]
583    fn pending_batch_survives_request_assembly_unstripped() {
584        // The whole point of pairing the turn with a result per call: the
585        // assembler's orphan sanitizer must keep every block, so the re-issued
586        // request shows the model exactly what already ran. A sliding-window
587        // conversation, since that's the kind assembled as typed messages.
588        let (mut world, entity) = agent_world();
589        world
590            .get_mut::<ContextWindow>(entity)
591            .unwrap()
592            .get_region_mut("conversation")
593            .unwrap()
594            .kind = RegionKind::SlidingWindow {
595            max_items: 100,
596            eviction_strategy: Default::default(),
597        };
598        restore_agent(
599            &mut world,
600            entity,
601            &snapshot(),
602            1,
603            7,
604            TokenTotals::default(),
605        );
606        restore_pending_batch(
607            &mut world,
608            entity,
609            &pending_batch(vec![pending_call("c1", "shell", None)]),
610            &[],
611        );
612
613        let assembled = world.get::<ContextWindow>(entity).unwrap().assemble();
614        let mut tool_uses = 0;
615        let mut tool_results = 0;
616        for msg in &assembled.messages {
617            if let leviath_providers::MessageContent::Blocks(blocks) = &msg.content {
618                for block in blocks {
619                    match block {
620                        leviath_providers::ContentBlock::ToolUse { id, .. } => {
621                            assert_eq!(id, "c1");
622                            tool_uses += 1;
623                        }
624                        leviath_providers::ContentBlock::ToolResult { tool_use_id, .. } => {
625                            assert_eq!(tool_use_id, "c1");
626                            tool_results += 1;
627                        }
628                        _ => {}
629                    }
630                }
631            }
632        }
633        assert_eq!((tool_uses, tool_results), (1, 1), "nothing stripped");
634    }
635
636    #[test]
637    fn pending_batch_routes_results_through_the_restored_stage_routing() {
638        // Stage 1 routes results to `knowledge`: the replayed result's full text
639        // lands there and the conversation keeps the pointer - identical to the
640        // live apply path, because it IS the live apply path.
641        let (mut world, entity) = agent_world();
642        world
643            .get_mut::<ContextWindow>(entity)
644            .unwrap()
645            .add_region(Region::new(
646                "knowledge".to_string(),
647                RegionKind::Pinned,
648                10_000,
649            ));
650        world
651            .get_mut::<StageSetups>(entity)
652            .unwrap()
653            .0
654            .get_mut(1)
655            .unwrap()
656            .routing = Some(leviath_core::ToolResultRouting {
657            default_region: "knowledge".to_string(),
658            ..Default::default()
659        });
660        restore_agent(
661            &mut world,
662            entity,
663            &snapshot(),
664            1,
665            7,
666            TokenTotals::default(),
667        );
668        restore_pending_batch(
669            &mut world,
670            entity,
671            &pending_batch(vec![pending_call("c1", "read_file", Some("the file body"))]),
672            &[],
673        );
674
675        let window = world.get::<ContextWindow>(entity).unwrap();
676        let knowledge = window.get_region("knowledge").unwrap();
677        assert!(
678            knowledge
679                .content
680                .iter()
681                .any(|e| e.content.contains("the file body")),
682            "full text routed to the knowledge region"
683        );
684        assert!(
685            conv_entries(&world, entity).iter().any(
686                |e| matches!(&e.kind, EntryKind::ToolResult { tool_call_id, .. } if tool_call_id == "c1")
687            ),
688            "conversation keeps the paired pointer result"
689        );
690    }
691
692    #[test]
693    fn pending_batch_taints_results_per_tool_sensitivity() {
694        use leviath_core::taint::TaintLevel;
695        let (mut world, entity) = agent_world();
696        world
697            .get_mut::<ContextWindow>(entity)
698            .unwrap()
699            .get_region_mut("conversation")
700            .unwrap()
701            .enable_taint_tracking();
702        world
703            .entity_mut(entity)
704            .insert(crate::pipeline::ToolSensitivities(
705                [("read_file".to_string(), TaintLevel::Private)]
706                    .into_iter()
707                    .collect(),
708            ));
709        restore_agent(
710            &mut world,
711            entity,
712            &snapshot(),
713            1,
714            7,
715            TokenTotals::default(),
716        );
717        restore_pending_batch(
718            &mut world,
719            entity,
720            &pending_batch(vec![pending_call("c1", "read_file", Some("secret body"))]),
721            &[],
722        );
723
724        let window = world.get::<ContextWindow>(entity).unwrap();
725        assert_eq!(
726            window.get_region("conversation").unwrap().taint_level(),
727            Some(TaintLevel::Private),
728            "replayed result tainted like a live one"
729        );
730    }
731
732    #[test]
733    fn unparseable_journaled_arguments_survive_as_a_raw_string() {
734        let (mut world, entity) = agent_world();
735        restore_agent(
736            &mut world,
737            entity,
738            &snapshot(),
739            1,
740            7,
741            TokenTotals::default(),
742        );
743        let mut call = pending_call("c1", "shell", None);
744        call.arguments = "not json {".to_string();
745        restore_pending_batch(&mut world, entity, &pending_batch(vec![call]), &[]);
746
747        let entries = conv_entries(&world, entity);
748        let turn = entries
749            .iter()
750            .find_map(|e| match &e.kind {
751                EntryKind::AssistantTurn { tool_calls } if !tool_calls.is_empty() => {
752                    Some(tool_calls.clone())
753                }
754                _ => None,
755            })
756            .expect("turn still lands");
757        assert_eq!(
758            turn[0].arguments,
759            serde_json::Value::String("not json {".to_string())
760        );
761    }
762
763    #[test]
764    fn interrupted_subagent_calls_point_at_known_children() {
765        // A sub-agent call with known children gets the check-first note; other
766        // shapes (children but a non-subagent tool, a subagent tool but no
767        // children) get the plain interrupted text.
768        let kids = vec!["run-kid-1".to_string(), "run-kid-2".to_string()];
769        let enriched = interrupted_result("spawn_agent", &kids);
770        assert!(enriched.contains("run-kid-1, run-kid-2"));
771        assert!(enriched.contains("check_agent"));
772        assert_eq!(interrupted_result("shell", &kids), INTERRUPTED_TOOL_RESULT);
773        assert_eq!(
774            interrupted_result("spawn_agent", &[]),
775            INTERRUPTED_TOOL_RESULT
776        );
777
778        // And end-to-end: the enriched text is what lands in the window.
779        let (mut world, entity) = agent_world();
780        restore_agent(
781            &mut world,
782            entity,
783            &snapshot(),
784            1,
785            7,
786            TokenTotals::default(),
787        );
788        restore_pending_batch(
789            &mut world,
790            entity,
791            &pending_batch(vec![pending_call("c1", "spawn_agent", None)]),
792            &kids,
793        );
794        assert!(
795            conv_entries(&world, entity)
796                .iter()
797                .any(|e| e.content.contains("already has child agent runs")),
798            "the synthesized sub-agent note lands in the window"
799        );
800    }
801
802    #[test]
803    fn restore_swaps_in_the_stage_routing_and_clears_stale() {
804        use crate::components::ToolResultRoutingComponent;
805
806        // The restored stage routes tool results: the component comes in.
807        let (mut world, entity) = agent_world();
808        let routed = leviath_core::ToolResultRouting {
809            default_region: "knowledge".to_string(),
810            ..Default::default()
811        };
812        world
813            .get_mut::<StageSetups>(entity)
814            .unwrap()
815            .0
816            .get_mut(1)
817            .unwrap()
818            .routing = Some(routed);
819        restore_agent(
820            &mut world,
821            entity,
822            &snapshot(),
823            1,
824            7,
825            TokenTotals::default(),
826        );
827        assert_eq!(
828            world
829                .get::<ToolResultRoutingComponent>(entity)
830                .expect("stage 1's routing swapped in")
831                .routing
832                .default_region,
833            "knowledge"
834        );
835
836        // The restored stage has no routing: a stale component (left over from
837        // the spawn stage) is cleared rather than routing future batches.
838        let (mut world, entity) = agent_world();
839        world.entity_mut(entity).insert(ToolResultRoutingComponent {
840            routing: leviath_core::ToolResultRouting::default(),
841        });
842        restore_agent(
843            &mut world,
844            entity,
845            &snapshot(),
846            1,
847            7,
848            TokenTotals::default(),
849        );
850        assert!(world.get::<ToolResultRoutingComponent>(entity).is_none());
851    }
852
853    fn meta_with(run_id: &str, status: RunStatus, updated_at: i64) -> RunMeta {
854        let mut m = RunMeta::new(
855            run_id.to_string(),
856            "a".to_string(),
857            "/p".to_string(),
858            "t".to_string(),
859            None,
860            "/w".to_string(),
861            1,
862        );
863        m.status = status;
864        m.updated_at = updated_at;
865        m
866    }
867
868    #[test]
869    fn classify_restore_skips_terminal_and_ranks_the_rest() {
870        // Terminal → skipped.
871        assert_eq!(classify_restore(&RunStatus::Complete, false), None);
872        assert_eq!(classify_restore(&RunStatus::Error, false), None);
873        assert_eq!(classify_restore(&RunStatus::Cancelled, false), None);
874        // Actionable → Active.
875        assert_eq!(
876            classify_restore(&RunStatus::Running, false),
877            Some(RestorePriority::Active)
878        );
879        assert_eq!(
880            classify_restore(&RunStatus::Starting, false),
881            Some(RestorePriority::Active)
882        );
883        // No immediate progress → Blocked.
884        assert_eq!(
885            classify_restore(&RunStatus::WaitingInput, false),
886            Some(RestorePriority::Blocked)
887        );
888        assert_eq!(
889            classify_restore(&RunStatus::Paused, false),
890            Some(RestorePriority::Blocked)
891        );
892        assert_eq!(
893            classify_restore(&RunStatus::CompleteInteractive, false),
894            Some(RestorePriority::Blocked)
895        );
896        // Parked mid fan-out is Blocked even when otherwise Running.
897        assert_eq!(
898            classify_restore(&RunStatus::Running, true),
899            Some(RestorePriority::Blocked)
900        );
901        // A terminal run parked on a fan-out is still skipped.
902        assert_eq!(classify_restore(&RunStatus::Complete, true), None);
903    }
904
905    #[test]
906    fn triage_orders_actionable_first_then_by_recency_and_drops_terminal() {
907        let candidates = vec![
908            (
909                meta_with("blocked-old", RunStatus::WaitingInput, 100),
910                false,
911            ),
912            (meta_with("active-old", RunStatus::Running, 200), false),
913            (meta_with("terminal", RunStatus::Complete, 999), false),
914            (meta_with("active-new", RunStatus::Starting, 300), false),
915            (meta_with("parked", RunStatus::Running, 999), true), // fan-out → Blocked
916            (
917                meta_with("blocked-new", RunStatus::WaitingInput, 400),
918                false,
919            ),
920        ];
921        let order: Vec<String> = triage_restores(candidates)
922            .into_iter()
923            .map(|m| m.run_id)
924            .collect();
925        // Active tier first (most-recent first), then Blocked tier (most-recent
926        // first, with the fan-out-parked run demoted into it). Terminal dropped.
927        assert_eq!(
928            order,
929            vec![
930                "active-new".to_string(),  // Active, updated 300
931                "active-old".to_string(),  // Active, updated 200
932                "parked".to_string(),      // Blocked (fan-out), updated 999
933                "blocked-new".to_string(), // Blocked, updated 400
934                "blocked-old".to_string(), // Blocked, updated 100
935            ]
936        );
937    }
938
939    #[test]
940    fn restore_with_out_of_range_stage_keeps_spawn_config() {
941        let (mut world, entity) = agent_world();
942        let mut snap = snapshot();
943        snap.stage_name = "s0".to_string();
944        // The blueprint now has fewer stages than the persisted index.
945        restore_agent(&mut world, entity, &snap, 9, 2, TokenTotals::default());
946
947        // Stage jump skipped: cursor + config stay at stage 0.
948        assert_eq!(world.get::<StageCursor>(entity).unwrap().index, 0);
949        assert_eq!(world.get::<StageInference>(entity).unwrap().model, "m0");
950        // State + context still restored.
951        assert_eq!(world.get::<AgentState>(entity).unwrap().iteration, 2);
952        assert_eq!(
953            world
954                .get::<ContextWindow>(entity)
955                .unwrap()
956                .get_region("conversation")
957                .unwrap()
958                .content
959                .len(),
960            2
961        );
962    }
963}