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
11use bevy_ecs::prelude::*;
12use leviath_core::region::RegionEntry;
13use leviath_core::run_meta::{ContextSnapshot, RunMeta, RunStatus};
14
15use crate::components::{AgentState, AgentStatus, ContextWindow};
16use crate::persistence::TokenTotals;
17use crate::pipeline::{StageCursor, StageInferences, StageSetups};
18
19/// How urgently a persisted run should be brought back on restart. Ordered so a
20/// higher value restores first (see [`triage_restores`]).
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
22pub enum RestorePriority {
23    /// Restorable, but can make no immediate progress: blocked on user input,
24    /// done-but-interactive (awaiting optional follow-up), or a parent parked mid
25    /// fan-out waiting on its children. Brought back after the actionable runs.
26    Blocked,
27    /// Actionable now: an in-flight inference to re-dispatch or pending tool
28    /// results to process. These resume real work the moment they're reloaded, so
29    /// they come back first.
30    Active,
31}
32
33/// Classify one persisted run for restart recovery from its on-disk status and
34/// whether it is parked mid fan-out (a `<run_dir>/fanout.json` is present).
35///
36/// Returns `None` for a **terminal** run (`Complete` / `Error` / `Cancelled`) -
37/// those are never resumed. A run parked on a fan-out is [`Blocked`] regardless of
38/// its status: it can't progress until its children finish.
39///
40/// [`Blocked`]: RestorePriority::Blocked
41pub fn classify_restore(status: &RunStatus, parked_on_fanout: bool) -> Option<RestorePriority> {
42    match status {
43        RunStatus::Complete | RunStatus::Error | RunStatus::Cancelled => None,
44        _ if parked_on_fanout => Some(RestorePriority::Blocked),
45        RunStatus::Starting | RunStatus::Running => Some(RestorePriority::Active),
46        RunStatus::WaitingInput | RunStatus::CompleteInteractive => Some(RestorePriority::Blocked),
47    }
48}
49
50/// Triage a set of persisted runs into the order they should be restored on
51/// restart: drop terminal runs, then rank the rest **actionable-first**
52/// ([`RestorePriority::Active`] before [`Blocked`]), breaking ties by most-recently
53/// updated. Each input is `(meta, parked_on_fanout)` where `parked_on_fanout` is
54/// whether the run has a `fanout.json` (see [`classify_restore`]); the returned
55/// [`RunMeta`]s are ready to reload in order.
56///
57/// This lets a resource- or time-constrained caller restore only a prefix (the most
58/// actionable agents) and still make the most progress possible.
59///
60/// [`Blocked`]: RestorePriority::Blocked
61pub fn triage_restores(candidates: Vec<(RunMeta, bool)>) -> Vec<RunMeta> {
62    let mut ranked: Vec<(RestorePriority, RunMeta)> = candidates
63        .into_iter()
64        .filter_map(|(meta, parked)| {
65            classify_restore(&meta.status, parked).map(|prio| (prio, meta))
66        })
67        .collect();
68    // Higher priority first; within a tier, most-recently updated first. `sort_by`
69    // is stable, so equal keys keep their scan order.
70    ranked.sort_by(|(a_prio, a), (b_prio, b)| {
71        b_prio
72            .cmp(a_prio)
73            .then_with(|| b.updated_at.cmp(&a.updated_at))
74    });
75    ranked.into_iter().map(|(_, meta)| meta).collect()
76}
77
78/// Restore a just-spawned `entity` to the persisted state captured in `snapshot`
79/// (its context), `stage_index` + `iteration` (its position), and `totals` (its
80/// running token/tool counts). The agent stays `Active` + `ReadyToInfer` so it
81/// resumes on the next tick.
82///
83/// Context is overlaid by region **name**: each persisted region replaces the
84/// matching window region's entries (rebuilt from the blueprint layout, so region
85/// kinds/limits are correct). A persisted region with no matching window region
86/// is skipped. An out-of-range `stage_index` (e.g. the blueprint gained/lost
87/// stages) leaves the spawned stage-0 config in place.
88pub fn restore_agent(
89    world: &mut World,
90    entity: Entity,
91    snapshot: &ContextSnapshot,
92    stage_index: usize,
93    iteration: usize,
94    totals: TokenTotals,
95) {
96    // 1. Overlay the persisted context onto the (blueprint-built) window.
97    {
98        let mut window = world
99            .get_mut::<ContextWindow>(entity)
100            .expect("a spawned agent has a context window");
101        for snap_region in &snapshot.regions {
102            if let Some(region) = window
103                .regions
104                .iter_mut()
105                .find(|r| r.name == snap_region.name)
106            {
107                region.content = snap_region
108                    .entries
109                    .iter()
110                    .map(|e| RegionEntry {
111                        content: e.content.clone(),
112                        tokens: e.tokens,
113                        timestamp: 0,
114                        metadata: e.metadata.clone(),
115                        kind: e.kind.clone(),
116                        key: e.key.clone(),
117                    })
118                    .collect();
119                // Rebuild the taint alongside the content. Assigning `content`
120                // directly bypasses `add_tainted_entry`, which is the only thing
121                // that records per-entry taint - so without this the region came
122                // back `Public` no matter how sensitive it had been, while the
123                // gate reported itself armed.
124                // Only where the region already tracks taint: restoring it onto
125                // a region with tracking off would invent a level nothing reads.
126                if region.taint.is_some() {
127                    region.taint = Some(leviath_core::taint::RegionTaint::from_entry_taints(
128                        snap_region.entries.iter().map(|e| e.taint).collect(),
129                    ));
130                }
131                region.current_tokens = region.content.iter().map(|e| e.tokens).sum();
132            }
133        }
134        window.current_tokens = window.calculate_tokens();
135    }
136
137    // 2. Jump to the persisted stage, swapping in its inference config.
138    if let Some(inf) = world
139        .get::<StageInferences>(entity)
140        .expect("a spawned agent has stage inferences")
141        .0
142        .get(stage_index)
143        .cloned()
144    {
145        let cfg = world
146            .get::<StageSetups>(entity)
147            .expect("a spawned agent has stage setups")
148            .0[stage_index]
149            .inference_config
150            .clone();
151        world.entity_mut(entity).insert((inf, cfg));
152        world
153            .get_mut::<StageCursor>(entity)
154            .expect("a spawned agent has a stage cursor")
155            .index = stage_index;
156    }
157
158    // 3. Restore the agent's running state + token totals.
159    {
160        let mut state = world
161            .get_mut::<AgentState>(entity)
162            .expect("a spawned agent has state");
163        state.current_stage = snapshot.stage_name.clone();
164        state.iteration = iteration;
165        state.status = AgentStatus::Active;
166    }
167    world.entity_mut(entity).insert(totals);
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::components::InferenceConfig;
174    use crate::pipeline::{ReadyToInfer, StageInference, StageSetup};
175    use leviath_core::region::EntryKind;
176    use leviath_core::run_meta::{RegionEntrySnapshot, RegionSnapshot};
177    use leviath_core::{Region, RegionKind};
178
179    fn setup(temp: Option<f32>) -> StageSetup {
180        StageSetup {
181            inference_config: InferenceConfig {
182                temperature: temp,
183                max_output_tokens: None,
184                extra_params: Default::default(),
185                batch_tool_hint: false,
186                request_timeout_secs: None,
187            },
188            routing: None,
189            accepts_messages: true,
190            context_layout: None,
191            system_prompt: None,
192        }
193    }
194
195    fn si(model: &str) -> StageInference {
196        StageInference {
197            provider_name: "p".to_string(),
198            model: model.to_string(),
199            tools: vec![],
200            tool_filter: None,
201        }
202    }
203
204    /// A world with one spawned-looking agent: a `conversation` region window,
205    /// two stages, cursor at 0, `ReadyToInfer`.
206    fn agent_world() -> (World, Entity) {
207        let mut world = World::new();
208        let mut window = ContextWindow::new(10_000);
209        window.add_region(Region::new(
210            "conversation".to_string(),
211            RegionKind::Clearable,
212            10_000,
213        ));
214        let _ = window.add_to_region("conversation", "fresh task seed".to_string(), 3);
215        let entity = world
216            .spawn((
217                window,
218                StageCursor { index: 0 },
219                AgentState {
220                    agent_id: "a".to_string(),
221                    current_stage: "s0".to_string(),
222                    iteration: 0,
223                    status: AgentStatus::Active,
224                    spawned_children_ids: vec![],
225                    pending_wait: None,
226                    accepts_messages: true,
227                },
228                StageInferences(vec![si("m0"), si("m1")]),
229                StageSetups(vec![setup(None), setup(Some(0.5))]),
230                si("m0"),
231                setup(None).inference_config,
232                TokenTotals::default(),
233                ReadyToInfer,
234            ))
235            .id();
236        (world, entity)
237    }
238
239    fn snapshot() -> ContextSnapshot {
240        ContextSnapshot {
241            stage_name: "s1".to_string(),
242            total_tokens: 8,
243            max_tokens: 10_000,
244            regions: vec![
245                RegionSnapshot {
246                    name: "conversation".to_string(),
247                    kind: "clearable".to_string(),
248                    current_tokens: 8,
249                    max_tokens: 10_000,
250                    entries: vec![
251                        RegionEntrySnapshot {
252                            content: "prior user turn".to_string(),
253                            tokens: 5,
254                            kind: EntryKind::UserMessage,
255                            metadata: None,
256                            key: None,
257                            taint: Default::default(),
258                        },
259                        RegionEntrySnapshot {
260                            content: "prior assistant".to_string(),
261                            tokens: 3,
262                            kind: EntryKind::AssistantTurn { tool_calls: vec![] },
263                            metadata: None,
264                            key: None,
265                            taint: Default::default(),
266                        },
267                    ],
268                },
269                // A region that no longer exists in the window - skipped.
270                RegionSnapshot {
271                    name: "ghost".to_string(),
272                    kind: "pinned".to_string(),
273                    current_tokens: 1,
274                    max_tokens: 10,
275                    entries: vec![RegionEntrySnapshot {
276                        content: "orphan".to_string(),
277                        tokens: 1,
278                        kind: EntryKind::Text,
279                        metadata: None,
280                        key: None,
281                        taint: Default::default(),
282                    }],
283                },
284            ],
285        }
286    }
287
288    /// Taint was not persisted at all, so a restart, resume or page-in brought
289    /// every region back `Public` no matter how sensitive it had been - while
290    /// the gate went on reporting itself armed. It is rebuilt from the entries,
291    /// and only where the region already tracks taint: restoring a level onto a
292    /// region with tracking off would invent one nothing reads.
293    #[test]
294    fn restore_rebuilds_region_taint_from_the_persisted_entries() {
295        use leviath_core::taint::TaintLevel;
296
297        let mut snap = snapshot();
298        snap.regions[0].entries[0].taint = TaintLevel::Private;
299        snap.regions[0].entries[1].taint = TaintLevel::Public;
300
301        // Tracking off: the region stays untainted rather than gaining a level.
302        let (mut world, entity) = agent_world();
303        restore_agent(&mut world, entity, &snap, 1, 7, TokenTotals::default());
304        assert!(
305            world
306                .get::<ContextWindow>(entity)
307                .unwrap()
308                .get_region("conversation")
309                .unwrap()
310                .taint
311                .is_none()
312        );
313
314        // Tracking on: the level comes back, per entry and in aggregate.
315        let (mut world, entity) = agent_world();
316        world
317            .get_mut::<ContextWindow>(entity)
318            .unwrap()
319            .get_region_mut("conversation")
320            .unwrap()
321            .enable_taint_tracking();
322        restore_agent(&mut world, entity, &snap, 1, 7, TokenTotals::default());
323
324        let window = world.get::<ContextWindow>(entity).unwrap();
325        let region = window.get_region("conversation").unwrap();
326        assert_eq!(region.taint_level(), Some(TaintLevel::Private));
327        let taint = region.taint.as_ref().unwrap();
328        assert_eq!(taint.entry_taint(0), Some(TaintLevel::Private));
329        assert_eq!(taint.entry_taint(1), Some(TaintLevel::Public));
330    }
331
332    #[test]
333    fn restore_overlays_context_and_jumps_to_stage() {
334        let (mut world, entity) = agent_world();
335        restore_agent(
336            &mut world,
337            entity,
338            &snapshot(),
339            1,
340            7,
341            TokenTotals {
342                prompt_tokens: 100,
343                ..Default::default()
344            },
345        );
346
347        // Context replaced by the persisted entries (with kinds), not the seed.
348        let window = world.get::<ContextWindow>(entity).unwrap();
349        let region = window.get_region("conversation").unwrap();
350        assert_eq!(region.content.len(), 2);
351        assert_eq!(region.content[0].content, "prior user turn");
352        assert_eq!(region.content[0].kind, EntryKind::UserMessage);
353        assert_eq!(region.current_tokens, 8);
354
355        // Jumped to stage 1 (its config swapped in) + iteration restored.
356        assert_eq!(world.get::<StageCursor>(entity).unwrap().index, 1);
357        let state = world.get::<AgentState>(entity).unwrap();
358        assert_eq!(state.current_stage, "s1");
359        assert_eq!(state.iteration, 7);
360        assert_eq!(state.status, AgentStatus::Active);
361        assert_eq!(
362            world.get::<InferenceConfig>(entity).unwrap().temperature,
363            Some(0.5)
364        );
365        assert_eq!(world.get::<StageInference>(entity).unwrap().model, "m1");
366        assert_eq!(world.get::<TokenTotals>(entity).unwrap().prompt_tokens, 100);
367        // Still ready to (re-)infer.
368        assert!(world.get::<ReadyToInfer>(entity).is_some());
369    }
370
371    fn meta_with(run_id: &str, status: RunStatus, updated_at: i64) -> RunMeta {
372        let mut m = RunMeta::new(
373            run_id.to_string(),
374            "a".to_string(),
375            "/p".to_string(),
376            "t".to_string(),
377            None,
378            "/w".to_string(),
379            1,
380        );
381        m.status = status;
382        m.updated_at = updated_at;
383        m
384    }
385
386    #[test]
387    fn classify_restore_skips_terminal_and_ranks_the_rest() {
388        // Terminal → skipped.
389        assert_eq!(classify_restore(&RunStatus::Complete, false), None);
390        assert_eq!(classify_restore(&RunStatus::Error, false), None);
391        assert_eq!(classify_restore(&RunStatus::Cancelled, false), None);
392        // Actionable → Active.
393        assert_eq!(
394            classify_restore(&RunStatus::Running, false),
395            Some(RestorePriority::Active)
396        );
397        assert_eq!(
398            classify_restore(&RunStatus::Starting, false),
399            Some(RestorePriority::Active)
400        );
401        // No immediate progress → Blocked.
402        assert_eq!(
403            classify_restore(&RunStatus::WaitingInput, false),
404            Some(RestorePriority::Blocked)
405        );
406        assert_eq!(
407            classify_restore(&RunStatus::CompleteInteractive, false),
408            Some(RestorePriority::Blocked)
409        );
410        // Parked mid fan-out is Blocked even when otherwise Running.
411        assert_eq!(
412            classify_restore(&RunStatus::Running, true),
413            Some(RestorePriority::Blocked)
414        );
415        // A terminal run parked on a fan-out is still skipped.
416        assert_eq!(classify_restore(&RunStatus::Complete, true), None);
417    }
418
419    #[test]
420    fn triage_orders_actionable_first_then_by_recency_and_drops_terminal() {
421        let candidates = vec![
422            (
423                meta_with("blocked-old", RunStatus::WaitingInput, 100),
424                false,
425            ),
426            (meta_with("active-old", RunStatus::Running, 200), false),
427            (meta_with("terminal", RunStatus::Complete, 999), false),
428            (meta_with("active-new", RunStatus::Starting, 300), false),
429            (meta_with("parked", RunStatus::Running, 999), true), // fan-out → Blocked
430            (
431                meta_with("blocked-new", RunStatus::WaitingInput, 400),
432                false,
433            ),
434        ];
435        let order: Vec<String> = triage_restores(candidates)
436            .into_iter()
437            .map(|m| m.run_id)
438            .collect();
439        // Active tier first (most-recent first), then Blocked tier (most-recent
440        // first, with the fan-out-parked run demoted into it). Terminal dropped.
441        assert_eq!(
442            order,
443            vec![
444                "active-new".to_string(),  // Active, updated 300
445                "active-old".to_string(),  // Active, updated 200
446                "parked".to_string(),      // Blocked (fan-out), updated 999
447                "blocked-new".to_string(), // Blocked, updated 400
448                "blocked-old".to_string(), // Blocked, updated 100
449            ]
450        );
451    }
452
453    #[test]
454    fn restore_with_out_of_range_stage_keeps_spawn_config() {
455        let (mut world, entity) = agent_world();
456        let mut snap = snapshot();
457        snap.stage_name = "s0".to_string();
458        // The blueprint now has fewer stages than the persisted index.
459        restore_agent(&mut world, entity, &snap, 9, 2, TokenTotals::default());
460
461        // Stage jump skipped: cursor + config stay at stage 0.
462        assert_eq!(world.get::<StageCursor>(entity).unwrap().index, 0);
463        assert_eq!(world.get::<StageInference>(entity).unwrap().model, "m0");
464        // State + context still restored.
465        assert_eq!(world.get::<AgentState>(entity).unwrap().iteration, 2);
466        assert_eq!(
467            world
468                .get::<ContextWindow>(entity)
469                .unwrap()
470                .get_region("conversation")
471                .unwrap()
472                .content
473                .len(),
474            2
475        );
476    }
477}