Skip to main content

layover_tower/
factory.rs

1//! The loop: taking work that is waiting and turning it into runs that happened.
2//!
3//! # What makes this a factory rather than a supervisor
4//!
5//! [`crate::spawn`] can start one agent. [`crate::dispatch`] can say whether one flight may fly.
6//! This is what puts them together and does it repeatedly — and repetition is where the
7//! interesting failures live, because everything here has to be safe to interrupt.
8//!
9//! # The order within a single run
10//!
11//! ```text
12//! authorise            cheap refusals first: Ground Stop, route, rails
13//! record as started    before the process exists, so an interruption is detectable
14//! spawn                the first irreversible step
15//! wait                 with a timeout, watching for a Ground Stop
16//! read the transcript  what it cost, and whether to believe it
17//! record the outcome   history, which is what the dashboard reads
18//! forget the live mark the run is accounted for; nothing needs to reconcile it
19//! ```
20//!
21//! Every step before `spawn` can be repeated harmlessly. Everything after it has happened whether
22//! or not this process survives to write it down, which is why the live mark goes first and comes
23//! off last.
24
25use 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/// What happened to one piece of work the factory picked up.
54#[derive(Debug)]
55pub enum Dispatched {
56    /// A run started, finished, and was recorded.
57    Ran {
58        /// How it ended.
59        outcome: Outcome,
60        /// What it cost, and whether that figure can be believed.
61        usd: f64,
62        /// Where the figure came from.
63        source: CostSource,
64    },
65    /// The flight was refused before anything started.
66    Refused(Refusal),
67    /// The flight is waiting at a rendezvous for its siblings.
68    ///
69    /// Not a failure and not a run: the work is held, and the agent it was for stays asleep until
70    /// the rest of what it needs arrives.
71    Parked {
72        /// Upstreams still outstanding.
73        waiting_for: Vec<AgentName>,
74    },
75    /// The flight arrived after an `any` join had already woken its agent.
76    ///
77    /// Recorded rather than dropped. Releasing on the first arrival is the point of `any` — waking
78    /// a publisher once per straggler means one pull request per straggler — but work that
79    /// disappears without a record is indistinguishable from work nobody asked for.
80    Superseded,
81    /// Something went wrong that is the factory's fault rather than the flight's.
82    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/// What a drain did.
105#[derive(Debug, Default)]
106pub struct Drained {
107    /// How many runs started and finished.
108    pub ran: usize,
109    /// Barriers given up because nothing could still satisfy them.
110    ///
111    /// Reported rather than counted, because each one is work somebody asked for that will not
112    /// happen, and a number does not say which.
113    pub abandoned: Vec<Abandoned>,
114}
115
116/// A running factory.
117pub 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    /// Opens a factory rooted at `root`, which is where `.layover/` lives.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error when the state directory cannot be created.
135    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    /// Tells runs where Layover's MCP endpoint is, so they can call back into it.
156    ///
157    /// Without this a run is a one-shot: it reads its instructions, does the work and ends, and no
158    /// chain can be longer than the flight that started it. With it, `layover_send` reaches a real
159    /// queue and the route map means something at runtime rather than only at load.
160    #[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    /// The token registry, which the MCP endpoint resolves callers against.
167    #[must_use]
168    pub fn tokens(&self) -> Arc<Tokens> {
169        Arc::clone(&self.tokens)
170    }
171
172    /// The route map this factory is enforcing.
173    #[must_use]
174    pub const fn graph(&self) -> &RouteGraph {
175        &self.graph
176    }
177
178    /// The factory definition this is running.
179    #[must_use]
180    pub const fn config(&self) -> &Config {
181        &self.config
182    }
183
184    /// Whether a Ground Stop is engaged.
185    ///
186    /// Read from disk on every check rather than cached: a kill switch whose state is a snapshot
187    /// taken at startup is a kill switch that does not work while the thing is running.
188    #[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    /// Runs one flight to completion, recording what happened.
198    ///
199    /// `sender` is `None` for a human trigger.
200    ///
201    /// # Errors
202    ///
203    /// Never returns `Err`; a failure is reported as [`Dispatched::Failed`] so that one bad flight
204    /// cannot stop a factory draining the rest of its queue.
205    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        // Counted here rather than after the spawn: a run that starts and is never seen to finish
224        // has still been started, and a cap that only counts completions is not a cap.
225        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        // Minted before the plan is assembled, because the plan is where the token becomes an
238        // argument and an environment variable. It is revoked on every path out of this function:
239        // a token that outlives its run is a finished process that can still send work.
240        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        // The run is written down before the process exists. If this process dies in the next
260        // instant, that record is the only evidence the run happened.
261        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        // The moment the process is gone, before anything else is written. Whatever the child was
297        // doing, it may no longer send flights — and this is the first instant at which that is
298        // certainly true.
299        self.revoke(token.as_deref());
300
301        // A run has happened, so the agent's provisional advice is one run closer to lapsing.
302        // Charged whatever the outcome: a learning that only decays on success would be kept alive
303        // by the failures it was meant to prevent.
304        self.age_learnings(&authorised.name);
305
306        self.settle(&run, itinerary, &authorised, started_at, &finished, ended)
307    }
308
309    /// Prices a finished run, charges the chain for it, and writes it down.
310    ///
311    /// Separate from starting it because everything here happens whether the run went well or
312    /// badly, and because the order of the last three steps is load-bearing: charge, record, then
313    /// forget the live mark.
314    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        // Debited even when the run failed. Money spent is money spent, and a chain that could
327        // retry forever on failures without paying for them is not bounded.
328        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        // Last, because until the outcome is written the run is still unaccounted for. Forgetting
358        // it first would lose a run that this process failed to finish recording.
359        let _ = self.live.finished(run);
360
361        Dispatched::Ran {
362            outcome,
363            usd: reported.usd,
364            source: reported.source,
365        }
366    }
367
368    /// The record for a run whose process never came into being.
369    ///
370    /// Written rather than dropped: from the itinerary's point of view the run was started — it
371    /// spent a slot against the cap before the spawn was attempted — so a history that omits it
372    /// disagrees with the accounting.
373    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    /// Where an agent keeps the notes it writes for its future selves.
400    ///
401    /// Per agent rather than per run: a note written by one run is only worth anything to the
402    /// next, and runs are deliberately fresh.
403    fn agent_dir(&self, agent: &AgentName) -> PathBuf {
404        self.root
405            .join(".layover")
406            .join("hangars")
407            .join(agent.to_string())
408    }
409
410    /// What an agent wrote down for itself, if anything.
411    ///
412    /// Read fresh on every run rather than cached, because a run that finished a moment ago may
413    /// have written the thing this one needs.
414    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    /// What earlier runs of this agent worked out, and how to ask for help.
419    ///
420    /// Best-effort: a factory whose learnings file cannot be read still runs, with one fewer thing
421    /// in the prompt. Refusing to start over unreadable *advice* would turn a nice-to-have into a
422    /// dependency.
423    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    /// Charges a finished run against the agent's provisional learnings.
429    ///
430    /// This is what makes a learning lapse. Without it, "applies now and expires unless later runs
431    /// arrive at it independently" is only the first half — everything proposed once would apply
432    /// forever, which is the approval queue's failure arrived at from the other direction.
433    fn age_learnings(&self, agent: &AgentName) {
434        let Ok(mut learnings) = self.journal.learnings() else {
435            return;
436        };
437
438        // `charge_run` returns the learnings that *lapsed*, not the ones it touched — so an empty
439        // result means "nothing expired this time", not "nothing changed". Saving only when
440        // something lapsed would throw away every decrement in between, and a learning would never
441        // reach zero because it never got past its first step.
442        //
443        // The lapsed list is not reported here because saving already reports it: a lapsed
444        // learning shows as `lapsed` in the dashboard, which is where somebody would look.
445        drop(learnings.charge_run(agent));
446
447        let _ = self.journal.save_learnings(&learnings);
448    }
449
450    /// Assembles everything a run needs to start: its payload, its runner, its environment.
451    ///
452    /// Separate from starting it because every failure here is one the operator caused — a missing
453    /// credential, an unwritable directory — and is worth reporting differently from a child that
454    /// started and went wrong.
455    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    /// Writes this run's MCP configuration and tells the child where to find it.
516    ///
517    /// Returns `None` when there is nothing to wire — no endpoint, no token, or a runner whose CLI
518    /// cannot be told about an MCP server. The environment variables go in regardless of whether
519    /// the runner takes a flag: a CLI that reads `LAYOVER_MCP_URL` directly, and any tool the
520    /// agent shells out to, can use them.
521    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    /// Ends a token's life, if there was one.
548    fn revoke(&self, token: Option<&str>) {
549        if let Some(token) = token {
550            self.tokens.revoke(token);
551        }
552    }
553
554    /// Where history is written.
555    #[must_use]
556    pub fn history_dir(&self) -> PathBuf {
557        self.root.join(".layover").join("history")
558    }
559
560    /// Appends a run to history, as JSON Lines in the day's segment.
561    ///
562    /// Deliberately best-effort: a factory that stops working because it could not write a log
563    /// line has turned an observability problem into an outage.
564    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    /// Runs every flight waiting in `pending`, and every flight those runs send, until nothing is
589    /// left.
590    ///
591    /// Each is taken off the queue **before** it runs. A flight that crashes the factory mid-run
592    /// must not come back on restart and run again: an agent that opened a pull request and was
593    /// interrupted before its outcome was recorded would open a second one.
594    ///
595    /// # Why this loops rather than iterating once
596    ///
597    /// A run can send flights while it is running. Draining the list it started with would leave
598    /// those sitting until something else picked them up, which turns every chain into one hop per
599    /// invocation. `refill` is asked for whatever is queued now, after each pass.
600    ///
601    /// The loop is bounded by the rails rather than by a count: each flight spends a Hop from a
602    /// shared itinerary and each run spends Fuel and a slot against the run cap, so a chain that
603    /// will not settle is cut by the same mechanism that bounds every other chain.
604    ///
605    /// It also stops as soon as a whole pass starts nothing. Only a *run* can send a flight, so a
606    /// pass in which every flight was refused cannot have produced new work, and asking for more
607    /// would spin against a queue the rails have already closed.
608    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    /// Drains, asking `refill` for newly queued work after each pass.
618    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                // Recorded before anything runs, because only this first flight knows which
643                // pipeline opened the chain: a flight an agent sends carries none, and every run
644                // after this one reads the answer from here.
645                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                // The chain is looked up, not created. Every flight in one causal chain is
654                // accounted against the same Hops, Fuel and run cap; minting a fresh itinerary
655                // per flight would reset all three and a loop between two agents would never end.
656                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            // Only a run can send a flight. A pass that started nothing cannot have produced new
676            // work, so asking for more would spin against a queue the rails have already closed.
677            if ran == before {
678                break;
679            }
680
681            batch = refill(&done);
682        }
683
684        // Nothing is running and nothing is queued, so any barrier still holding work is waiting
685        // for something that will never arrive. Giving up loudly beats a silent permanent stall,
686        // which is the worst outcome in this system: a failure at least says something happened.
687        let abandoned = self
688            .barriers
689            .abandon_unreachable(&self.graph, &BTreeSet::new());
690
691        Drained { ran, abandoned }
692    }
693
694    /// Resolves a flight against any rendezvous guarding its destination.
695    ///
696    /// Returns the flight that should actually run — which for a released join is **one** flight
697    /// carrying everything the agent was waiting for, not one run per upstream. Two edges into one
698    /// agent without a join fire it twice; for a publisher that means two pull requests.
699    ///
700    /// Returns `None` when nothing should run: the flight was parked, or it arrived after an `any`
701    /// join had already fired.
702    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            // The common case: the destination declares no join at all.
709            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    /// Where the factory's root is.
724    #[must_use]
725    pub fn root(&self) -> &Path {
726        &self.root
727    }
728
729    /// Variables the factory would pass to `agent`, for reporting rather than for spawning.
730    #[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
740/// Folds everything a join was waiting for into the one flight that wakes its agent.
741///
742/// Each body is labelled with who sent it. A joined agent is looking at several verdicts about the
743/// same work — a test result and a review, say — and "approved" means nothing without knowing
744/// which of them said it.
745///
746/// The surviving flight keeps the first sender for the route check. Every parked sender has a
747/// permitted edge to this agent, so any of them establishes the same thing; taking one keeps the
748/// record honest about the fact that a single run happened.
749fn 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
775/// How a run is recorded, given how it ended and what it said.
776///
777/// `halted` and `timed_out` are deliberately not `failed`: a rail stopping work is the system
778/// doing its job, and colouring it like a crash teaches people to ignore the colour.
779const 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
788/// A line explaining an outcome that is not self-evident.
789///
790/// `succeeded` and `failed` speak for themselves — the agent did or did not do the thing. The
791/// other two are the supervisor's doing rather than the agent's, and a list that does not say so
792/// reads as though the agent chose to stop.
793fn 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    /// A factory whose one agent is a real, harmless command.
827    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        // The mark exists only while the outcome is unknown. One left behind would be recovered
907        // on the next start, and the work done twice.
908        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        // A run that starts and is never seen to finish has still been started. A cap that counted
960        // only completions would let a crashing agent run forever.
961        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        // An agent that prints prose and no cost has spent money nobody can see. The itinerary
981        // has to know that its own total is a lower bound.
982        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        // A flight that crashes the factory mid-run must not come back on restart and run again:
999        // an agent that opened a pull request and was interrupted would open a second one.
1000        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    /// A two-agent factory, so a chain can actually have a second hop.
1039    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        // Otherwise every chain is one hop per invocation: the second flight would sit in the
1088        // queue until something else happened to run.
1089        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                // Stands in for the analyst calling `layover_send`: one flight appears, once.
1102                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        // The rails are per-chain. A fresh itinerary per flight would reset Hops, Fuel and the run
1123        // cap, and a loop between two agents would run forever on a renewed budget.
1124        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        // The loop is bounded by the rails, not by a count of passes. An agent that keeps sending
1159        // has to hit something, and the run cap needs no runner cooperation to bite. This test
1160        // hangs rather than fails if the loop has no termination guarantee.
1161        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            // Never stops offering more work, exactly as a runaway chain would not.
1175            |_| 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        // Nothing is serving MCP, so there is nothing to tell a child about.
1191        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    /// The shape the reference factory is built around: two verdicts, one publisher.
1239    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        // Injected rather than fetched: an agent that forgets to call for its memory simply has
1304        // none, and nothing anywhere would report that it forgot.
1305        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(&notes, "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        // Without this, "applies now and expires unless later runs arrive at it independently" is
1365        // only the first half: everything proposed once would apply forever.
1366        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        // Only the first flight carries a pipeline; one an agent sends has none. Labelling only
1408        // the first hop would leave the rest of a chain looking like it belonged to nothing.
1409        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                // As an agent's `layover_send` would: no pipeline of its own.
1423                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        // Two edges into one agent without a join fire it twice. For a publisher that is two pull
1448        // requests for one piece of work.
1449        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        // "Approved" means nothing without knowing which of them said it.
1495        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        // Silent permanent stalling is the worst outcome in the system. When the drain goes quiet
1525        // with a barrier still holding work, nothing can ever deliver the rest.
1526        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        // A join declares which inputs an agent needs together, not when it may run.
1548        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}