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