1use std::path::Path;
42
43use bevy_ecs::entity::Entity;
44use leviath_core::run_archive;
45use leviath_core::run_meta::{ContextSnapshot, RunMeta, RunStatus};
46use leviath_runtime::host::SpawnArgs;
47use leviath_runtime::interaction_points::InteractionPointState;
48use leviath_runtime::persistence::{RunMetadata, TokenTotals};
49use leviath_runtime::restore::restore_agent;
50use leviath_runtime::world::PipelineWorld;
51
52use crate::daemon::spawn::{SpawnDeps, build_agent_for_reload};
55
56pub fn reload_persisted_agents(
60 world: &mut PipelineWorld,
61 deps: SpawnDeps<'_>,
62 runs_dir: &Path,
63) -> Vec<(String, leviath_runtime::world::AgentId)> {
64 let mut reloaded: Vec<(RunMeta, Entity)> = Vec::new();
65 let Ok(dir_entries) = std::fs::read_dir(runs_dir) else {
66 return Vec::new(); };
68 let candidates: Vec<(RunMeta, bool)> = dir_entries
71 .flatten()
72 .filter_map(|dir_entry| {
73 let run_dir = dir_entry.path();
74 let meta = read_meta(&run_dir)?; let parked_on_fanout = run_dir.join("fanout.json").exists();
76 Some((meta, parked_on_fanout))
77 })
78 .collect();
79 let ordered = leviath_runtime::restore::triage_restores(candidates);
83 for meta in ordered {
84 let run_dir = runs_dir.join(&meta.run_id);
85 match reload_one(world, deps.clone(), &meta, &run_dir) {
86 Ok(entity) => reloaded.push((meta, entity)),
87 Err(e) => {
88 tracing::warn!(run_id = %meta.run_id, error = %e, "skipping un-reloadable run");
89 mark_crashed(&run_dir, meta, &e.to_string(), deps.now_secs);
90 }
91 }
92 }
93 relink_tree(world, &reloaded);
97 restore_fan_outs(world, &reloaded, runs_dir);
98 reloaded
101 .into_iter()
102 .map(|(meta, entity)| (meta.run_id, world.own_agent(entity)))
103 .collect()
104}
105
106pub fn reload_run(
112 world: &mut PipelineWorld,
113 deps: SpawnDeps<'_>,
114 run_id: &str,
115 runs_dir: &std::path::Path,
116) -> Option<leviath_runtime::world::AgentId> {
117 let run_dir = runs_dir.join(run_id);
118 let meta = read_meta(&run_dir)?;
119 if is_terminal(&meta.status) {
120 return None; }
122 let entity = reload_one(world, deps, &meta, &run_dir).ok()?;
123 Some(world.own_agent(entity))
124}
125
126fn restore_fan_outs(world: &mut PipelineWorld, reloaded: &[(RunMeta, Entity)], runs_dir: &Path) {
132 let by_run_id: std::collections::HashMap<&str, Entity> = reloaded
133 .iter()
134 .map(|(m, e)| (m.run_id.as_str(), *e))
135 .collect();
136 for (meta, entity) in reloaded {
137 let path = runs_dir.join(&meta.run_id).join("fanout.json");
138 let Some(state) = std::fs::read_to_string(&path)
139 .ok()
140 .and_then(|s| serde_json::from_str::<leviath_runtime::fanout::FanOutState>(&s).ok())
141 else {
142 continue;
143 };
144 leviath_runtime::fanout::restore_fan_out_waiting(
145 world.world_mut(),
146 *entity,
147 state,
148 &|rid| by_run_id.get(rid).copied(),
149 );
150 }
151}
152
153fn relink_tree(world: &mut PipelineWorld, reloaded: &[(RunMeta, Entity)]) {
159 use leviath_runtime::components::{AgentState, ParentRef, SubAgentChildren};
160
161 let by_run_id: std::collections::HashMap<&str, Entity> = reloaded
162 .iter()
163 .map(|(m, e)| (m.run_id.as_str(), *e))
164 .collect();
165 let w = world.world_mut();
166 for (meta, entity) in reloaded {
167 if let Some(parent_id) = &meta.parent_run_id {
169 match by_run_id.get(parent_id.as_str()) {
170 Some(&parent_entity) => {
171 w.entity_mut(*entity).insert(ParentRef {
172 parent_entity,
173 parent_agent_id: parent_id.clone(),
174 depth: meta.depth,
175 });
176 }
177 None => tracing::warn!(
178 run_id = %meta.run_id, parent = %parent_id,
179 "parent run did not reload; leaving child unlinked"
180 ),
181 }
182 }
183 if !meta.children.is_empty() {
185 let children: Vec<Entity> = meta
186 .children
187 .iter()
188 .filter_map(|cid| by_run_id.get(cid.as_str()).copied())
189 .collect();
190 if !children.is_empty() {
191 w.entity_mut(*entity).insert(SubAgentChildren {
192 children,
193 max_child_depth: meta.max_child_depth,
194 });
195 }
196 w.get_mut::<AgentState>(*entity)
200 .expect("a reloaded agent always has AgentState")
201 .spawned_children_ids = meta.children.clone();
202 }
203 }
204}
205
206fn read_meta(run_dir: &Path) -> Option<RunMeta> {
209 let text = std::fs::read_to_string(run_dir.join("meta.json")).ok()?;
210 serde_json::from_str(&text).ok()
211}
212
213fn totals_from(meta: &RunMeta) -> TokenTotals {
215 TokenTotals {
216 prompt_tokens: meta.prompt_tokens,
217 completion_tokens: meta.completion_tokens,
218 cached_tokens: meta.cached_tokens,
219 cache_write_tokens: meta.cache_write_tokens,
220 tool_calls: meta.tool_calls,
221 }
222}
223
224fn mark_crashed(run_dir: &Path, meta: RunMeta, reason: &str, now_secs: i64) {
236 let crashed = RunMeta {
237 status: RunStatus::Error,
238 error: Some(format!(
239 "the daemon exited while this run was active and it could not be recovered: {reason}"
240 )),
241 updated_at: now_secs,
242 ..meta
243 };
244 if let Err(e) = crate::runstate::write_meta_to(run_dir, &crashed) {
245 tracing::warn!(
246 run_id = %crashed.run_id,
247 error = %e,
248 "could not record an un-reloadable run as crashed"
249 );
250 }
251}
252
253fn is_terminal(status: &RunStatus) -> bool {
255 matches!(
256 status,
257 RunStatus::Complete | RunStatus::Cancelled | RunStatus::Error
258 )
259}
260
261fn reload_one(
265 world: &mut PipelineWorld,
266 deps: SpawnDeps<'_>,
267 meta: &RunMeta,
268 run_dir: &Path,
269) -> Result<Entity, String> {
270 let args = SpawnArgs {
271 run_id: meta.run_id.clone(),
272 blueprint_path: meta.agent_path.clone(),
273 task: meta.task.clone(),
274 regions: Default::default(),
278 model: meta.model.clone(),
279 workdir: meta.workdir.clone(),
280 metadata: meta.metadata.clone(),
281 callback_url: meta.callback_url.clone(),
282 callback_secret: meta.callback_secret.clone(),
283 yolo: meta.yolo,
294 no_seed_commands: true,
297 allow: Vec::new(),
298 max_depth: None,
299 parent_run_id: meta.parent_run_id.clone(),
300 output: meta.output_request.clone(),
304 };
305 let entity = build_agent_for_reload(world.world_mut(), deps, &args)?;
306
307 let folded = std::fs::read(run_dir.join("run.lvr"))
318 .ok()
319 .and_then(|bytes| run_archive::read_archive_lenient(&mut bytes.as_slice()).ok())
320 .and_then(|(_version, records)| run_archive::fold(&records));
321 let (snapshot, stage_index, iteration, totals, pending_batch) = match folded {
322 Some(folded) => {
323 let totals = totals_from(&folded.meta);
324 (
325 folded.context,
326 folded.meta.stage_index,
327 folded.meta.iteration,
328 totals,
329 folded.pending_batch,
330 )
331 }
332 None => {
333 let snapshot = std::fs::read_to_string(run_dir.join("context.json"))
334 .ok()
335 .and_then(|s| serde_json::from_str::<ContextSnapshot>(&s).ok())
336 .unwrap_or_else(|| ContextSnapshot {
337 stage_name: meta.current_stage.clone(),
338 total_tokens: 0,
339 max_tokens: 0,
340 regions: Vec::new(),
341 });
342 (
343 snapshot,
344 meta.stage_index,
345 meta.iteration,
346 totals_from(meta),
347 None,
350 )
351 }
352 };
353 restore_agent(
354 world.world_mut(),
355 entity,
356 &snapshot,
357 stage_index,
358 iteration,
359 totals,
360 );
361
362 leviath_runtime::restore::restore_stage_ledger(
370 world.world_mut(),
371 entity,
372 &crate::runstate::read_stages_index_from(run_dir),
373 );
374
375 if let Some(leviath_core::run_meta::WaitReason::NeedsSetup { blocker, remedy }) =
384 &meta.waiting_on
385 {
386 world
387 .world_mut()
388 .entity_mut(entity)
389 .insert(leviath_runtime::pipeline::PausedForSetup {
390 blocker: *blocker,
391 remedy: remedy.clone(),
392 });
393 }
394
395 if let Some(batch) = pending_batch {
402 leviath_runtime::restore::restore_pending_batch(
403 world.world_mut(),
404 entity,
405 &batch,
406 &meta.children,
407 );
408 }
409
410 {
412 let mut md = world
413 .world_mut()
414 .get_mut::<RunMetadata>(entity)
415 .expect("build_agent attached run metadata");
416 md.started_at = meta.started_at;
417 md.title = meta.title.clone();
418 md.callback_url = meta.callback_url.clone();
419 md.callback_secret = meta.callback_secret.clone();
420 }
422
423 {
426 let mut flags = world
427 .world_mut()
428 .get_mut::<leviath_runtime::persistence::RunOutcomeFlags>(entity)
429 .expect("build_agent attached run outcome flags");
430 flags.0 = meta.flags.clone();
431 }
432
433 if let Some(output) = read_final_output_from(run_dir, meta) {
441 world
442 .world_mut()
443 .entity_mut(entity)
444 .insert(leviath_runtime::persistence::FinalOutput(output));
445 }
446
447 if let Some(state) = std::fs::read_to_string(run_dir.join("interactions.json"))
453 .ok()
454 .and_then(|s| serde_json::from_str::<InteractionPointState>(&s).ok())
455 {
456 let agent = world.own_agent(entity);
458 leviath_runtime::interaction_points::restore_interaction_point(
459 world.world_mut(),
460 agent,
461 state,
462 );
463 }
464
465 if meta.status == RunStatus::Paused {
468 world.pause(world.own_agent(entity));
470 }
471
472 Ok(entity)
473}
474
475fn read_final_output_from(dir: &Path, meta: &RunMeta) -> Option<leviath_core::FinalOutput> {
483 let descriptor = meta.final_output.clone()?;
484 let content = std::fs::read_to_string(dir.join(leviath_core::FINAL_OUTPUT_FILE)).ok()?;
485 Some(leviath_core::FinalOutput {
486 content,
487 format: descriptor.format,
488 stage: descriptor.stage,
489 submitted_at: descriptor.submitted_at,
490 truncated: descriptor.truncated,
491 artifacts: descriptor.artifacts,
492 })
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use std::sync::Arc;
502
503 use leviath_mcp::ToolExecutor;
504 use leviath_runtime::host::SubAgentOp;
505 use leviath_runtime::interaction_hub::InteractionHub;
506 use tokio::sync::Mutex;
507 use tokio::sync::mpsc::UnboundedSender;
508
509 use crate::config::Config;
510 use crate::daemon::tool_service::CliToolService;
511
512 use leviath_runtime::ProviderRegistry;
513 use leviath_runtime::components::AgentStatus;
514 use leviath_runtime::inference_pool::InferencePoolConfig;
515 use tokio::runtime::Handle;
516
517 fn sub_tx() -> UnboundedSender<SubAgentOp> {
518 tokio::sync::mpsc::unbounded_channel().0
519 }
520
521 struct FakeProvider;
522 #[async_trait::async_trait]
523 impl leviath_providers::Provider for FakeProvider {
524 async fn infer(
525 &self,
526 _r: &leviath_providers::InferenceRequest,
527 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
528 Err(leviath_providers::ProviderError::Other("t".to_string()))
529 }
530 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
531 1
532 }
533 fn max_context_tokens(&self, _m: &str) -> usize {
534 1000
535 }
536 fn name(&self) -> &str {
537 "fake"
538 }
539 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
540 leviath_providers::ModelCapabilities::default()
541 }
542 }
543
544 fn test_world() -> (PipelineWorld, Arc<CliToolService>) {
545 let cli = Arc::new(CliToolService::new());
546 let mut registry = ProviderRegistry::new();
547 for p in ["anthropic", "openai", "ollama"] {
548 registry.register(p.to_string(), Arc::new(FakeProvider));
549 }
550 let world = PipelineWorld::new(
551 registry,
552 cli.clone(),
553 InferencePoolConfig::new(),
554 1,
555 None,
556 Handle::current(),
557 );
558 (world, cli)
559 }
560
561 fn coder_manifest() -> String {
562 crate::test_support::inline_coder_manifest()
564 }
565
566 fn write_run(
569 runs_dir: &Path,
570 run_id: &str,
571 agent_path: &str,
572 status: RunStatus,
573 context: Option<&ContextSnapshot>,
574 ) {
575 write_run_tree(RunFixture {
576 runs_dir,
577 run_id,
578 agent_path,
579 status,
580 context,
581 parent_run_id: None,
582 children: &[],
583 depth: 0,
584 max_child_depth: 0,
585 });
586 }
587
588 struct RunFixture<'a> {
597 runs_dir: &'a Path,
598 run_id: &'a str,
599 agent_path: &'a str,
600 status: RunStatus,
601 context: Option<&'a ContextSnapshot>,
602 parent_run_id: Option<&'a str>,
603 children: &'a [&'a str],
604 depth: usize,
605 max_child_depth: usize,
606 }
607
608 fn write_run_tree(f: RunFixture<'_>) {
609 let RunFixture {
610 runs_dir,
611 run_id,
612 agent_path,
613 status,
614 context,
615 parent_run_id,
616 children,
617 depth,
618 max_child_depth,
619 } = f;
620 let dir = runs_dir.join(run_id);
621 std::fs::create_dir_all(&dir).unwrap();
622 let meta = RunMeta {
623 run_id: run_id.to_string(),
624 agent_name: "coder".to_string(),
625 agent_path: agent_path.to_string(),
626 task: "resume me".to_string(),
627 model: None,
628 pid: 0,
629 status,
630 current_stage: "implement".to_string(),
631 stage_index: 0,
632 num_stages: 1,
633 iteration: 5,
634 prompt_tokens: 42,
635 completion_tokens: 7,
636 cached_tokens: 0,
637 cache_write_tokens: 0,
638 tool_calls: 3,
639 workdir: std::env::temp_dir().to_string_lossy().to_string(),
640 started_at: 111,
641 updated_at: 222,
642 last_progress_at: None,
643 error: None,
644 title: Some("Resume Me".to_string()),
645 metadata: std::collections::HashMap::new(),
646 callback_url: Some("http://cb".to_string()),
647 callback_secret: None,
648 parent_run_id: parent_run_id.map(str::to_string),
649 children: children.iter().map(|s| s.to_string()).collect(),
650 depth,
651 max_child_depth,
652 flags: leviath_core::run_meta::RunFlags {
655 modified_files: vec!["src/a.rs".to_string()],
656 modified_file_count: 1,
657 no_output_tools: true,
662 ..Default::default()
663 },
664 yolo: false,
665 read_paths: None,
666 final_output: Some(
670 leviath_core::output::FinalOutput::new(
671 "already answered",
672 Some("markdown".to_string()),
673 "implement".to_string(),
674 777,
675 )
676 .descriptor(),
677 ),
678 output_request: Some(leviath_core::output::OutputSpec {
679 format: Some("a2ui".to_string()),
680 ..Default::default()
681 }),
682 waiting_on: None,
683 };
684 std::fs::write(dir.join("meta.json"), serde_json::to_string(&meta).unwrap()).unwrap();
685 std::fs::write(
688 dir.join(leviath_core::FINAL_OUTPUT_FILE),
689 "already answered",
690 )
691 .unwrap();
692 if let Some(ctx) = context {
693 std::fs::write(
694 dir.join("context.json"),
695 serde_json::to_string(ctx).unwrap(),
696 )
697 .unwrap();
698 }
699 }
700
701 fn agent_dir() -> tempfile::TempDir {
702 let dir = tempfile::tempdir().unwrap();
703 std::fs::write(dir.path().join("agent.leviath"), coder_manifest()).unwrap();
704 dir
705 }
706
707 fn write_run_archive(
711 runs_dir: &Path,
712 run_id: &str,
713 agent_path: &str,
714 stage_index: usize,
715 iteration: usize,
716 prompt_tokens: usize,
717 context: &ContextSnapshot,
718 ) {
719 use leviath_core::run_archive::{self, RunIdentity, RunRecord};
720 let dir = runs_dir.join(run_id);
721 std::fs::create_dir_all(&dir).unwrap();
722 let mut meta = RunMeta::new(
723 run_id.to_string(),
724 "coder".to_string(),
725 agent_path.to_string(),
726 "resume me".to_string(),
727 None,
728 std::env::temp_dir().to_string_lossy().to_string(),
729 1,
730 );
731 meta.status = RunStatus::Running;
732 meta.current_stage = "implement".to_string();
733 meta.stage_index = stage_index;
734 meta.iteration = iteration;
735 meta.prompt_tokens = prompt_tokens;
736 let mut buf = Vec::new();
737 run_archive::write_archive_start(&mut buf, run_archive::RUN_ARCHIVE_VERSION).unwrap();
738 run_archive::write_record(
739 &mut buf,
740 &RunRecord::Header {
741 identity: RunIdentity {
742 run_id: run_id.to_string(),
743 machine_id: "m".to_string(),
744 world_id: "w".to_string(),
745 created_at: 1,
746 },
747 meta: Box::new(meta),
748 },
749 )
750 .unwrap();
751 run_archive::write_record(
752 &mut buf,
753 &RunRecord::ContextCheckpoint {
754 snapshot: context.clone(),
755 at: 2,
756 },
757 )
758 .unwrap();
759 std::fs::write(dir.join("run.lvr"), &buf).unwrap();
760 }
761
762 #[tokio::test]
765 async fn reload_keeps_a_paused_run_paused() {
766 let agent = agent_dir();
767 let manifest = agent.path().join("agent.leviath");
768 let runs = tempfile::tempdir().unwrap();
769 write_run(
770 runs.path(),
771 "run-paused",
772 manifest.to_str().unwrap(),
773 RunStatus::Paused,
774 None,
775 );
776
777 let (mut world, cli) = test_world();
778 let hub = InteractionHub::new();
779 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
780 let restored = reload_persisted_agents(
781 &mut world,
782 crate::daemon::spawn::SpawnDeps {
783 tool_service: cli.as_ref(),
784 config: &Config::default(),
785 shared_mcp: mcp,
786 mcp_tool_defs: &[],
787 hub: &hub,
788 now_secs: 999,
789 subagent_tx: sub_tx().clone(),
790 },
791 runs.path(),
792 );
793
794 assert_eq!(restored.len(), 1);
795 let (run_id, entity) = &restored[0];
796 assert_eq!(run_id, "run-paused");
797 assert_eq!(world.agent_status(*entity), Some(AgentStatus::Paused));
798 }
799
800 #[test]
806 fn a_descriptor_without_its_sidecar_restores_nothing() {
807 let dir = tempfile::tempdir().unwrap();
808 let mut meta = RunMeta::new(
809 "run-1".to_string(),
810 "a".to_string(),
811 "/p".to_string(),
812 "t".to_string(),
813 None,
814 "/w".to_string(),
815 1,
816 );
817
818 assert!(read_final_output_from(dir.path(), &meta).is_none());
820
821 let answer = leviath_core::output::FinalOutput::new(
823 "already answered",
824 Some("markdown".to_string()),
825 "implement".to_string(),
826 777,
827 );
828 meta.final_output = Some(answer.descriptor());
829 assert!(read_final_output_from(dir.path(), &meta).is_none());
830
831 std::fs::write(
833 dir.path().join(leviath_core::FINAL_OUTPUT_FILE),
834 &answer.content,
835 )
836 .unwrap();
837 let restored = read_final_output_from(dir.path(), &meta).expect("both halves");
838 assert_eq!(restored.content, "already answered");
839 assert_eq!(restored.stage, "implement");
840 }
841
842 async fn reload_single(runs: &Path, run_id: &str) -> (PipelineWorld, Entity) {
843 let (mut world, cli) = test_world();
844 let restored = reload_persisted_agents(
845 &mut world,
846 crate::daemon::spawn::SpawnDeps {
847 tool_service: cli.as_ref(),
848 config: &Config::default(),
849 shared_mcp: Arc::new(Mutex::new(ToolExecutor::new())),
850 mcp_tool_defs: &[],
851 hub: &InteractionHub::new(),
852 now_secs: 999,
853 subagent_tx: sub_tx().clone(),
854 },
855 runs,
856 );
857 assert_eq!(restored.len(), 1);
858 assert_eq!(restored[0].0, run_id);
859 let entity = restored[0].1;
860 (world, entity.entity())
861 }
862
863 #[tokio::test]
867 async fn reload_keeps_an_unattended_run_unattended() {
868 let agent = agent_dir();
869 let manifest = agent.path().join("agent.leviath");
870 let runs = tempfile::tempdir().unwrap();
871 write_run(
872 runs.path(),
873 "run-yolo",
874 manifest.to_str().unwrap(),
875 RunStatus::Running,
876 None,
877 );
878 let meta_path = runs.path().join("run-yolo").join("meta.json");
880 let mut meta: RunMeta =
881 serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
882 meta.yolo = true;
883 std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
884
885 let (world, entity) = reload_single(runs.path(), "run-yolo").await;
886 assert!(
887 world
888 .world()
889 .get::<RunMetadata>(entity)
890 .expect("reloaded run has metadata")
891 .unattended
892 );
893 assert!(
894 world
895 .world()
896 .get::<leviath_runtime::components::InteractionAutoApprove>(entity)
897 .is_some(),
898 "an unattended reload still auto-approves its checkpoints"
899 );
900 }
901
902 #[tokio::test]
905 async fn reload_does_not_invent_unattended() {
906 let agent = agent_dir();
907 let manifest = agent.path().join("agent.leviath");
908 let runs = tempfile::tempdir().unwrap();
909 write_run(
910 runs.path(),
911 "run-plain",
912 manifest.to_str().unwrap(),
913 RunStatus::Running,
914 None,
915 );
916 let meta_path = runs.path().join("run-plain").join("meta.json");
918 let mut raw: serde_json::Value =
919 serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
920 raw.as_object_mut().unwrap().remove("yolo");
921 std::fs::write(&meta_path, serde_json::to_string(&raw).unwrap()).unwrap();
922
923 let (world, entity) = reload_single(runs.path(), "run-plain").await;
924 assert!(
925 !world
926 .world()
927 .get::<RunMetadata>(entity)
928 .expect("reloaded run has metadata")
929 .unattended
930 );
931 }
932
933 #[tokio::test]
934 async fn reloads_nonterminal_runs_and_restores_state() {
935 let agent = agent_dir();
936 let manifest = agent.path().join("agent.leviath");
937 let runs = tempfile::tempdir().unwrap();
938
939 let ctx = ContextSnapshot {
941 stage_name: "implement".to_string(),
942 total_tokens: 4,
943 max_tokens: 100_000,
944 regions: vec![leviath_core::run_meta::RegionSnapshot {
945 name: "conversation".to_string(),
946 kind: "clearable".to_string(),
947 current_tokens: 4,
948 max_tokens: 100_000,
949 entries: vec![leviath_core::run_meta::RegionEntrySnapshot {
950 content: "earlier turn".to_string(),
951 tokens: 4,
952 kind: leviath_core::region::EntryKind::UserMessage,
953 metadata: None,
954 key: None,
955 taint: Default::default(),
956 }],
957 }],
958 };
959 write_run(
960 runs.path(),
961 "run-live",
962 manifest.to_str().unwrap(),
963 RunStatus::Running,
964 Some(&ctx),
965 );
966 write_run(
968 runs.path(),
969 "run-done",
970 manifest.to_str().unwrap(),
971 RunStatus::Complete,
972 None,
973 );
974
975 let (mut world, cli) = test_world();
976 let hub = InteractionHub::new();
977 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
978 let restored = reload_persisted_agents(
979 &mut world,
980 crate::daemon::spawn::SpawnDeps {
981 tool_service: cli.as_ref(),
982 config: &Config::default(),
983 shared_mcp: mcp,
984 mcp_tool_defs: &[],
985 hub: &hub,
986 now_secs: 999,
987 subagent_tx: sub_tx().clone(),
988 },
989 runs.path(),
990 );
991
992 assert_eq!(restored.len(), 1);
993 let (run_id, entity) = &restored[0];
994 assert_eq!(run_id, "run-live");
995 assert_eq!(world.agent_status(*entity), Some(AgentStatus::Active));
996 let md = world.world().get::<RunMetadata>(entity.entity()).unwrap();
998 assert_eq!(md.started_at, 111);
999 assert_eq!(md.title.as_deref(), Some("Resume Me"));
1000 assert_eq!(md.callback_url.as_deref(), Some("http://cb"));
1001 let totals = world.world().get::<TokenTotals>(entity.entity()).unwrap();
1002 assert_eq!(totals.prompt_tokens, 42);
1003 assert_eq!(totals.tool_calls, 3);
1004 let flags = world
1007 .world()
1008 .get::<leviath_runtime::persistence::RunOutcomeFlags>(entity.entity())
1009 .unwrap();
1010 assert_eq!(flags.0.modified_files, vec!["src/a.rs".to_string()]);
1011 assert_eq!(flags.0.modified_file_count, 1);
1012 assert!(flags.0.no_output_tools);
1015 let output = world
1019 .world()
1020 .get::<leviath_runtime::persistence::FinalOutput>(entity.entity())
1021 .expect("a submitted answer survives the restart");
1022 assert_eq!(output.0.content, "already answered");
1023 assert_eq!(output.0.stage, "implement");
1024 assert_eq!(
1027 md.output_request.as_ref().and_then(|s| s.format.as_deref()),
1028 Some("a2ui")
1029 );
1030 }
1031
1032 fn assert_restored_from_archive(world: &PipelineWorld, entity: Entity) {
1035 use leviath_runtime::components::AgentState;
1036 let state = world.world().get::<AgentState>(entity).unwrap();
1037 assert_eq!(state.current_stage, "fresh-stage");
1040 assert_eq!(state.iteration, 9);
1041 let totals = world.world().get::<TokenTotals>(entity).unwrap();
1043 assert_eq!(totals.prompt_tokens, 99);
1044 }
1045
1046 #[tokio::test]
1051 async fn reload_prefers_the_atomic_journal_over_a_stale_context_json() {
1052 let agent = agent_dir();
1053 let manifest = agent.path().join("agent.leviath");
1054 let mpath = manifest.to_str().unwrap();
1055 let runs = tempfile::tempdir().unwrap();
1056
1057 let stale = ContextSnapshot {
1061 stage_name: "stale-stage".to_string(),
1062 total_tokens: 1,
1063 max_tokens: 100,
1064 regions: vec![],
1065 };
1066 write_run(
1067 runs.path(),
1068 "run-torn",
1069 mpath,
1070 RunStatus::Running,
1071 Some(&stale),
1072 );
1073 let fresh = ContextSnapshot {
1076 stage_name: "fresh-stage".to_string(),
1077 total_tokens: 4,
1078 max_tokens: 100_000,
1079 regions: vec![],
1080 };
1081 write_run_archive(runs.path(), "run-torn", mpath, 0, 9, 99, &fresh);
1082
1083 let (mut world, cli) = test_world();
1084 let hub = InteractionHub::new();
1085 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1086 let restored = reload_persisted_agents(
1087 &mut world,
1088 crate::daemon::spawn::SpawnDeps {
1089 tool_service: cli.as_ref(),
1090 config: &Config::default(),
1091 shared_mcp: mcp,
1092 mcp_tool_defs: &[],
1093 hub: &hub,
1094 now_secs: 999,
1095 subagent_tx: sub_tx().clone(),
1096 },
1097 runs.path(),
1098 );
1099
1100 assert_eq!(restored.len(), 1);
1101 assert_restored_from_archive(&world, restored[0].1.entity());
1102 }
1103
1104 #[tokio::test]
1108 async fn reload_tolerates_a_torn_journal_tail() {
1109 let agent = agent_dir();
1110 let manifest = agent.path().join("agent.leviath");
1111 let mpath = manifest.to_str().unwrap();
1112 let runs = tempfile::tempdir().unwrap();
1113
1114 let stale = ContextSnapshot {
1115 stage_name: "stale-stage".to_string(),
1116 total_tokens: 1,
1117 max_tokens: 100,
1118 regions: vec![],
1119 };
1120 write_run(
1121 runs.path(),
1122 "run-torn2",
1123 mpath,
1124 RunStatus::Running,
1125 Some(&stale),
1126 );
1127 let fresh = ContextSnapshot {
1128 stage_name: "fresh-stage".to_string(),
1129 total_tokens: 4,
1130 max_tokens: 100_000,
1131 regions: vec![],
1132 };
1133 write_run_archive(runs.path(), "run-torn2", mpath, 0, 9, 99, &fresh);
1134 {
1136 use std::io::Write;
1137 let mut f = std::fs::OpenOptions::new()
1138 .append(true)
1139 .open(runs.path().join("run-torn2/run.lvr"))
1140 .unwrap();
1141 f.write_all(&[0, 0, 0, 0, 0, 0, 0, 10, 1, 2]).unwrap();
1142 }
1143
1144 let (mut world, cli) = test_world();
1145 let hub = InteractionHub::new();
1146 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1147 let restored = reload_persisted_agents(
1148 &mut world,
1149 crate::daemon::spawn::SpawnDeps {
1150 tool_service: cli.as_ref(),
1151 config: &Config::default(),
1152 shared_mcp: mcp,
1153 mcp_tool_defs: &[],
1154 hub: &hub,
1155 now_secs: 999,
1156 subagent_tx: sub_tx().clone(),
1157 },
1158 runs.path(),
1159 );
1160
1161 assert_eq!(restored.len(), 1);
1162 assert_restored_from_archive(&world, restored[0].1.entity());
1164 }
1165
1166 fn append_archive_records(
1169 runs_dir: &Path,
1170 run_id: &str,
1171 records: &[leviath_core::run_archive::RunRecord],
1172 ) {
1173 use std::io::Write;
1174 let mut buf = Vec::new();
1175 for r in records {
1176 leviath_core::run_archive::write_record(&mut buf, r).unwrap();
1177 }
1178 let mut f = std::fs::OpenOptions::new()
1179 .append(true)
1180 .open(runs_dir.join(run_id).join("run.lvr"))
1181 .unwrap();
1182 f.write_all(&buf).unwrap();
1183 }
1184
1185 fn batch_call(
1186 id: &str,
1187 name: &str,
1188 result: Option<&str>,
1189 ) -> leviath_core::run_archive::ToolCallRecord {
1190 leviath_core::run_archive::ToolCallRecord {
1191 id: id.to_string(),
1192 name: name.to_string(),
1193 arguments: "{}".to_string(),
1194 result: result.map(str::to_string),
1195 thought_signature: None,
1196 }
1197 }
1198
1199 fn conversation_of(world: &PipelineWorld, entity: Entity) -> Vec<leviath_core::RegionEntry> {
1201 world
1202 .world()
1203 .get::<leviath_runtime::components::ContextWindow>(entity)
1204 .unwrap()
1205 .get_region("conversation")
1206 .unwrap()
1207 .content
1208 .clone()
1209 }
1210
1211 #[tokio::test]
1217 async fn reload_replays_a_pending_tool_batch_instead_of_reexecuting() {
1218 use leviath_core::run_archive::RunRecord;
1219 let agent = agent_dir();
1220 let manifest = agent.path().join("agent.leviath");
1221 let mpath = manifest.to_str().unwrap();
1222 let runs = tempfile::tempdir().unwrap();
1223
1224 write_run(runs.path(), "run-batch", mpath, RunStatus::Running, None);
1225 let ctx = ContextSnapshot {
1226 stage_name: "implement".to_string(),
1227 total_tokens: 0,
1228 max_tokens: 100_000,
1229 regions: vec![],
1230 };
1231 write_run_archive(runs.path(), "run-batch", mpath, 0, 9, 99, &ctx);
1232 append_archive_records(
1233 runs.path(),
1234 "run-batch",
1235 &[
1236 RunRecord::ToolBatch {
1237 calls: vec![
1238 batch_call("c_done", "write_file", None),
1239 batch_call("c_lost", "shell", None),
1240 ],
1241 at: 3,
1242 stage_index: 0,
1243 iteration: 9,
1244 response: "writing then running".to_string(),
1245 },
1246 RunRecord::ToolCallDone {
1247 iteration: 9,
1248 call_id: "c_done".to_string(),
1249 result: "Wrote 42 bytes to x.txt".to_string(),
1250 at: 4,
1251 },
1252 ],
1253 );
1254
1255 let (mut world, cli) = test_world();
1256 let hub = InteractionHub::new();
1257 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1258 let restored = reload_persisted_agents(
1259 &mut world,
1260 crate::daemon::spawn::SpawnDeps {
1261 tool_service: cli.as_ref(),
1262 config: &Config::default(),
1263 shared_mcp: mcp,
1264 mcp_tool_defs: &[],
1265 hub: &hub,
1266 now_secs: 999,
1267 subagent_tx: sub_tx().clone(),
1268 },
1269 runs.path(),
1270 );
1271
1272 assert_eq!(restored.len(), 1);
1273 let entity = restored[0].1;
1274 let entries = conversation_of(&world, entity.entity());
1275 assert!(entries.iter().any(|e| matches!(
1277 &e.kind,
1278 leviath_core::region::EntryKind::AssistantTurn { tool_calls } if tool_calls.len() == 2
1279 )));
1280 assert!(
1282 entries
1283 .iter()
1284 .any(|e| e.content == "Wrote 42 bytes to x.txt")
1285 );
1286 assert!(entries.iter().any(|e| e.content.contains("interrupted")
1288 && e.content.contains("Verify whether it took effect")));
1289 assert!(
1291 world
1292 .world()
1293 .get::<leviath_runtime::pipeline::ReadyToInfer>(entity.entity())
1294 .is_some()
1295 );
1296 }
1297
1298 #[tokio::test]
1302 async fn reload_does_not_replay_a_batch_already_in_the_window() {
1303 use leviath_core::region::EntryKind;
1304 use leviath_core::run_archive::RunRecord;
1305 let agent = agent_dir();
1306 let manifest = agent.path().join("agent.leviath");
1307 let mpath = manifest.to_str().unwrap();
1308 let runs = tempfile::tempdir().unwrap();
1309
1310 write_run(runs.path(), "run-applied", mpath, RunStatus::Running, None);
1311 let ctx = ContextSnapshot {
1313 stage_name: "implement".to_string(),
1314 total_tokens: 2,
1315 max_tokens: 100_000,
1316 regions: vec![leviath_core::run_meta::RegionSnapshot {
1317 name: "conversation".to_string(),
1318 kind: "clearable".to_string(),
1319 current_tokens: 2,
1320 max_tokens: 100_000,
1321 entries: vec![
1322 leviath_core::run_meta::RegionEntrySnapshot {
1323 content: "done".to_string(),
1324 tokens: 1,
1325 kind: EntryKind::AssistantTurn {
1326 tool_calls: vec![leviath_core::region::SerializedToolCall {
1327 id: "c1".to_string(),
1328 name: "write_file".to_string(),
1329 arguments: serde_json::Value::Null,
1330 thought_signature: None,
1331 }],
1332 },
1333 metadata: None,
1334 key: None,
1335 taint: Default::default(),
1336 },
1337 leviath_core::run_meta::RegionEntrySnapshot {
1338 content: "Wrote it".to_string(),
1339 tokens: 1,
1340 kind: EntryKind::ToolResult {
1341 tool_call_id: "c1".to_string(),
1342 tool_name: "write_file".to_string(),
1343 is_error: false,
1344 },
1345 metadata: None,
1346 key: None,
1347 taint: Default::default(),
1348 },
1349 ],
1350 }],
1351 };
1352 write_run_archive(runs.path(), "run-applied", mpath, 0, 9, 99, &ctx);
1353 append_archive_records(
1354 runs.path(),
1355 "run-applied",
1356 &[RunRecord::ToolBatch {
1357 calls: vec![batch_call("c1", "write_file", None)],
1358 at: 3,
1359 stage_index: 0,
1360 iteration: 9,
1361 response: "done".to_string(),
1362 }],
1363 );
1364
1365 let (mut world, cli) = test_world();
1366 let hub = InteractionHub::new();
1367 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1368 let restored = reload_persisted_agents(
1369 &mut world,
1370 crate::daemon::spawn::SpawnDeps {
1371 tool_service: cli.as_ref(),
1372 config: &Config::default(),
1373 shared_mcp: mcp,
1374 mcp_tool_defs: &[],
1375 hub: &hub,
1376 now_secs: 999,
1377 subagent_tx: sub_tx().clone(),
1378 },
1379 runs.path(),
1380 );
1381
1382 assert_eq!(restored.len(), 1);
1383 let entries = conversation_of(&world, restored[0].1.entity());
1384 assert_eq!(
1386 entries
1387 .iter()
1388 .filter(|e| matches!(&e.kind, EntryKind::AssistantTurn { tool_calls } if !tool_calls.is_empty()))
1389 .count(),
1390 1
1391 );
1392 assert!(!entries.iter().any(|e| e.content.contains("interrupted")));
1393 }
1394
1395 fn interactive_agent_dir() -> tempfile::TempDir {
1398 let dir = tempfile::tempdir().unwrap();
1399 std::fs::write(
1400 dir.path().join("agent.leviath"),
1401 crate::test_support::inline_interactive_manifest(),
1402 )
1403 .unwrap();
1404 dir
1405 }
1406
1407 #[tokio::test]
1408 async fn reload_resumes_a_blocked_interaction_point_in_the_waiting_state() {
1409 let agent = interactive_agent_dir();
1410 let manifest = agent.path().join("agent.leviath");
1411 let runs = tempfile::tempdir().unwrap();
1412
1413 write_run(
1415 runs.path(),
1416 "run-await",
1417 manifest.to_str().unwrap(),
1418 RunStatus::WaitingInput,
1419 None,
1420 );
1421 std::fs::write(
1423 runs.path().join("run-await/interactions.json"),
1424 serde_json::to_string(&InteractionPointState {
1425 cursor: 0,
1426 round: 0,
1427 body: "## Plan\n1. do it".to_string(),
1428 })
1429 .unwrap(),
1430 )
1431 .unwrap();
1432
1433 let (mut world, cli) = test_world();
1434 let hub = InteractionHub::new();
1435 world.insert_interaction_hub(hub.clone()); let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1437 let restored = reload_persisted_agents(
1438 &mut world,
1439 crate::daemon::spawn::SpawnDeps {
1440 tool_service: cli.as_ref(),
1441 config: &Config::default(),
1442 shared_mcp: mcp,
1443 mcp_tool_defs: &[],
1444 hub: &hub,
1445 now_secs: 999,
1446 subagent_tx: sub_tx().clone(),
1447 },
1448 runs.path(),
1449 );
1450
1451 assert_eq!(restored.len(), 1);
1452 let (run_id, entity) = &restored[0];
1453 assert_eq!(run_id, "run-await");
1454 assert_eq!(world.agent_status(*entity), Some(AgentStatus::Waiting));
1457 assert!(
1458 world
1459 .world()
1460 .get::<leviath_runtime::interaction_points::AwaitingInteractionPoint>(
1461 entity.entity()
1462 )
1463 .is_some()
1464 );
1465 assert!(
1466 world
1467 .world()
1468 .get::<leviath_runtime::pipeline::ReadyToInfer>(entity.entity())
1469 .is_none(),
1470 "the spawn-set ReadyToInfer is cleared so the inference lane won't fire"
1471 );
1472
1473 for _ in 0..8 {
1475 tokio::task::yield_now().await;
1476 }
1477 let pending = hub.pending();
1478 assert_eq!(pending.len(), 1);
1479 assert_eq!(pending[0].0, "run-await");
1480 assert_eq!(pending[0].1.body.as_deref(), Some("## Plan\n1. do it"));
1481 }
1482
1483 #[tokio::test]
1484 async fn reload_restores_actionable_runs_before_blocked_and_skips_terminal() {
1485 let agent = agent_dir();
1486 let mpath = agent.path().join("agent.leviath");
1487 let mpath = mpath.to_str().unwrap();
1488 let runs = tempfile::tempdir().unwrap();
1489 write_run(
1492 runs.path(),
1493 "aaa-blocked",
1494 mpath,
1495 RunStatus::WaitingInput,
1496 None,
1497 );
1498 write_run(runs.path(), "zzz-active", mpath, RunStatus::Running, None);
1499 write_run(runs.path(), "mmm-done", mpath, RunStatus::Complete, None);
1500
1501 let (mut world, cli) = test_world();
1502 let hub = InteractionHub::new();
1503 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1504 let restored = reload_persisted_agents(
1505 &mut world,
1506 crate::daemon::spawn::SpawnDeps {
1507 tool_service: cli.as_ref(),
1508 config: &Config::default(),
1509 shared_mcp: mcp,
1510 mcp_tool_defs: &[],
1511 hub: &hub,
1512 now_secs: 999,
1513 subagent_tx: sub_tx().clone(),
1514 },
1515 runs.path(),
1516 );
1517
1518 let order: Vec<&str> = restored.iter().map(|(id, _)| id.as_str()).collect();
1520 assert_eq!(order, vec!["zzz-active", "aaa-blocked"]);
1521 }
1522
1523 #[tokio::test]
1524 async fn reload_run_pages_in_nonterminal_only() {
1525 let agent = agent_dir();
1526 let manifest = agent.path().join("agent.leviath");
1527 let mpath = manifest.to_str().unwrap();
1528 let runs = tempfile::tempdir().unwrap();
1529 write_run(runs.path(), "live", mpath, RunStatus::Running, None);
1530 write_run(runs.path(), "done", mpath, RunStatus::Complete, None);
1531
1532 let (mut world, cli) = test_world();
1533 let hub = InteractionHub::new();
1534 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1535
1536 assert!(
1538 reload_run(
1539 &mut world,
1540 crate::daemon::spawn::SpawnDeps {
1541 tool_service: cli.as_ref(),
1542 config: &Config::default(),
1543 shared_mcp: mcp.clone(),
1544 mcp_tool_defs: &[],
1545 hub: &hub,
1546 now_secs: 1,
1547 subagent_tx: sub_tx().clone(),
1548 },
1549 "live",
1550 runs.path(),
1551 )
1552 .is_some()
1553 );
1554 assert!(
1556 reload_run(
1557 &mut world,
1558 crate::daemon::spawn::SpawnDeps {
1559 tool_service: cli.as_ref(),
1560 config: &Config::default(),
1561 shared_mcp: mcp.clone(),
1562 mcp_tool_defs: &[],
1563 hub: &hub,
1564 now_secs: 1,
1565 subagent_tx: sub_tx().clone(),
1566 },
1567 "done",
1568 runs.path(),
1569 )
1570 .is_none()
1571 );
1572 assert!(
1574 reload_run(
1575 &mut world,
1576 crate::daemon::spawn::SpawnDeps {
1577 tool_service: cli.as_ref(),
1578 config: &Config::default(),
1579 shared_mcp: mcp,
1580 mcp_tool_defs: &[],
1581 hub: &hub,
1582 now_secs: 1,
1583 subagent_tx: sub_tx().clone(),
1584 },
1585 "no-such-run",
1586 runs.path(),
1587 )
1588 .is_none()
1589 );
1590 }
1591
1592 #[tokio::test]
1593 async fn resumes_a_parent_parked_mid_fan_out() {
1594 use leviath_core::blueprint::{FanOutConfig, WorkerFailurePolicy};
1595 use leviath_runtime::fanout::{FanOutState, FanOutWaiting};
1596
1597 let agent = agent_dir();
1598 let manifest = agent.path().join("agent.leviath");
1599 let mpath = manifest.to_str().unwrap();
1600 let runs = tempfile::tempdir().unwrap();
1601
1602 write_run(
1604 runs.path(),
1605 "parent-fo",
1606 mpath,
1607 RunStatus::WaitingInput,
1608 None,
1609 );
1610 let state = FanOutState {
1611 config: FanOutConfig {
1612 worker_agent: None,
1613 worker_stage: Some("w".to_string()),
1614 worker_query: None,
1615 merge_stage: None,
1616 max_workers: 1,
1617 on_worker_failure: WorkerFailurePolicy::Continue,
1618 split_prompt: "s".to_string(),
1619 results_region: None,
1620 max_items: None,
1621 },
1622 max_workers: 1,
1623 pending: vec![],
1624 active: vec![("item-1".to_string(), "worker-fo".to_string())],
1627 summaries: vec![],
1628 failures: vec![],
1629 };
1630 std::fs::write(
1631 runs.path().join("parent-fo").join("fanout.json"),
1632 serde_json::to_string(&state).unwrap(),
1633 )
1634 .unwrap();
1635 write_run(runs.path(), "worker-fo", mpath, RunStatus::Running, None);
1637
1638 write_run(runs.path(), "bad-fo", mpath, RunStatus::WaitingInput, None);
1640 std::fs::write(runs.path().join("bad-fo").join("fanout.json"), b"garbage").unwrap();
1641
1642 let (mut world, cli) = test_world();
1643 let hub = InteractionHub::new();
1644 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1645 let restored = reload_persisted_agents(
1646 &mut world,
1647 crate::daemon::spawn::SpawnDeps {
1648 tool_service: cli.as_ref(),
1649 config: &Config::default(),
1650 shared_mcp: mcp,
1651 mcp_tool_defs: &[],
1652 hub: &hub,
1653 now_secs: 999,
1654 subagent_tx: sub_tx().clone(),
1655 },
1656 runs.path(),
1657 );
1658 let by_id: std::collections::HashMap<_, _> =
1659 restored.iter().map(|(r, e)| (r.clone(), *e)).collect();
1660
1661 assert!(
1663 world
1664 .world()
1665 .get::<FanOutWaiting>(by_id["parent-fo"].entity())
1666 .is_some()
1667 );
1668 assert!(
1669 world
1670 .world()
1671 .get::<FanOutWaiting>(by_id["bad-fo"].entity())
1672 .is_none()
1673 );
1674 }
1675
1676 #[tokio::test]
1677 async fn rebuilds_parent_child_tree_on_reload() {
1678 use leviath_runtime::components::{ParentRef, SubAgentChildren};
1679
1680 let agent = agent_dir();
1681 let manifest = agent.path().join("agent.leviath");
1682 let mpath = manifest.to_str().unwrap();
1683 let runs = tempfile::tempdir().unwrap();
1684
1685 write_run_tree(RunFixture {
1687 runs_dir: runs.path(),
1688 run_id: "parent",
1689 agent_path: mpath,
1690 status: RunStatus::WaitingInput,
1691 context: None,
1692 parent_run_id: None,
1693 children: &["child-a", "child-b"],
1694 depth: 0,
1695 max_child_depth: 4,
1696 });
1697 write_run_tree(RunFixture {
1698 runs_dir: runs.path(),
1699 run_id: "child-a",
1700 agent_path: mpath,
1701 status: RunStatus::Running,
1702 context: None,
1703 parent_run_id: Some("parent"),
1704 children: &[],
1705 depth: 1,
1706 max_child_depth: 0,
1707 });
1708 write_run_tree(RunFixture {
1709 runs_dir: runs.path(),
1710 run_id: "child-b",
1711 agent_path: mpath,
1712 status: RunStatus::Running,
1713 context: None,
1714 parent_run_id: Some("parent"),
1715 children: &[],
1716 depth: 1,
1717 max_child_depth: 0,
1718 });
1719
1720 let (mut world, cli) = test_world();
1721 let hub = InteractionHub::new();
1722 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1723 let restored = reload_persisted_agents(
1724 &mut world,
1725 crate::daemon::spawn::SpawnDeps {
1726 tool_service: cli.as_ref(),
1727 config: &Config::default(),
1728 shared_mcp: mcp,
1729 mcp_tool_defs: &[],
1730 hub: &hub,
1731 now_secs: 999,
1732 subagent_tx: sub_tx().clone(),
1733 },
1734 runs.path(),
1735 );
1736 assert_eq!(restored.len(), 3);
1737 let by_id: std::collections::HashMap<_, _> =
1738 restored.iter().map(|(r, e)| (r.clone(), *e)).collect();
1739 let parent = by_id["parent"];
1740 let child_a = by_id["child-a"];
1741 let child_b = by_id["child-b"];
1742
1743 let kids = world
1745 .world()
1746 .get::<SubAgentChildren>(parent.entity())
1747 .unwrap();
1748 assert_eq!(kids.max_child_depth, 4);
1749 assert_eq!(kids.children.len(), 2);
1750 assert!(
1751 kids.children.contains(&child_a.entity()) && kids.children.contains(&child_b.entity())
1752 );
1753 let pr = world.world().get::<ParentRef>(child_a.entity()).unwrap();
1755 assert_eq!(pr.parent_entity, parent.entity());
1756 assert_eq!(pr.parent_agent_id, "parent");
1757 assert_eq!(pr.depth, 1);
1758 let state = world
1760 .world()
1761 .get::<leviath_runtime::components::AgentState>(parent.entity())
1762 .unwrap();
1763 assert_eq!(state.spawned_children_ids, vec!["child-a", "child-b"]);
1764 }
1765
1766 #[tokio::test]
1767 async fn relink_skips_children_and_parents_that_did_not_reload() {
1768 use leviath_runtime::components::{ParentRef, SubAgentChildren};
1769
1770 let agent = agent_dir();
1771 let manifest = agent.path().join("agent.leviath");
1772 let mpath = manifest.to_str().unwrap();
1773 let runs = tempfile::tempdir().unwrap();
1774
1775 write_run_tree(RunFixture {
1777 runs_dir: runs.path(),
1778 run_id: "lonely-parent",
1779 agent_path: mpath,
1780 status: RunStatus::WaitingInput,
1781 context: None,
1782 parent_run_id: None,
1783 children: &["gone-child"],
1784 depth: 0,
1785 max_child_depth: 2,
1786 });
1787 write_run_tree(RunFixture {
1788 runs_dir: runs.path(),
1789 run_id: "gone-child",
1790 agent_path: mpath,
1791 status: RunStatus::Complete,
1792 context: None,
1794 parent_run_id: Some("lonely-parent"),
1795 children: &[],
1796 depth: 1,
1797 max_child_depth: 0,
1798 });
1799 write_run_tree(RunFixture {
1801 runs_dir: runs.path(),
1802 run_id: "orphan",
1803 agent_path: mpath,
1804 status: RunStatus::Running,
1805 context: None,
1806 parent_run_id: Some("gone-parent"),
1807 children: &[],
1808 depth: 1,
1809 max_child_depth: 0,
1810 });
1811 write_run_tree(RunFixture {
1812 runs_dir: runs.path(),
1813 run_id: "gone-parent",
1814 agent_path: mpath,
1815 status: RunStatus::Error,
1816 context: None,
1817 parent_run_id: None,
1818 children: &["orphan"],
1819 depth: 0,
1820 max_child_depth: 2,
1821 });
1822
1823 let (mut world, cli) = test_world();
1824 let hub = InteractionHub::new();
1825 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1826 let restored = reload_persisted_agents(
1827 &mut world,
1828 crate::daemon::spawn::SpawnDeps {
1829 tool_service: cli.as_ref(),
1830 config: &Config::default(),
1831 shared_mcp: mcp,
1832 mcp_tool_defs: &[],
1833 hub: &hub,
1834 now_secs: 999,
1835 subagent_tx: sub_tx().clone(),
1836 },
1837 runs.path(),
1838 );
1839 assert_eq!(restored.len(), 2);
1841 let by_id: std::collections::HashMap<_, _> =
1842 restored.iter().map(|(r, e)| (r.clone(), *e)).collect();
1843 assert!(
1845 world
1846 .world()
1847 .get::<SubAgentChildren>(by_id["lonely-parent"].entity())
1848 .is_none()
1849 );
1850 assert!(
1852 world
1853 .world()
1854 .get::<ParentRef>(by_id["orphan"].entity())
1855 .is_none()
1856 );
1857 }
1858
1859 #[tokio::test]
1860 async fn reload_without_context_json_still_resumes() {
1861 let agent = agent_dir();
1862 let manifest = agent.path().join("agent.leviath");
1863 let runs = tempfile::tempdir().unwrap();
1864 write_run(
1865 runs.path(),
1866 "run-nocontext",
1867 manifest.to_str().unwrap(),
1868 RunStatus::WaitingInput,
1869 None, );
1871
1872 let (mut world, cli) = test_world();
1873 let hub = InteractionHub::new();
1874 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1875 let restored = reload_persisted_agents(
1876 &mut world,
1877 crate::daemon::spawn::SpawnDeps {
1878 tool_service: cli.as_ref(),
1879 config: &Config::default(),
1880 shared_mcp: mcp,
1881 mcp_tool_defs: &[],
1882 hub: &hub,
1883 now_secs: 999,
1884 subagent_tx: sub_tx().clone(),
1885 },
1886 runs.path(),
1887 );
1888 assert_eq!(restored.len(), 1);
1889 assert!(
1890 world
1891 .world()
1892 .get::<TokenTotals>(restored[0].1.entity())
1893 .is_some()
1894 );
1895 }
1896
1897 #[tokio::test]
1911 async fn reload_keeps_the_reason_a_parked_run_was_parked_for() {
1912 use leviath_core::run_meta::{SetupBlocker, WaitReason};
1913
1914 let agent = agent_dir();
1915 let manifest = agent.path().join("agent.leviath");
1916 let runs = tempfile::tempdir().unwrap();
1917 write_run(
1918 runs.path(),
1919 "run-parked",
1920 manifest.to_str().unwrap(),
1921 RunStatus::Paused,
1922 None,
1923 );
1924 let meta_path = runs.path().join("run-parked").join("meta.json");
1926 let mut meta: leviath_core::run_meta::RunMeta =
1927 serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
1928 meta.waiting_on = Some(WaitReason::NeedsSetup {
1929 blocker: SetupBlocker::ProviderMissing,
1930 remedy: "add it to config.toml".to_string(),
1931 });
1932 std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
1933
1934 let (mut world, cli) = test_world();
1935 let hub = InteractionHub::new();
1936 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
1937 let restored = reload_persisted_agents(
1938 &mut world,
1939 crate::daemon::spawn::SpawnDeps {
1940 tool_service: cli.as_ref(),
1941 config: &Config::default(),
1942 shared_mcp: mcp,
1943 mcp_tool_defs: &[],
1944 hub: &hub,
1945 now_secs: 999,
1946 subagent_tx: sub_tx().clone(),
1947 },
1948 runs.path(),
1949 );
1950 assert_eq!(restored.len(), 1);
1951
1952 let parked = world
1953 .world()
1954 .get::<leviath_runtime::pipeline::PausedForSetup>(restored[0].1.entity())
1955 .expect("the reason came back with the run");
1956 assert_eq!(parked.blocker, SetupBlocker::ProviderMissing);
1957 assert_eq!(parked.remedy, "add it to config.toml");
1958 }
1959
1960 #[tokio::test]
1961 async fn reload_restores_the_persisted_stage_ledger() {
1962 use leviath_core::run_meta::{StageRecord, StageRunStatus};
1963 use leviath_runtime::pipeline::StageLedger;
1964
1965 let agent = agent_dir();
1966 let manifest = agent.path().join("agent.leviath");
1967 let runs = tempfile::tempdir().unwrap();
1968 write_run(
1969 runs.path(),
1970 "run-stages",
1971 manifest.to_str().unwrap(),
1972 RunStatus::Running,
1973 None,
1974 );
1975 let mut analyze = StageRecord::new("analyze".to_string(), 0);
1979 analyze.status = StageRunStatus::Complete;
1980 analyze.entered = true;
1981 analyze.prompt_tokens = 1_234;
1982 analyze.completion_tokens = 56;
1983 analyze.cached_tokens = 7;
1984 analyze.cache_write_tokens = 8;
1985 analyze.first_call_prompt_tokens = Some(400);
1986 analyze.runaway_warned = true;
1987 analyze
1988 .region_tokens
1989 .insert("conversation".to_string(), 900);
1990 analyze.started_at = Some(10);
1991 analyze.ended_at = Some(20);
1992 let mut implement = StageRecord::new("implement".to_string(), 1);
1993 implement.status = StageRunStatus::Active;
1994 implement.entered = true;
1995 implement.prompt_tokens = 77;
1996 implement.started_at = Some(20);
1997 let removed = StageRecord::new("removed_stage".to_string(), 7);
1998 std::fs::write(
1999 runs.path().join("run-stages").join("stages.json"),
2000 serde_json::to_string(&vec![analyze, implement, removed]).unwrap(),
2001 )
2002 .unwrap();
2003
2004 let (mut world, cli) = test_world();
2005 let hub = InteractionHub::new();
2006 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
2007 let restored = reload_persisted_agents(
2008 &mut world,
2009 crate::daemon::spawn::SpawnDeps {
2010 tool_service: cli.as_ref(),
2011 config: &Config::default(),
2012 shared_mcp: mcp,
2013 mcp_tool_defs: &[],
2014 hub: &hub,
2015 now_secs: 999,
2016 subagent_tx: sub_tx().clone(),
2017 },
2018 runs.path(),
2019 );
2020 assert_eq!(restored.len(), 1);
2021
2022 let ledger = world
2023 .world()
2024 .get::<StageLedger>(restored[0].1.entity())
2025 .expect("a reloaded agent carries a stage ledger");
2026 let names: Vec<&str> = ledger.0.iter().map(|r| r.name.as_str()).collect();
2029 assert_eq!(names, vec!["analyze", "implement", "review"]);
2030 assert_eq!(ledger.0[0].prompt_tokens, 1_234);
2031 assert_eq!(ledger.0[0].completion_tokens, 56);
2032 assert_eq!(ledger.0[0].cached_tokens, 7);
2033 assert_eq!(ledger.0[0].cache_write_tokens, 8);
2034 assert_eq!(ledger.0[0].first_call_prompt_tokens, Some(400));
2035 assert!(ledger.0[0].runaway_warned);
2036 assert_eq!(ledger.0[0].region_tokens.get("conversation"), Some(&900));
2037 assert_eq!(ledger.0[0].started_at, Some(10));
2038 assert_eq!(ledger.0[0].ended_at, Some(20));
2039 assert_eq!(ledger.0[0].status, StageRunStatus::Complete);
2040 assert!(ledger.0[0].entered);
2041 assert_eq!(ledger.0[1].prompt_tokens, 77);
2042 assert!(ledger.0[1].entered);
2043 assert_eq!(ledger.0[2].prompt_tokens, 0);
2045 assert!(!ledger.0[2].entered);
2046 assert_eq!(ledger.0[2].index, 2);
2047 }
2048
2049 #[tokio::test]
2050 async fn skips_missing_dir_junk_and_unreloadable_runs() {
2051 let (mut world, cli) = test_world();
2053 let hub = InteractionHub::new();
2054 let mcp = Arc::new(Mutex::new(ToolExecutor::new()));
2055 assert!(
2056 reload_persisted_agents(
2057 &mut world,
2058 crate::daemon::spawn::SpawnDeps {
2059 tool_service: cli.as_ref(),
2060 config: &Config::default(),
2061 shared_mcp: mcp.clone(),
2062 mcp_tool_defs: &[],
2063 hub: &hub,
2064 now_secs: 1,
2065 subagent_tx: sub_tx().clone(),
2066 },
2067 std::path::Path::new("/no/such/runs/dir"),
2068 )
2069 .is_empty()
2070 );
2071
2072 let runs = tempfile::tempdir().unwrap();
2075 std::fs::create_dir_all(runs.path().join("no-meta")).unwrap();
2076 let corrupt = runs.path().join("corrupt");
2077 std::fs::create_dir_all(&corrupt).unwrap();
2078 std::fs::write(corrupt.join("meta.json"), "not json").unwrap();
2079 write_run(
2080 runs.path(),
2081 "run-badpath",
2082 "/no/such/agent.leviath",
2083 RunStatus::Running,
2084 None,
2085 );
2086
2087 let restored = reload_persisted_agents(
2088 &mut world,
2089 crate::daemon::spawn::SpawnDeps {
2090 tool_service: cli.as_ref(),
2091 config: &Config::default(),
2092 shared_mcp: mcp,
2093 mcp_tool_defs: &[],
2094 hub: &hub,
2095 now_secs: 1,
2096 subagent_tx: sub_tx().clone(),
2097 },
2098 runs.path(),
2099 );
2100 assert!(restored.is_empty()); let meta: RunMeta = serde_json::from_str(
2106 &std::fs::read_to_string(runs.path().join("run-badpath").join("meta.json")).unwrap(),
2107 )
2108 .unwrap();
2109 assert_eq!(meta.status, RunStatus::Error);
2110 let error = meta.error.unwrap_or_default();
2111 assert!(error.contains("could not be recovered"), "got: {error}");
2112 assert_eq!(meta.updated_at, 1);
2113 assert!(!runs.path().join("no-meta").join("meta.json").exists());
2115 assert_eq!(
2116 std::fs::read_to_string(corrupt.join("meta.json")).unwrap(),
2117 "not json"
2118 );
2119 }
2120
2121 #[test]
2122 fn marking_a_crash_is_best_effort() {
2123 let runs = tempfile::tempdir().unwrap();
2127 write_run(
2128 runs.path(),
2129 "run-x",
2130 "/no/such/agent.leviath",
2131 RunStatus::Running,
2132 None,
2133 );
2134 let meta = read_meta(&runs.path().join("run-x")).expect("written above");
2135 mark_crashed(&runs.path().join("gone"), meta, "boom", 7);
2136 assert!(!runs.path().join("gone").exists());
2137 }
2138
2139 #[tokio::test]
2140 async fn fake_provider_methods_are_exercised() {
2141 use leviath_providers::Provider;
2142 let p = FakeProvider;
2143 assert_eq!(p.name(), "fake");
2144 assert_eq!(p.count_tokens("t", "m").await, 1);
2145 assert_eq!(p.max_context_tokens("m"), 1000);
2146 let _ = p.capabilities("m");
2147 assert!(
2148 p.infer(&leviath_providers::InferenceRequest {
2149 system: vec![],
2150 messages: vec![],
2151 model: "m".to_string(),
2152 max_tokens: 1,
2153 temperature: 0.0,
2154 tools: vec![],
2155 extra: serde_json::Value::Null,
2156 request_timeout_secs: None,
2157 })
2158 .await
2159 .is_err()
2160 );
2161 }
2162
2163 #[test]
2164 fn is_terminal_covers_all_statuses() {
2165 assert!(is_terminal(&RunStatus::Complete));
2166 assert!(is_terminal(&RunStatus::Cancelled));
2167 assert!(is_terminal(&RunStatus::Error));
2168 assert!(!is_terminal(&RunStatus::Running));
2169 assert!(!is_terminal(&RunStatus::WaitingInput));
2170 }
2171}