1use std::collections::{BTreeMap, BTreeSet};
26use std::fmt::Write as _;
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29use std::time::Duration;
30
31use jiff::Timestamp;
32use layover_core::agent::AgentName;
33use layover_core::barrier::Delivery;
34use layover_core::config::Config;
35use layover_core::cost::CostSource;
36use layover_core::cost::TokenUsage;
37use layover_core::flight::Flight;
38use layover_core::graph::RouteGraph;
39use layover_core::itinerary::Itinerary;
40use layover_core::payload::{Run, compose};
41use layover_core::queue::Queued;
42use layover_core::run::{Outcome, RunRecord};
43use layover_store::Journal;
44
45use crate::barriers::{Abandoned, Barriers};
46use crate::dispatch::{Refusal, authorise, declared_env, declared_values};
47use crate::runtime::Chains;
48use crate::spawn::{self, Plan};
49use crate::state::{Ledger, Live};
50use crate::tokens::{self, ENDPOINT_VAR, TOKEN_VAR, Tokens};
51use crate::wait::{Ended, wait_for};
52
53#[derive(Debug)]
55pub enum Dispatched {
56 Ran {
58 outcome: Outcome,
60 usd: f64,
62 source: CostSource,
64 },
65 Refused(Refusal),
67 Parked {
72 waiting_for: Vec<AgentName>,
74 },
75 Superseded,
81 Failed(String),
83}
84
85impl std::fmt::Display for Dispatched {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 match self {
88 Self::Ran { outcome, usd, .. } => write!(f, "{outcome} ${usd:.2}"),
89 Self::Refused(refusal) => write!(f, "refused: {refusal}"),
90 Self::Parked { waiting_for } => {
91 let names = waiting_for
92 .iter()
93 .map(ToString::to_string)
94 .collect::<Vec<_>>()
95 .join(", ");
96 write!(f, "waiting for {names}")
97 }
98 Self::Superseded => f.write_str("superseded: the join had already released"),
99 Self::Failed(why) => write!(f, "could not start: {why}"),
100 }
101 }
102}
103
104#[derive(Debug, Default)]
106pub struct Drained {
107 pub ran: usize,
109 pub abandoned: Vec<Abandoned>,
114}
115
116pub struct Factory {
118 config: Config,
119 graph: RouteGraph,
120 root: PathBuf,
121 live: Ledger,
122 journal: Journal,
123 tokens: Arc<Tokens>,
124 chains: Chains,
125 barriers: Barriers,
126 endpoint: Option<String>,
127}
128
129impl Factory {
130 pub fn new(config: Config, root: impl Into<PathBuf>) -> std::io::Result<Self> {
136 let root = root.into();
137 let graph = RouteGraph::from_config(&config);
138 let live = Ledger::open(root.join(".layover").join("state").join("runs"))?;
139 let journal = Journal::open(root.join(".layover").join("journal"))
140 .map_err(|error| std::io::Error::other(error.to_string()))?;
141
142 Ok(Self {
143 config,
144 graph,
145 root,
146 live,
147 journal,
148 tokens: Arc::new(Tokens::new()),
149 chains: Chains::new(),
150 barriers: Barriers::new(),
151 endpoint: None,
152 })
153 }
154
155 #[must_use]
161 pub fn serving_mcp(mut self, endpoint: impl Into<String>) -> Self {
162 self.endpoint = Some(endpoint.into());
163 self
164 }
165
166 #[must_use]
168 pub fn tokens(&self) -> Arc<Tokens> {
169 Arc::clone(&self.tokens)
170 }
171
172 #[must_use]
174 pub const fn graph(&self) -> &RouteGraph {
175 &self.graph
176 }
177
178 #[must_use]
180 pub const fn config(&self) -> &Config {
181 &self.config
182 }
183
184 #[must_use]
189 pub fn ground_stop_engaged(&self) -> bool {
190 self.ground_stop_path().exists()
191 }
192
193 fn ground_stop_path(&self) -> PathBuf {
194 self.root.join(".layover").join("ground-stop")
195 }
196
197 pub fn run_flight(
206 &self,
207 itinerary: &mut Itinerary,
208 sender: Option<&AgentName>,
209 flight: &Flight,
210 ) -> Dispatched {
211 let authorised = match authorise(
212 &self.config,
213 &self.graph,
214 itinerary,
215 sender,
216 flight,
217 self.ground_stop_engaged(),
218 ) {
219 Ok(authorised) => authorised,
220 Err(refusal) => return Dispatched::Refused(refusal),
221 };
222
223 if let Err(denial) = itinerary.record_run_started() {
226 return Dispatched::Refused(Refusal::Rail(denial));
227 }
228
229 let run = layover_core::RunId::generate();
230 let hangar = self
231 .root
232 .join(".layover")
233 .join("hangars")
234 .join(authorised.name.to_string())
235 .join(run.as_str());
236
237 let token = self.endpoint.as_ref().map(|_| {
241 self.tokens.mint(
242 run.clone(),
243 authorised.name.clone(),
244 itinerary.id().clone(),
245 authorised.hops_remaining,
246 )
247 });
248
249 let plan = match self.plan_for(&authorised, &hangar, flight, token.as_deref()) {
250 Ok(plan) => plan,
251 Err(why) => {
252 self.revoke(token.as_deref());
253 return Dispatched::Failed(why);
254 }
255 };
256
257 let started_at = Timestamp::now();
258
259 let started = match spawn::start(&plan) {
262 Ok(started) => started,
263 Err(error) => {
264 self.record(&self.never_started(
265 &run,
266 itinerary,
267 &authorised,
268 started_at,
269 &error.to_string(),
270 ));
271 self.revoke(token.as_deref());
272 return Dispatched::Failed(error.to_string());
273 }
274 };
275
276 let mark = Live {
277 run: run.clone(),
278 itinerary: itinerary.id().clone(),
279 agent: authorised.name.clone(),
280 pid: started.pid(),
281 started_at,
282 hangar: hangar.clone(),
283 };
284 let _ = self.live.starting(&mark);
285
286 let timeout = Some(Duration::from_secs(self.config.defaults.timeout_sec));
287 let (finished, ended) = match wait_for(started, timeout, || self.ground_stop_engaged()) {
288 Ok(result) => result,
289 Err(error) => {
290 let _ = self.live.finished(&run);
291 self.revoke(token.as_deref());
292 return Dispatched::Failed(error.to_string());
293 }
294 };
295
296 self.revoke(token.as_deref());
300
301 self.age_learnings(&authorised.name);
305
306 self.settle(&run, itinerary, &authorised, started_at, &finished, ended)
307 }
308
309 fn settle(
315 &self,
316 run: &layover_core::RunId,
317 itinerary: &mut Itinerary,
318 authorised: &crate::dispatch::Authorised<'_>,
319 started_at: Timestamp,
320 finished: &spawn::Finished,
321 ended: Ended,
322 ) -> Dispatched {
323 let transcript = std::fs::read_to_string(&finished.transcript).unwrap_or_default();
324 let reported = crate::cost::from_transcript(&transcript);
325
326 if reported.source == CostSource::Reported {
329 itinerary.debit_fuel(reported.usd);
330 } else {
331 itinerary.note_unreported_cost();
332 }
333
334 let outcome = outcome_of(ended, finished.succeeded());
335
336 self.record(&RunRecord {
337 run: run.clone(),
338 itinerary: itinerary.id().clone(),
339 agent: authorised.name.clone(),
340 pipeline: self.chains.pipeline_of(itinerary.id()),
341 model: authorised.agent.model.clone(),
342 outcome,
343 started_at,
344 finished_at: Some(finished.finished_at),
345 usd: reported.usd,
346 source: reported.source,
347 usage: TokenUsage {
348 input: reported.input_tokens,
349 output: reported.output_tokens,
350 ..TokenUsage::default()
351 },
352 detail: detail_for(ended),
353 blocked_on: None,
354 pid: None,
355 });
356
357 let _ = self.live.finished(run);
360
361 Dispatched::Ran {
362 outcome,
363 usd: reported.usd,
364 source: reported.source,
365 }
366 }
367
368 fn never_started(
374 &self,
375 run: &layover_core::RunId,
376 itinerary: &Itinerary,
377 authorised: &crate::dispatch::Authorised<'_>,
378 started_at: Timestamp,
379 why: &str,
380 ) -> RunRecord {
381 RunRecord {
382 run: run.clone(),
383 itinerary: itinerary.id().clone(),
384 agent: authorised.name.clone(),
385 pipeline: self.chains.pipeline_of(itinerary.id()),
386 model: authorised.agent.model.clone(),
387 outcome: Outcome::Failed,
388 started_at,
389 finished_at: Some(Timestamp::now()),
390 usd: 0.0,
391 source: CostSource::Unreported,
392 usage: TokenUsage::default(),
393 detail: Some(why.to_owned()),
394 blocked_on: None,
395 pid: None,
396 }
397 }
398
399 fn agent_dir(&self, agent: &AgentName) -> PathBuf {
404 self.root
405 .join(".layover")
406 .join("hangars")
407 .join(agent.to_string())
408 }
409
410 fn memory_of(&self, agent: &AgentName) -> Option<String> {
415 std::fs::read_to_string(self.agent_dir(agent).join("memory.md")).ok()
416 }
417
418 fn brief_for(&self, agent: &AgentName) -> String {
424 let learnings = self.journal.learnings().unwrap_or_default();
425 layover_core::brief::brief(agent, &learnings, true)
426 }
427
428 fn age_learnings(&self, agent: &AgentName) {
434 let Ok(mut learnings) = self.journal.learnings() else {
435 return;
436 };
437
438 drop(learnings.charge_run(agent));
446
447 let _ = self.journal.save_learnings(&learnings);
448 }
449
450 fn plan_for(
456 &self,
457 authorised: &crate::dispatch::Authorised<'_>,
458 hangar: &Path,
459 flight: &Flight,
460 token: Option<&str>,
461 ) -> Result<Plan, String> {
462 let instructions = authorised
463 .agent
464 .prompt
465 .clone()
466 .unwrap_or_else(|| format!("You are `{}`.", authorised.name));
467
468 let payload = compose(&Run {
469 agent: &authorised.name,
470 instructions: &instructions,
471 memory: self.memory_of(&authorised.name).as_deref(),
472 brief: &self.brief_for(&authorised.name),
473 handover: None,
474 body: &flight.body,
475 });
476
477 let mut env = declared_values(authorised.agent);
478 env.extend(
479 spawn::env_from(&declared_env(
480 authorised.agent,
481 &self.config.defaults.env_from,
482 ))
483 .map_err(|e| e.to_string())?,
484 );
485
486 let runner = self
487 .config
488 .runners
489 .get(&authorised.runner)
490 .ok_or_else(|| format!("runner `{}` vanished", authorised.runner))?;
491
492 let work_dir = authorised
493 .agent
494 .work_dir
495 .clone()
496 .unwrap_or_else(|| self.root.join(&self.config.layover.work_dir));
497
498 std::fs::create_dir_all(&work_dir)
499 .map_err(|error| format!("could not make the working directory: {error}"))?;
500
501 let mcp_config = self.wire_mcp(runner, hangar, token, &mut env)?;
502
503 Ok(Plan {
504 agent: authorised.name.clone(),
505 runner: runner.clone(),
506 model: authorised.agent.model.clone(),
507 payload,
508 hangar: hangar.to_path_buf(),
509 work_dir,
510 env,
511 mcp_config,
512 })
513 }
514
515 fn wire_mcp(
522 &self,
523 runner: &layover_core::config::Runner,
524 hangar: &Path,
525 token: Option<&str>,
526 env: &mut BTreeMap<String, String>,
527 ) -> Result<Option<PathBuf>, String> {
528 let (Some(endpoint), Some(token)) = (self.endpoint.as_ref(), token) else {
529 return Ok(None);
530 };
531
532 env.insert(TOKEN_VAR.to_owned(), token.to_owned());
533 env.insert(ENDPOINT_VAR.to_owned(), endpoint.clone());
534
535 let Some(wiring) = runner.mcp.as_ref() else {
536 return Ok(None);
537 };
538
539 std::fs::create_dir_all(hangar)
540 .map_err(|error| format!("could not make the Hangar: {error}"))?;
541
542 tokens::write_config(hangar, &wiring.format, endpoint, token)
543 .map(Some)
544 .map_err(|error| format!("could not write the MCP configuration: {error}"))
545 }
546
547 fn revoke(&self, token: Option<&str>) {
549 if let Some(token) = token {
550 self.tokens.revoke(token);
551 }
552 }
553
554 #[must_use]
556 pub fn history_dir(&self) -> PathBuf {
557 self.root.join(".layover").join("history")
558 }
559
560 fn record(&self, record: &RunRecord) {
565 let dir = self.history_dir();
566 if std::fs::create_dir_all(&dir).is_err() {
567 return;
568 }
569
570 let day = record
571 .started_at
572 .to_string()
573 .chars()
574 .take(10)
575 .collect::<String>();
576 let path = dir.join(format!("runs-{day}.jsonl"));
577
578 if let Ok(mut line) = serde_json::to_string(record) {
579 line.push('\n');
580 let _ = std::fs::OpenOptions::new()
581 .create(true)
582 .append(true)
583 .open(path)
584 .map(|mut file| std::io::Write::write_all(&mut file, line.as_bytes()));
585 }
586 }
587
588 pub fn drain(
609 &self,
610 pending: Vec<Queued>,
611 mut unqueue: impl FnMut(&Flight),
612 mut report: impl FnMut(&Flight, &Dispatched),
613 ) -> Drained {
614 self.drain_with(pending, &mut unqueue, &mut report, |_| Vec::new())
615 }
616
617 pub fn drain_with(
619 &self,
620 pending: Vec<Queued>,
621 unqueue: &mut impl FnMut(&Flight),
622 report: &mut impl FnMut(&Flight, &Dispatched),
623 mut refill: impl FnMut(&[Flight]) -> Vec<Queued>,
624 ) -> Drained {
625 let mut ran = 0;
626 let mut batch = pending;
627 let mut done: Vec<Flight> = Vec::new();
628
629 while !batch.is_empty() {
630 let before = ran;
631
632 for queued in std::mem::take(&mut batch) {
633 if self.ground_stop_engaged() {
634 return Drained {
635 ran,
636 abandoned: Vec::new(),
637 };
638 }
639
640 unqueue(&queued.flight);
641
642 self.chains
646 .opened_by(&queued.flight.itinerary, queued.pipeline.as_ref());
647
648 let Some(flight) = self.past_the_barrier(&queued.flight, report) else {
649 done.push(queued.flight);
650 continue;
651 };
652
653 let sender = flight.from.agent().cloned();
657 let result = self
658 .chains
659 .with(&flight.itinerary, &self.config.defaults, |chain| {
660 self.run_flight(chain, sender.as_ref(), &flight)
661 })
662 .unwrap_or_else(|| {
663 Dispatched::Failed("the itinerary ledger was poisoned".to_owned())
664 });
665
666 report(&flight, &result);
667
668 if matches!(result, Dispatched::Ran { .. }) {
669 ran += 1;
670 }
671
672 done.push(queued.flight);
673 }
674
675 if ran == before {
678 break;
679 }
680
681 batch = refill(&done);
682 }
683
684 let abandoned = self
688 .barriers
689 .abandon_unreachable(&self.graph, &BTreeSet::new());
690
691 Drained { ran, abandoned }
692 }
693
694 fn past_the_barrier(
703 &self,
704 flight: &Flight,
705 report: &mut impl FnMut(&Flight, &Dispatched),
706 ) -> Option<Flight> {
707 match self.barriers.deliver(&self.graph, flight.clone()) {
708 None => Some(flight.clone()),
710 Some(Delivery::Direct(direct)) => Some(*direct),
711 Some(Delivery::Ready(arrived)) => Some(combine(arrived)),
712 Some(Delivery::Parked { waiting_for }) => {
713 report(flight, &Dispatched::Parked { waiting_for });
714 None
715 }
716 Some(Delivery::Late(late)) => {
717 report(&late, &Dispatched::Superseded);
718 None
719 }
720 }
721 }
722
723 #[must_use]
725 pub fn root(&self) -> &Path {
726 &self.root
727 }
728
729 #[must_use]
731 pub fn env_for(&self, agent: &AgentName) -> BTreeMap<String, String> {
732 self.config
733 .agents
734 .get(agent)
735 .map(declared_values)
736 .unwrap_or_default()
737 }
738}
739
740fn combine(mut arrived: Vec<Flight>) -> Flight {
750 let Some(mut first) = arrived.first().cloned() else {
751 unreachable!("a barrier does not release with nothing parked");
752 };
753
754 if arrived.len() == 1 {
755 return first;
756 }
757
758 arrived.sort_by(|a, b| a.from.agent().cmp(&b.from.agent()));
759
760 let mut body = String::new();
761 for flight in &arrived {
762 let who = flight
763 .from
764 .agent()
765 .map_or_else(|| "a human".to_owned(), ToString::to_string);
766
767 let _ = writeln!(body, "## From `{who}`\n\n{}\n", flight.body.trim());
768 }
769
770 first.body.clear();
771 first.body.push_str(body.trim_end());
772 first
773}
774
775const fn outcome_of(ended: Ended, succeeded: bool) -> Outcome {
780 match ended {
781 Ended::TimedOut => Outcome::TimedOut,
782 Ended::Halted => Outcome::Halted,
783 Ended::Exited if succeeded => Outcome::Succeeded,
784 Ended::Exited => Outcome::Failed,
785 }
786}
787
788fn detail_for(ended: Ended) -> Option<String> {
794 match ended {
795 Ended::Exited => None,
796 Ended::TimedOut => {
797 Some("the run outlived `timeout_sec` and its process tree was ended".to_owned())
798 }
799 Ended::Halted => Some("a Ground Stop was engaged and the run was ended".to_owned()),
800 }
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806 use layover_core::flight::{ItineraryId, Origin};
807
808 struct Temp(PathBuf);
809
810 impl Temp {
811 fn new(name: &str) -> Self {
812 let path =
813 std::env::temp_dir().join(format!("layover-factory-{name}-{}", std::process::id()));
814 let _ = std::fs::remove_dir_all(&path);
815 std::fs::create_dir_all(&path).expect("temp dir");
816 Self(path)
817 }
818 }
819
820 impl Drop for Temp {
821 fn drop(&mut self) {
822 let _ = std::fs::remove_dir_all(&self.0);
823 }
824 }
825
826 fn factory(temp: &Temp, command: &str) -> Factory {
828 let text = format!(
829 r#"
830[layover]
831work_dir = "work"
832
833[defaults]
834runner = "shell"
835max_hops = 4
836fuel_usd = 5.0
837max_runs = 10
838timeout_sec = 30
839
840[runners.shell]
841command = {command}
842
843[agents.worker]
844prompt = "work"
845entry = true
846
847[pipelines.build]
848entry = "worker"
849"#
850 );
851
852 let config: Config = toml::from_str(&text).expect("parses");
853 Factory::new(config, &temp.0).expect("opens")
854 }
855
856 fn shell(script: &str) -> String {
857 if cfg!(windows) {
858 format!(r#"["cmd", "/c", "{script}"]"#)
859 } else {
860 format!(r#"["sh", "-c", "{script}"]"#)
861 }
862 }
863
864 fn flight() -> Flight {
865 Flight::new(
866 ItineraryId::generate(),
867 Origin::Human,
868 AgentName::new("worker"),
869 "do the thing",
870 4,
871 )
872 }
873
874 fn itinerary(flight: &Flight) -> Itinerary {
875 Itinerary::new(flight.itinerary.clone(), 4, 5.0, 10)
876 }
877
878 #[test]
879 fn a_flight_becomes_a_run_that_is_written_to_history() {
880 let temp = Temp::new("ran");
881 let factory = factory(&temp, &shell("echo working"));
882 let flight = flight();
883
884 let result = factory.run_flight(&mut itinerary(&flight), None, &flight);
885
886 assert!(
887 matches!(
888 result,
889 Dispatched::Ran {
890 outcome: Outcome::Succeeded,
891 ..
892 }
893 ),
894 "{result:?}"
895 );
896
897 let history: Vec<_> = std::fs::read_dir(factory.history_dir())
898 .expect("history exists")
899 .filter_map(Result::ok)
900 .collect();
901 assert_eq!(history.len(), 1, "one run, one day's segment");
902 }
903
904 #[test]
905 fn a_completed_run_leaves_no_live_mark_to_reconcile() {
906 let temp = Temp::new("clean");
909 let factory = factory(&temp, &shell("echo done"));
910 let flight = flight();
911
912 factory.run_flight(&mut itinerary(&flight), None, &flight);
913
914 assert!(factory.live.live().expect("reads").is_empty());
915 }
916
917 #[test]
918 fn a_failing_agent_is_recorded_as_failed_rather_than_lost() {
919 let temp = Temp::new("failed");
920 let factory = factory(&temp, &shell("exit 4"));
921 let flight = flight();
922
923 let result = factory.run_flight(&mut itinerary(&flight), None, &flight);
924
925 assert!(
926 matches!(
927 result,
928 Dispatched::Ran {
929 outcome: Outcome::Failed,
930 ..
931 }
932 ),
933 "{result:?}"
934 );
935 }
936
937 #[test]
938 fn a_ground_stop_refuses_before_a_process_exists() {
939 let temp = Temp::new("stopped");
940 let factory = factory(&temp, &shell("echo should not run"));
941 std::fs::create_dir_all(temp.0.join(".layover")).expect("dirs");
942 std::fs::write(temp.0.join(".layover").join("ground-stop"), "").expect("engages");
943
944 let flight = flight();
945 let result = factory.run_flight(&mut itinerary(&flight), None, &flight);
946
947 assert!(
948 matches!(result, Dispatched::Refused(Refusal::GroundStop)),
949 "{result:?}"
950 );
951 assert!(
952 !factory.history_dir().exists(),
953 "nothing should have been recorded"
954 );
955 }
956
957 #[test]
958 fn the_run_cap_counts_starts_not_completions() {
959 let temp = Temp::new("cap");
962 let factory = factory(&temp, &shell("echo one"));
963 let flight = flight();
964 let mut chain = Itinerary::new(flight.itinerary.clone(), 4, 5.0, 1);
965
966 assert!(matches!(
967 factory.run_flight(&mut chain, None, &flight),
968 Dispatched::Ran { .. }
969 ));
970
971 let second = factory.run_flight(&mut chain, None, &flight);
972 assert!(
973 matches!(second, Dispatched::Refused(Refusal::Rail(_))),
974 "the second run should hit the cap: {second:?}"
975 );
976 }
977
978 #[test]
979 fn a_run_that_reports_nothing_is_counted_as_a_reporting_gap() {
980 let temp = Temp::new("silent");
983 let factory = factory(&temp, &shell("echo just talking"));
984 let flight = flight();
985 let mut chain = itinerary(&flight);
986
987 factory.run_flight(&mut chain, None, &flight);
988
989 assert!(
990 chain.has_cost_reporting_gap(),
991 "silence is not a measurement"
992 );
993 assert_eq!(chain.unreported_runs(), 1);
994 }
995
996 #[test]
997 fn draining_takes_each_flight_off_the_queue_before_running_it() {
998 let temp = Temp::new("drain");
1001 let factory = factory(&temp, &shell("echo drained"));
1002
1003 let queued = vec![Queued::new(flight(), None, BTreeMap::new())];
1004 let mut removed = Vec::new();
1005
1006 let ran = factory.drain(
1007 queued,
1008 |flight| removed.push(flight.id.as_str().to_owned()),
1009 |_, _| {},
1010 );
1011
1012 assert_eq!(ran.ran, 1);
1013 assert_eq!(removed.len(), 1, "taken off the queue exactly once");
1014 }
1015
1016 #[test]
1017 fn draining_stops_the_moment_a_ground_stop_appears() {
1018 let temp = Temp::new("drain-stop");
1019 let factory = factory(&temp, &shell("echo x"));
1020 std::fs::create_dir_all(temp.0.join(".layover")).expect("dirs");
1021 std::fs::write(temp.0.join(".layover").join("ground-stop"), "").expect("engages");
1022
1023 let queued = vec![
1024 Queued::new(flight(), None, BTreeMap::new()),
1025 Queued::new(flight(), None, BTreeMap::new()),
1026 ];
1027 let mut removed = 0;
1028
1029 let ran = factory.drain(queued, |_| removed += 1, |_, _| {});
1030
1031 assert_eq!(ran.ran, 0);
1032 assert_eq!(
1033 removed, 0,
1034 "a stopped factory does not even consume its queue"
1035 );
1036 }
1037
1038 fn pair(temp: &Temp) -> Factory {
1040 let text = format!(
1041 r#"
1042[layover]
1043work_dir = "work"
1044
1045[defaults]
1046runner = "shell"
1047max_hops = 4
1048fuel_usd = 5.0
1049max_runs = 10
1050timeout_sec = 30
1051
1052[runners.shell]
1053command = {}
1054mcp = {{ flag = "--mcp-config", format = "claude_json" }}
1055
1056[agents.analyst]
1057prompt = "analyse"
1058entry = true
1059
1060[agents.developer]
1061prompt = "develop"
1062
1063[pipelines.build]
1064entry = "analyst"
1065
1066[[routes]]
1067from = "analyst"
1068to = "developer"
1069"#,
1070 shell("echo done")
1071 );
1072
1073 let config: Config = toml::from_str(&text).expect("parses");
1074 Factory::new(config, &temp.0).expect("opens")
1075 }
1076
1077 fn queued_to(itinerary: &ItineraryId, from: Origin, to: &str, hops: u32) -> Queued {
1078 Queued::new(
1079 Flight::new(itinerary.clone(), from, AgentName::new(to), "go", hops),
1080 None,
1081 BTreeMap::new(),
1082 )
1083 }
1084
1085 #[test]
1086 fn a_flight_sent_by_a_run_is_picked_up_in_the_same_drain() {
1087 let temp = Temp::new("multihop");
1090 let factory = pair(&temp);
1091 let chain = ItineraryId::generate();
1092
1093 let mut seen = Vec::new();
1094 let mut handed_over = false;
1095
1096 let ran = factory.drain_with(
1097 vec![queued_to(&chain, Origin::Human, "analyst", 4)],
1098 &mut |_| {},
1099 &mut |flight, _| seen.push(flight.to.to_string()),
1100 |_| {
1101 if handed_over {
1103 Vec::new()
1104 } else {
1105 handed_over = true;
1106 vec![queued_to(
1107 &chain,
1108 Origin::Agent(AgentName::new("analyst")),
1109 "developer",
1110 3,
1111 )]
1112 }
1113 },
1114 );
1115
1116 assert_eq!(ran.ran, 2, "both hops ran: {seen:?}");
1117 assert_eq!(seen, ["analyst", "developer"]);
1118 }
1119
1120 #[test]
1121 fn every_hop_of_a_chain_spends_the_same_fuel_and_the_same_run_cap() {
1122 let temp = Temp::new("sharedrails");
1125 let factory = pair(&temp);
1126 let chain = ItineraryId::generate();
1127
1128 let mut refills = 0;
1129 factory.drain_with(
1130 vec![queued_to(&chain, Origin::Human, "analyst", 4)],
1131 &mut |_| {},
1132 &mut |_, _| {},
1133 |_| {
1134 refills += 1;
1135 if refills > 1 {
1136 Vec::new()
1137 } else {
1138 vec![queued_to(
1139 &chain,
1140 Origin::Agent(AgentName::new("analyst")),
1141 "developer",
1142 3,
1143 )]
1144 }
1145 },
1146 );
1147
1148 let started = factory
1149 .chains
1150 .with(&chain, &factory.config.defaults, |c| c.runs_started())
1151 .expect("the chain exists");
1152
1153 assert_eq!(started, 2, "both runs counted against one itinerary");
1154 }
1155
1156 #[test]
1157 fn a_chain_that_will_not_settle_is_cut_by_the_run_cap_rather_than_looping_forever() {
1158 let temp = Temp::new("runaway");
1162 let factory = pair(&temp);
1163 let chain = ItineraryId::generate();
1164
1165 let mut refusals = 0;
1166 let ran = factory.drain_with(
1167 vec![queued_to(&chain, Origin::Human, "analyst", 4)],
1168 &mut |_| {},
1169 &mut |_, result| {
1170 if matches!(result, Dispatched::Refused(_)) {
1171 refusals += 1;
1172 }
1173 },
1174 |_| vec![queued_to(&chain, Origin::Human, "analyst", 4)],
1176 );
1177
1178 assert_eq!(
1179 ran.ran, 10,
1180 "ran up to the configured run cap and no further"
1181 );
1182 assert!(refusals >= 1, "the cap must refuse, not silently stop");
1183 }
1184
1185 #[test]
1186 fn a_run_is_given_a_token_and_an_endpoint_only_when_the_factory_serves_one() {
1187 let temp = Temp::new("notoken");
1188 let factory = pair(&temp);
1189
1190 factory.drain(
1192 vec![queued_to(
1193 &ItineraryId::generate(),
1194 Origin::Human,
1195 "analyst",
1196 4,
1197 )],
1198 |_| {},
1199 |_, _| {},
1200 );
1201
1202 assert_eq!(factory.tokens.live_count(), 0);
1203 }
1204
1205 #[test]
1206 fn a_served_run_gets_an_mcp_config_in_its_hangar_and_loses_its_token_afterwards() {
1207 let temp = Temp::new("served");
1208 let factory = pair(&temp).serving_mcp("http://127.0.0.1:9/mcp");
1209
1210 factory.drain(
1211 vec![queued_to(
1212 &ItineraryId::generate(),
1213 Origin::Human,
1214 "analyst",
1215 4,
1216 )],
1217 |_| {},
1218 |_, _| {},
1219 );
1220
1221 let hangars = temp.0.join(".layover").join("hangars").join("analyst");
1222 let written = std::fs::read_dir(&hangars)
1223 .expect("a Hangar")
1224 .filter_map(Result::ok)
1225 .map(|entry| entry.path().join("mcp.json"))
1226 .find(|path| path.exists())
1227 .and_then(|path| std::fs::read_to_string(path).ok())
1228 .expect("an MCP configuration");
1229
1230 assert!(written.contains("127.0.0.1:9/mcp"), "{written}");
1231 assert_eq!(
1232 factory.tokens.live_count(),
1233 0,
1234 "a token that outlives its run is a finished process that can still send work"
1235 );
1236 }
1237
1238 fn joined(temp: &Temp) -> Factory {
1240 let text = format!(
1241 r#"
1242[layover]
1243work_dir = "work"
1244
1245[defaults]
1246runner = "shell"
1247max_hops = 6
1248fuel_usd = 5.0
1249max_runs = 20
1250timeout_sec = 30
1251
1252[runners.shell]
1253command = {}
1254
1255[agents.developer]
1256prompt = "develop"
1257entry = true
1258
1259[agents.tester]
1260prompt = "test"
1261
1262[agents.reviewer]
1263prompt = "review"
1264
1265[agents.publisher]
1266prompt = "publish"
1267
1268[pipelines.build]
1269entry = "developer"
1270
1271[[routes]]
1272from = "developer"
1273to = ["tester", "reviewer"]
1274
1275[[routes]]
1276from = ["tester", "reviewer"]
1277to = "publisher"
1278join = "all"
1279"#,
1280 shell("echo done")
1281 );
1282
1283 let config: Config = toml::from_str(&text).expect("parses");
1284 Factory::new(config, &temp.0).expect("opens")
1285 }
1286
1287 fn verdict(chain: &ItineraryId, from: &str, to: &str, body: &str) -> Queued {
1288 Queued::new(
1289 Flight::new(
1290 chain.clone(),
1291 Origin::Agent(AgentName::new(from)),
1292 AgentName::new(to),
1293 body,
1294 4,
1295 ),
1296 None,
1297 BTreeMap::new(),
1298 )
1299 }
1300
1301 #[test]
1302 fn a_run_is_given_the_notes_its_agent_wrote_for_itself() {
1303 let temp = Temp::new("memory-injected");
1306 let factory = factory(&temp, &shell("echo x"));
1307
1308 let notes = temp
1309 .0
1310 .join(".layover")
1311 .join("hangars")
1312 .join("worker")
1313 .join("memory.md");
1314 std::fs::create_dir_all(notes.parent().expect("a parent")).expect("dirs");
1315 std::fs::write(¬es, "The e2e suite needs the VPN.\n").expect("writes");
1316
1317 let mut itinerary = itinerary(&flight());
1318 factory.run_flight(&mut itinerary, None, &flight());
1319
1320 let prompt = std::fs::read_dir(temp.0.join(".layover").join("hangars").join("worker"))
1321 .expect("a Hangar")
1322 .filter_map(Result::ok)
1323 .map(|entry| entry.path().join("prompt.md"))
1324 .find(|path| path.exists())
1325 .and_then(|path| std::fs::read_to_string(path).ok())
1326 .expect("a composed prompt");
1327
1328 assert!(prompt.contains("needs the VPN"), "{prompt}");
1329 }
1330
1331 #[test]
1332 fn a_run_is_given_what_earlier_runs_of_it_learned() {
1333 let temp = Temp::new("learnings-injected");
1334 let factory = factory(&temp, &shell("echo x"));
1335
1336 let mut learnings = layover_core::learning::Learnings::new();
1337 learnings.propose(&layover_core::learning::Proposal::new(
1338 AgentName::new("worker"),
1339 "The build cache lives in /var/cache.",
1340 layover_core::learning::Impact::Medium,
1341 Timestamp::now(),
1342 ));
1343 factory
1344 .journal
1345 .save_learnings(&learnings)
1346 .expect("writes learnings");
1347
1348 let mut itinerary = itinerary(&flight());
1349 factory.run_flight(&mut itinerary, None, &flight());
1350
1351 let prompt = std::fs::read_dir(temp.0.join(".layover").join("hangars").join("worker"))
1352 .expect("a Hangar")
1353 .filter_map(Result::ok)
1354 .map(|entry| entry.path().join("prompt.md"))
1355 .find(|path| path.exists())
1356 .and_then(|path| std::fs::read_to_string(path).ok())
1357 .expect("a composed prompt");
1358
1359 assert!(prompt.contains("/var/cache"), "{prompt}");
1360 }
1361
1362 #[test]
1363 fn a_run_ages_the_provisional_advice_it_was_given() {
1364 let temp = Temp::new("learnings-age");
1367 let factory = factory(&temp, &shell("echo x"));
1368
1369 let mut learnings = layover_core::learning::Learnings::new();
1370 learnings.propose(&layover_core::learning::Proposal::new(
1371 AgentName::new("worker"),
1372 "Something provisional.",
1373 layover_core::learning::Impact::Low,
1374 Timestamp::now(),
1375 ));
1376 factory.journal.save_learnings(&learnings).expect("writes");
1377
1378 let before = factory
1379 .journal
1380 .learnings()
1381 .expect("readable")
1382 .all()
1383 .next()
1384 .expect("one")
1385 .runs_left;
1386
1387 let mut itinerary = itinerary(&flight());
1388 factory.run_flight(&mut itinerary, None, &flight());
1389
1390 let after = factory
1391 .journal
1392 .learnings()
1393 .expect("readable")
1394 .all()
1395 .next()
1396 .expect("one")
1397 .runs_left;
1398
1399 assert!(
1400 after < before,
1401 "a run must spend one of its runs: {before} -> {after}"
1402 );
1403 }
1404
1405 #[test]
1406 fn every_run_in_a_chain_is_labelled_with_the_pipeline_that_opened_it() {
1407 let temp = Temp::new("pipeline-label");
1410 let factory = pair(&temp);
1411 let chain = ItineraryId::generate();
1412
1413 let mut opener = queued_to(&chain, Origin::Human, "analyst", 4);
1414 opener.pipeline = Some(layover_core::pipeline::PipelineName::new("build"));
1415
1416 let mut handed_over = false;
1417 factory.drain_with(vec![opener], &mut |_| {}, &mut |_, _| {}, |_| {
1418 if handed_over {
1419 Vec::new()
1420 } else {
1421 handed_over = true;
1422 vec![queued_to(
1424 &chain,
1425 Origin::Agent(AgentName::new("analyst")),
1426 "developer",
1427 3,
1428 )]
1429 }
1430 });
1431
1432 let history = std::fs::read_dir(factory.history_dir())
1433 .expect("a history directory")
1434 .filter_map(Result::ok)
1435 .filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
1436 .collect::<String>();
1437
1438 assert_eq!(
1439 history.matches("\"pipeline\":\"build\"").count(),
1440 2,
1441 "both runs should carry the pipeline: {history}"
1442 );
1443 }
1444
1445 #[test]
1446 fn a_publisher_behind_a_join_runs_once_not_once_per_verdict() {
1447 let temp = Temp::new("join-once");
1450 let factory = joined(&temp);
1451 let chain = ItineraryId::generate();
1452
1453 let mut woke = Vec::new();
1454 let drained = factory.drain(
1455 vec![
1456 verdict(&chain, "tester", "publisher", "tests pass"),
1457 verdict(&chain, "reviewer", "publisher", "looks good"),
1458 ],
1459 |_: &Flight| {},
1460 |flight, result| {
1461 if matches!(result, Dispatched::Ran { .. }) {
1462 woke.push(flight.to.to_string());
1463 }
1464 },
1465 );
1466
1467 assert_eq!(drained.ran, 1, "the publisher ran once: {woke:?}");
1468 assert_eq!(woke, ["publisher"]);
1469 assert!(drained.abandoned.is_empty(), "{:?}", drained.abandoned);
1470 }
1471
1472 #[test]
1473 fn the_first_verdict_parks_and_says_who_it_is_waiting_for() {
1474 let temp = Temp::new("join-park");
1475 let factory = joined(&temp);
1476 let chain = ItineraryId::generate();
1477
1478 let mut parked = Vec::new();
1479 factory.drain(
1480 vec![verdict(&chain, "tester", "publisher", "tests pass")],
1481 |_: &Flight| {},
1482 |_, result| {
1483 if let Dispatched::Parked { waiting_for } = result {
1484 parked.clone_from(waiting_for);
1485 }
1486 },
1487 );
1488
1489 assert_eq!(parked, [AgentName::new("reviewer")]);
1490 }
1491
1492 #[test]
1493 fn a_released_join_hands_the_agent_every_verdict_labelled_by_sender() {
1494 let temp = Temp::new("join-body");
1496 let factory = joined(&temp);
1497 let chain = ItineraryId::generate();
1498
1499 factory.drain(
1500 vec![
1501 verdict(&chain, "tester", "publisher", "17 tests pass"),
1502 verdict(&chain, "reviewer", "publisher", "no blocking comments"),
1503 ],
1504 |_: &Flight| {},
1505 |_, _| {},
1506 );
1507
1508 let payload = std::fs::read_dir(temp.0.join(".layover").join("hangars").join("publisher"))
1509 .expect("a Hangar")
1510 .filter_map(Result::ok)
1511 .map(|entry| entry.path().join("prompt.md"))
1512 .find(|path| path.exists())
1513 .and_then(|path| std::fs::read_to_string(path).ok())
1514 .expect("a composed payload");
1515
1516 assert!(payload.contains("From `tester`"), "{payload}");
1517 assert!(payload.contains("17 tests pass"), "{payload}");
1518 assert!(payload.contains("From `reviewer`"), "{payload}");
1519 assert!(payload.contains("no blocking comments"), "{payload}");
1520 }
1521
1522 #[test]
1523 fn a_join_that_can_never_complete_is_given_up_and_named() {
1524 let temp = Temp::new("join-dead");
1527 let factory = joined(&temp);
1528 let chain = ItineraryId::generate();
1529
1530 let drained = factory.drain(
1531 vec![verdict(&chain, "tester", "publisher", "tests pass")],
1532 |_: &Flight| {},
1533 |_, _| {},
1534 );
1535
1536 assert_eq!(drained.ran, 0, "the publisher never woke");
1537 assert_eq!(drained.abandoned.len(), 1);
1538 assert_eq!(drained.abandoned[0].missing, [AgentName::new("reviewer")]);
1539 assert_eq!(
1540 drained.abandoned[0].stranded, 1,
1541 "the flight that was held is accounted for"
1542 );
1543 }
1544
1545 #[test]
1546 fn a_human_reaching_a_joined_agent_is_not_held_up_by_the_join() {
1547 let temp = Temp::new("join-human");
1549 let factory = joined(&temp);
1550
1551 let direct = Queued::new(
1552 Flight::new(
1553 ItineraryId::generate(),
1554 Origin::Human,
1555 AgentName::new("publisher"),
1556 "publish it anyway",
1557 4,
1558 ),
1559 None,
1560 BTreeMap::new(),
1561 );
1562
1563 let drained = factory.drain(vec![direct], |_| {}, |_, _| {});
1564
1565 assert_eq!(drained.ran, 1, "a human trigger bypasses the barrier");
1566 }
1567}