1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
22pub enum RestorePriority {
23 Blocked,
27 Active,
31}
32
33pub 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
50pub 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 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
78pub 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 {
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 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 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 {
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 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 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 #[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 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 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 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 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 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 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 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 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 assert_eq!(
412 classify_restore(&RunStatus::Running, true),
413 Some(RestorePriority::Blocked)
414 );
415 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), (
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 assert_eq!(
442 order,
443 vec![
444 "active-new".to_string(), "active-old".to_string(), "parked".to_string(), "blocked-new".to_string(), "blocked-old".to_string(), ]
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 restore_agent(&mut world, entity, &snap, 9, 2, TokenTotals::default());
460
461 assert_eq!(world.get::<StageCursor>(entity).unwrap().index, 0);
463 assert_eq!(world.get::<StageInference>(entity).unwrap().model, "m0");
464 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}