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        }
309    }
310
311    fn si(model: &str) -> StageInference {
312        StageInference {
313            provider_name: "p".to_string(),
314            model: model.to_string(),
315            tools: vec![],
316            tool_filter: None,
317            fallbacks: Vec::new(),
318        }
319    }
320
321    /// A world with one spawned-looking agent: a `conversation` region window,
322    /// two stages, cursor at 0, `ReadyToInfer`.
323    fn agent_world() -> (World, Entity) {
324        let mut world = World::new();
325        let mut window = ContextWindow::new(10_000);
326        window.add_region(Region::new(
327            "conversation".to_string(),
328            RegionKind::Clearable,
329            10_000,
330        ));
331        let _ = window.add_to_region("conversation", "fresh task seed".to_string(), 3);
332        let entity = world
333            .spawn((
334                window,
335                StageCursor { index: 0 },
336                AgentState {
337                    agent_id: "a".to_string(),
338                    current_stage: "s0".to_string(),
339                    iteration: 0,
340                    status: AgentStatus::Active,
341                    spawned_children_ids: vec![],
342                    pending_wait: None,
343                    accepts_messages: true,
344                },
345                StageInferences(vec![si("m0"), si("m1")]),
346                StageSetups(vec![setup(None), setup(Some(0.5))]),
347                si("m0"),
348                setup(None).inference_config,
349                TokenTotals::default(),
350                ReadyToInfer,
351            ))
352            .id();
353        (world, entity)
354    }
355
356    fn snapshot() -> ContextSnapshot {
357        ContextSnapshot {
358            stage_name: "s1".to_string(),
359            total_tokens: 8,
360            max_tokens: 10_000,
361            regions: vec![
362                RegionSnapshot {
363                    name: "conversation".to_string(),
364                    kind: "clearable".to_string(),
365                    current_tokens: 8,
366                    max_tokens: 10_000,
367                    entries: vec![
368                        RegionEntrySnapshot {
369                            content: "prior user turn".to_string(),
370                            tokens: 5,
371                            kind: EntryKind::UserMessage,
372                            metadata: None,
373                            key: None,
374                            taint: Default::default(),
375                        },
376                        RegionEntrySnapshot {
377                            content: "prior assistant".to_string(),
378                            tokens: 3,
379                            kind: EntryKind::AssistantTurn { tool_calls: vec![] },
380                            metadata: None,
381                            key: None,
382                            taint: Default::default(),
383                        },
384                    ],
385                },
386                // A region that no longer exists in the window - skipped.
387                RegionSnapshot {
388                    name: "ghost".to_string(),
389                    kind: "pinned".to_string(),
390                    current_tokens: 1,
391                    max_tokens: 10,
392                    entries: vec![RegionEntrySnapshot {
393                        content: "orphan".to_string(),
394                        tokens: 1,
395                        kind: EntryKind::Text,
396                        metadata: None,
397                        key: None,
398                        taint: Default::default(),
399                    }],
400                },
401            ],
402        }
403    }
404
405    /// Taint was not persisted at all, so a restart, resume or page-in brought
406    /// every region back `Public` no matter how sensitive it had been - while
407    /// the gate went on reporting itself armed. It is rebuilt from the entries,
408    /// and only where the region already tracks taint: restoring a level onto a
409    /// region with tracking off would invent one nothing reads.
410    #[test]
411    fn restore_rebuilds_region_taint_from_the_persisted_entries() {
412        use leviath_core::taint::TaintLevel;
413
414        let mut snap = snapshot();
415        snap.regions[0].entries[0].taint = TaintLevel::Private;
416        snap.regions[0].entries[1].taint = TaintLevel::Public;
417
418        // Tracking off: the region stays untainted rather than gaining a level.
419        let (mut world, entity) = agent_world();
420        restore_agent(&mut world, entity, &snap, 1, 7, TokenTotals::default());
421        assert!(
422            world
423                .get::<ContextWindow>(entity)
424                .unwrap()
425                .get_region("conversation")
426                .unwrap()
427                .taint
428                .is_none()
429        );
430
431        // Tracking on: the level comes back, per entry and in aggregate.
432        let (mut world, entity) = agent_world();
433        world
434            .get_mut::<ContextWindow>(entity)
435            .unwrap()
436            .get_region_mut("conversation")
437            .unwrap()
438            .enable_taint_tracking();
439        restore_agent(&mut world, entity, &snap, 1, 7, TokenTotals::default());
440
441        let window = world.get::<ContextWindow>(entity).unwrap();
442        let region = window.get_region("conversation").unwrap();
443        assert_eq!(region.taint_level(), Some(TaintLevel::Private));
444        let taint = region.taint.as_ref().unwrap();
445        assert_eq!(taint.entry_taint(0), Some(TaintLevel::Private));
446        assert_eq!(taint.entry_taint(1), Some(TaintLevel::Public));
447    }
448
449    #[test]
450    fn restore_overlays_context_and_jumps_to_stage() {
451        let (mut world, entity) = agent_world();
452        restore_agent(
453            &mut world,
454            entity,
455            &snapshot(),
456            1,
457            7,
458            TokenTotals {
459                prompt_tokens: 100,
460                ..Default::default()
461            },
462        );
463
464        // Context replaced by the persisted entries (with kinds), not the seed.
465        let window = world.get::<ContextWindow>(entity).unwrap();
466        let region = window.get_region("conversation").unwrap();
467        assert_eq!(region.content.len(), 2);
468        assert_eq!(region.content[0].content, "prior user turn");
469        assert_eq!(region.content[0].kind, EntryKind::UserMessage);
470        assert_eq!(region.current_tokens, 8);
471
472        // Jumped to stage 1 (its config swapped in) + iteration restored.
473        assert_eq!(world.get::<StageCursor>(entity).unwrap().index, 1);
474        let state = world.get::<AgentState>(entity).unwrap();
475        assert_eq!(state.current_stage, "s1");
476        assert_eq!(state.iteration, 7);
477        assert_eq!(state.status, AgentStatus::Active);
478        assert_eq!(
479            world.get::<InferenceConfig>(entity).unwrap().temperature,
480            Some(0.5)
481        );
482        assert_eq!(world.get::<StageInference>(entity).unwrap().model, "m1");
483        assert_eq!(world.get::<TokenTotals>(entity).unwrap().prompt_tokens, 100);
484        // Still ready to (re-)infer.
485        assert!(world.get::<ReadyToInfer>(entity).is_some());
486    }
487
488    // ── pending-batch replay (#96) ──
489
490    fn pending_call(
491        id: &str,
492        name: &str,
493        result: Option<&str>,
494    ) -> leviath_core::run_archive::ToolCallRecord {
495        leviath_core::run_archive::ToolCallRecord {
496            id: id.to_string(),
497            name: name.to_string(),
498            arguments: r#"{"path":"x.txt"}"#.to_string(),
499            result: result.map(str::to_string),
500            thought_signature: None,
501        }
502    }
503
504    fn pending_batch(
505        calls: Vec<leviath_core::run_archive::ToolCallRecord>,
506    ) -> leviath_core::run_archive::PendingToolBatch {
507        leviath_core::run_archive::PendingToolBatch {
508            stage_index: 1,
509            iteration: 7,
510            response: "writing then checking".to_string(),
511            calls,
512        }
513    }
514
515    /// The `conversation` entries of `entity`'s window.
516    fn conv_entries(world: &World, entity: Entity) -> Vec<RegionEntry> {
517        world
518            .get::<ContextWindow>(entity)
519            .unwrap()
520            .get_region("conversation")
521            .unwrap()
522            .content
523            .clone()
524    }
525
526    #[test]
527    fn pending_batch_replays_real_results_and_synthesizes_interrupted_ones() {
528        let (mut world, entity) = agent_world();
529        restore_agent(
530            &mut world,
531            entity,
532            &snapshot(),
533            1,
534            7,
535            TokenTotals::default(),
536        );
537        restore_pending_batch(
538            &mut world,
539            entity,
540            &pending_batch(vec![
541                pending_call("c1", "write_file", Some("Wrote 42 bytes to x.txt")),
542                pending_call("c2", "shell", None),
543            ]),
544            &[],
545        );
546
547        let entries = conv_entries(&world, entity);
548        // The assistant turn landed with both calls, then one result per call:
549        // the journaled real result and the synthesized interrupted one.
550        let turn = entries
551            .iter()
552            .find_map(|e| match &e.kind {
553                EntryKind::AssistantTurn { tool_calls } if !tool_calls.is_empty() => {
554                    Some(tool_calls.clone())
555                }
556                _ => None,
557            })
558            .expect("assistant turn appended");
559        assert_eq!(turn.len(), 2);
560        assert_eq!(turn[0].id, "c1");
561        assert_eq!(
562            turn[0].arguments,
563            serde_json::json!({"path": "x.txt"}),
564            "journaled arguments parsed back to JSON"
565        );
566        let result_of = |id: &str| {
567            entries
568                .iter()
569                .find(|e| {
570                    matches!(&e.kind, EntryKind::ToolResult { tool_call_id, .. } if tool_call_id == id)
571                })
572                .map(|e| e.content.clone())
573                .expect("a result per call")
574        };
575        assert_eq!(result_of("c1"), "Wrote 42 bytes to x.txt");
576        assert!(result_of("c2").contains("interrupted"));
577        assert!(result_of("c2").contains("Verify whether it took effect"));
578    }
579
580    #[test]
581    fn pending_batch_survives_request_assembly_unstripped() {
582        // The whole point of pairing the turn with a result per call: the
583        // assembler's orphan sanitizer must keep every block, so the re-issued
584        // request shows the model exactly what already ran. A sliding-window
585        // conversation, since that's the kind assembled as typed messages.
586        let (mut world, entity) = agent_world();
587        world
588            .get_mut::<ContextWindow>(entity)
589            .unwrap()
590            .get_region_mut("conversation")
591            .unwrap()
592            .kind = RegionKind::SlidingWindow {
593            max_items: 100,
594            eviction_strategy: Default::default(),
595        };
596        restore_agent(
597            &mut world,
598            entity,
599            &snapshot(),
600            1,
601            7,
602            TokenTotals::default(),
603        );
604        restore_pending_batch(
605            &mut world,
606            entity,
607            &pending_batch(vec![pending_call("c1", "shell", None)]),
608            &[],
609        );
610
611        let assembled = world.get::<ContextWindow>(entity).unwrap().assemble();
612        let mut tool_uses = 0;
613        let mut tool_results = 0;
614        for msg in &assembled.messages {
615            if let leviath_providers::MessageContent::Blocks(blocks) = &msg.content {
616                for block in blocks {
617                    match block {
618                        leviath_providers::ContentBlock::ToolUse { id, .. } => {
619                            assert_eq!(id, "c1");
620                            tool_uses += 1;
621                        }
622                        leviath_providers::ContentBlock::ToolResult { tool_use_id, .. } => {
623                            assert_eq!(tool_use_id, "c1");
624                            tool_results += 1;
625                        }
626                        _ => {}
627                    }
628                }
629            }
630        }
631        assert_eq!((tool_uses, tool_results), (1, 1), "nothing stripped");
632    }
633
634    #[test]
635    fn pending_batch_routes_results_through_the_restored_stage_routing() {
636        // Stage 1 routes results to `knowledge`: the replayed result's full text
637        // lands there and the conversation keeps the pointer - identical to the
638        // live apply path, because it IS the live apply path.
639        let (mut world, entity) = agent_world();
640        world
641            .get_mut::<ContextWindow>(entity)
642            .unwrap()
643            .add_region(Region::new(
644                "knowledge".to_string(),
645                RegionKind::Pinned,
646                10_000,
647            ));
648        world
649            .get_mut::<StageSetups>(entity)
650            .unwrap()
651            .0
652            .get_mut(1)
653            .unwrap()
654            .routing = Some(leviath_core::ToolResultRouting {
655            default_region: "knowledge".to_string(),
656            ..Default::default()
657        });
658        restore_agent(
659            &mut world,
660            entity,
661            &snapshot(),
662            1,
663            7,
664            TokenTotals::default(),
665        );
666        restore_pending_batch(
667            &mut world,
668            entity,
669            &pending_batch(vec![pending_call("c1", "read_file", Some("the file body"))]),
670            &[],
671        );
672
673        let window = world.get::<ContextWindow>(entity).unwrap();
674        let knowledge = window.get_region("knowledge").unwrap();
675        assert!(
676            knowledge
677                .content
678                .iter()
679                .any(|e| e.content.contains("the file body")),
680            "full text routed to the knowledge region"
681        );
682        assert!(
683            conv_entries(&world, entity).iter().any(
684                |e| matches!(&e.kind, EntryKind::ToolResult { tool_call_id, .. } if tool_call_id == "c1")
685            ),
686            "conversation keeps the paired pointer result"
687        );
688    }
689
690    #[test]
691    fn pending_batch_taints_results_per_tool_sensitivity() {
692        use leviath_core::taint::TaintLevel;
693        let (mut world, entity) = agent_world();
694        world
695            .get_mut::<ContextWindow>(entity)
696            .unwrap()
697            .get_region_mut("conversation")
698            .unwrap()
699            .enable_taint_tracking();
700        world
701            .entity_mut(entity)
702            .insert(crate::pipeline::ToolSensitivities(
703                [("read_file".to_string(), TaintLevel::Private)]
704                    .into_iter()
705                    .collect(),
706            ));
707        restore_agent(
708            &mut world,
709            entity,
710            &snapshot(),
711            1,
712            7,
713            TokenTotals::default(),
714        );
715        restore_pending_batch(
716            &mut world,
717            entity,
718            &pending_batch(vec![pending_call("c1", "read_file", Some("secret body"))]),
719            &[],
720        );
721
722        let window = world.get::<ContextWindow>(entity).unwrap();
723        assert_eq!(
724            window.get_region("conversation").unwrap().taint_level(),
725            Some(TaintLevel::Private),
726            "replayed result tainted like a live one"
727        );
728    }
729
730    #[test]
731    fn unparseable_journaled_arguments_survive_as_a_raw_string() {
732        let (mut world, entity) = agent_world();
733        restore_agent(
734            &mut world,
735            entity,
736            &snapshot(),
737            1,
738            7,
739            TokenTotals::default(),
740        );
741        let mut call = pending_call("c1", "shell", None);
742        call.arguments = "not json {".to_string();
743        restore_pending_batch(&mut world, entity, &pending_batch(vec![call]), &[]);
744
745        let entries = conv_entries(&world, entity);
746        let turn = entries
747            .iter()
748            .find_map(|e| match &e.kind {
749                EntryKind::AssistantTurn { tool_calls } if !tool_calls.is_empty() => {
750                    Some(tool_calls.clone())
751                }
752                _ => None,
753            })
754            .expect("turn still lands");
755        assert_eq!(
756            turn[0].arguments,
757            serde_json::Value::String("not json {".to_string())
758        );
759    }
760
761    #[test]
762    fn interrupted_subagent_calls_point_at_known_children() {
763        // A sub-agent call with known children gets the check-first note; other
764        // shapes (children but a non-subagent tool, a subagent tool but no
765        // children) get the plain interrupted text.
766        let kids = vec!["run-kid-1".to_string(), "run-kid-2".to_string()];
767        let enriched = interrupted_result("spawn_agent", &kids);
768        assert!(enriched.contains("run-kid-1, run-kid-2"));
769        assert!(enriched.contains("check_agent"));
770        assert_eq!(interrupted_result("shell", &kids), INTERRUPTED_TOOL_RESULT);
771        assert_eq!(
772            interrupted_result("spawn_agent", &[]),
773            INTERRUPTED_TOOL_RESULT
774        );
775
776        // And end-to-end: the enriched text is what lands in the window.
777        let (mut world, entity) = agent_world();
778        restore_agent(
779            &mut world,
780            entity,
781            &snapshot(),
782            1,
783            7,
784            TokenTotals::default(),
785        );
786        restore_pending_batch(
787            &mut world,
788            entity,
789            &pending_batch(vec![pending_call("c1", "spawn_agent", None)]),
790            &kids,
791        );
792        assert!(
793            conv_entries(&world, entity)
794                .iter()
795                .any(|e| e.content.contains("already has child agent runs")),
796            "the synthesized sub-agent note lands in the window"
797        );
798    }
799
800    #[test]
801    fn restore_swaps_in_the_stage_routing_and_clears_stale() {
802        use crate::components::ToolResultRoutingComponent;
803
804        // The restored stage routes tool results: the component comes in.
805        let (mut world, entity) = agent_world();
806        let routed = leviath_core::ToolResultRouting {
807            default_region: "knowledge".to_string(),
808            ..Default::default()
809        };
810        world
811            .get_mut::<StageSetups>(entity)
812            .unwrap()
813            .0
814            .get_mut(1)
815            .unwrap()
816            .routing = Some(routed);
817        restore_agent(
818            &mut world,
819            entity,
820            &snapshot(),
821            1,
822            7,
823            TokenTotals::default(),
824        );
825        assert_eq!(
826            world
827                .get::<ToolResultRoutingComponent>(entity)
828                .expect("stage 1's routing swapped in")
829                .routing
830                .default_region,
831            "knowledge"
832        );
833
834        // The restored stage has no routing: a stale component (left over from
835        // the spawn stage) is cleared rather than routing future batches.
836        let (mut world, entity) = agent_world();
837        world.entity_mut(entity).insert(ToolResultRoutingComponent {
838            routing: leviath_core::ToolResultRouting::default(),
839        });
840        restore_agent(
841            &mut world,
842            entity,
843            &snapshot(),
844            1,
845            7,
846            TokenTotals::default(),
847        );
848        assert!(world.get::<ToolResultRoutingComponent>(entity).is_none());
849    }
850
851    fn meta_with(run_id: &str, status: RunStatus, updated_at: i64) -> RunMeta {
852        let mut m = RunMeta::new(
853            run_id.to_string(),
854            "a".to_string(),
855            "/p".to_string(),
856            "t".to_string(),
857            None,
858            "/w".to_string(),
859            1,
860        );
861        m.status = status;
862        m.updated_at = updated_at;
863        m
864    }
865
866    #[test]
867    fn classify_restore_skips_terminal_and_ranks_the_rest() {
868        // Terminal → skipped.
869        assert_eq!(classify_restore(&RunStatus::Complete, false), None);
870        assert_eq!(classify_restore(&RunStatus::Error, false), None);
871        assert_eq!(classify_restore(&RunStatus::Cancelled, false), None);
872        // Actionable → Active.
873        assert_eq!(
874            classify_restore(&RunStatus::Running, false),
875            Some(RestorePriority::Active)
876        );
877        assert_eq!(
878            classify_restore(&RunStatus::Starting, false),
879            Some(RestorePriority::Active)
880        );
881        // No immediate progress → Blocked.
882        assert_eq!(
883            classify_restore(&RunStatus::WaitingInput, false),
884            Some(RestorePriority::Blocked)
885        );
886        assert_eq!(
887            classify_restore(&RunStatus::Paused, false),
888            Some(RestorePriority::Blocked)
889        );
890        assert_eq!(
891            classify_restore(&RunStatus::CompleteInteractive, false),
892            Some(RestorePriority::Blocked)
893        );
894        // Parked mid fan-out is Blocked even when otherwise Running.
895        assert_eq!(
896            classify_restore(&RunStatus::Running, true),
897            Some(RestorePriority::Blocked)
898        );
899        // A terminal run parked on a fan-out is still skipped.
900        assert_eq!(classify_restore(&RunStatus::Complete, true), None);
901    }
902
903    #[test]
904    fn triage_orders_actionable_first_then_by_recency_and_drops_terminal() {
905        let candidates = vec![
906            (
907                meta_with("blocked-old", RunStatus::WaitingInput, 100),
908                false,
909            ),
910            (meta_with("active-old", RunStatus::Running, 200), false),
911            (meta_with("terminal", RunStatus::Complete, 999), false),
912            (meta_with("active-new", RunStatus::Starting, 300), false),
913            (meta_with("parked", RunStatus::Running, 999), true), // fan-out → Blocked
914            (
915                meta_with("blocked-new", RunStatus::WaitingInput, 400),
916                false,
917            ),
918        ];
919        let order: Vec<String> = triage_restores(candidates)
920            .into_iter()
921            .map(|m| m.run_id)
922            .collect();
923        // Active tier first (most-recent first), then Blocked tier (most-recent
924        // first, with the fan-out-parked run demoted into it). Terminal dropped.
925        assert_eq!(
926            order,
927            vec![
928                "active-new".to_string(),  // Active, updated 300
929                "active-old".to_string(),  // Active, updated 200
930                "parked".to_string(),      // Blocked (fan-out), updated 999
931                "blocked-new".to_string(), // Blocked, updated 400
932                "blocked-old".to_string(), // Blocked, updated 100
933            ]
934        );
935    }
936
937    #[test]
938    fn restore_with_out_of_range_stage_keeps_spawn_config() {
939        let (mut world, entity) = agent_world();
940        let mut snap = snapshot();
941        snap.stage_name = "s0".to_string();
942        // The blueprint now has fewer stages than the persisted index.
943        restore_agent(&mut world, entity, &snap, 9, 2, TokenTotals::default());
944
945        // Stage jump skipped: cursor + config stay at stage 0.
946        assert_eq!(world.get::<StageCursor>(entity).unwrap().index, 0);
947        assert_eq!(world.get::<StageInference>(entity).unwrap().model, "m0");
948        // State + context still restored.
949        assert_eq!(world.get::<AgentState>(entity).unwrap().iteration, 2);
950        assert_eq!(
951            world
952                .get::<ContextWindow>(entity)
953                .unwrap()
954                .get_region("conversation")
955                .unwrap()
956                .content
957                .len(),
958            2
959        );
960    }
961}