Skip to main content

layover_tower/
barriers.rs

1//! Barriers a running factory is holding, and what happens to a flight that meets one.
2//!
3//! # Why flights are parked and processes are not
4//!
5//! A joined agent needs several inputs together. The obvious implementation — start it and let it
6//! block until the rest arrive — costs a live process per waiting branch, and those processes are
7//! agent CLIs: they have context windows, they cost money per minute in some pricing models, and a
8//! supervisor that dies leaves them orphaned. Parking the *flight* costs a map entry.
9//!
10//! It also makes the wait durable. A parked flight is data, so it survives being written down; a
11//! blocked process is not.
12//!
13//! # Why a dead barrier is abandoned rather than left
14//!
15//! A barrier waiting for an upstream that no live run can still produce will wait forever, holding
16//! work somebody asked for. Silent permanent stalling is the worst outcome in this system — worse
17//! than a failure, which at least says something happened. So after every pass the barriers are
18//! checked against what can still be reached, and the ones that cannot complete are given up and
19//! reported.
20
21use std::collections::{BTreeMap, BTreeSet};
22use std::sync::Mutex;
23
24use layover_core::agent::AgentName;
25use layover_core::barrier::{Barrier, BarrierKey, Delivery};
26use layover_core::flight::Flight;
27use layover_core::graph::RouteGraph;
28
29/// A barrier that will never complete, and the work it was holding.
30#[derive(Debug, Clone)]
31pub struct Abandoned {
32    /// Which agent was waiting, in which chain.
33    pub key: BarrierKey,
34    /// Upstreams that never arrived and now never can.
35    pub missing: Vec<AgentName>,
36    /// Flights that were parked behind it.
37    ///
38    /// Returned rather than dropped so the Tower can say what was lost. Work that vanishes without
39    /// a record is indistinguishable from work that was never asked for.
40    pub stranded: usize,
41}
42
43impl std::fmt::Display for Abandoned {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        let missing = self
46            .missing
47            .iter()
48            .map(ToString::to_string)
49            .collect::<Vec<_>>()
50            .join(", ");
51
52        write!(
53            f,
54            "`{}` will never wake: nothing live can still deliver {missing}",
55            self.key.to
56        )
57    }
58}
59
60/// The barriers a running factory is holding.
61#[derive(Debug, Default)]
62pub struct Barriers {
63    live: Mutex<BTreeMap<BarrierKey, Barrier>>,
64}
65
66impl Barriers {
67    /// No barriers held.
68    #[must_use]
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Offers a flight to whichever barrier guards its destination.
74    ///
75    /// Returns `None` when the destination declares no join, which is the common case and means
76    /// the flight should simply be run.
77    ///
78    /// # Panics
79    ///
80    /// Never; the lock is not held across a call out.
81    pub fn deliver(&self, graph: &RouteGraph, flight: Flight) -> Option<Delivery> {
82        let spec = graph.join_for(&flight.to)?;
83        let key = BarrierKey::new(flight.itinerary.clone(), flight.to.clone());
84
85        let mut live = self.live.lock().ok()?;
86        let barrier = live.entry(key).or_insert_with(|| Barrier::from_spec(spec));
87
88        Some(barrier.deliver(flight))
89    }
90
91    /// Gives up every barrier that no live run could still satisfy.
92    ///
93    /// `live_agents` is who is currently running or queued to run. A barrier is dead when none of
94    /// its missing upstreams can be reached from any of them — at which point waiting is not
95    /// patience, it is a hang.
96    pub fn abandon_unreachable(
97        &self,
98        graph: &RouteGraph,
99        live_agents: &BTreeSet<AgentName>,
100    ) -> Vec<Abandoned> {
101        let Ok(mut held) = self.live.lock() else {
102            return Vec::new();
103        };
104
105        let mut given_up = Vec::new();
106
107        held.retain(|key, barrier| {
108            // A barrier that has already woken its agent is not waiting for anything; leaving it
109            // in place is what lets the next wave reset it.
110            if barrier.has_released() || barrier.is_reachable(graph, live_agents) {
111                return true;
112            }
113
114            given_up.push(Abandoned {
115                key: key.clone(),
116                missing: barrier.waiting_for(),
117                stranded: barrier.parked_count(),
118            });
119
120            false
121        });
122
123        given_up
124    }
125
126    /// Every barrier currently holding work, for reporting.
127    #[must_use]
128    pub fn waiting(&self) -> Vec<(BarrierKey, Vec<AgentName>)> {
129        let Ok(held) = self.live.lock() else {
130            return Vec::new();
131        };
132
133        held.iter()
134            .filter(|(_, barrier)| !barrier.has_released() && barrier.parked_count() > 0)
135            .map(|(key, barrier)| (key.clone(), barrier.waiting_for()))
136            .collect()
137    }
138
139    /// How many barriers are held, for reporting.
140    #[must_use]
141    pub fn count(&self) -> usize {
142        self.live.lock().map_or(0, |held| held.len())
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use layover_core::config::Config;
150    use layover_core::flight::{ItineraryId, Origin};
151
152    const FACTORY: &str = r#"
153[layover]
154work_dir = "work"
155
156[defaults]
157runner = "shell"
158
159[runners.shell]
160command = ["echo"]
161
162[agents.developer]
163prompt = "develop"
164entry = true
165
166[agents.tester]
167prompt = "test"
168
169[agents.reviewer]
170prompt = "review"
171
172[agents.publisher]
173prompt = "publish"
174
175[pipelines.build]
176entry = "developer"
177
178[[routes]]
179from = "developer"
180to = "tester"
181
182[[routes]]
183from = "developer"
184to = "reviewer"
185
186[[routes]]
187from = ["tester", "reviewer"]
188to = "publisher"
189join = "all"
190"#;
191
192    fn graph() -> RouteGraph {
193        let config: Config = toml::from_str(FACTORY).expect("the fixture factory parses");
194        RouteGraph::from_config(&config)
195    }
196
197    fn flight(chain: &ItineraryId, from: &str, to: &str) -> Flight {
198        Flight::new(
199            chain.clone(),
200            Origin::Agent(AgentName::new(from)),
201            AgentName::new(to),
202            "verdict",
203            3,
204        )
205    }
206
207    fn live(names: &[&str]) -> BTreeSet<AgentName> {
208        names.iter().map(|n| AgentName::new(*n)).collect()
209    }
210
211    #[test]
212    fn an_unjoined_destination_has_no_barrier_to_meet() {
213        // The common case: most agents are not joined, and asking about a barrier that does not
214        // exist must not create one.
215        let barriers = Barriers::new();
216        let chain = ItineraryId::generate();
217
218        let outcome = barriers.deliver(&graph(), flight(&chain, "developer", "tester"));
219
220        assert!(outcome.is_none(), "no join is declared on `tester`");
221        assert_eq!(barriers.count(), 0, "nothing should have been created");
222    }
223
224    #[test]
225    fn the_first_of_two_upstreams_is_parked_rather_than_run() {
226        let barriers = Barriers::new();
227        let chain = ItineraryId::generate();
228
229        let outcome = barriers
230            .deliver(&graph(), flight(&chain, "tester", "publisher"))
231            .expect("publisher is joined");
232
233        match outcome {
234            Delivery::Parked { waiting_for } => {
235                assert_eq!(waiting_for, [AgentName::new("reviewer")]);
236            }
237            other => panic!("expected a park, got {other:?}"),
238        }
239    }
240
241    #[test]
242    fn the_second_upstream_releases_the_agent_once_with_both_flights() {
243        // Once, not twice. Two edges into one agent without a join fire it twice, which for a
244        // publisher means two pull requests.
245        let barriers = Barriers::new();
246        let graph = graph();
247        let chain = ItineraryId::generate();
248
249        barriers.deliver(&graph, flight(&chain, "tester", "publisher"));
250        let outcome = barriers
251            .deliver(&graph, flight(&chain, "reviewer", "publisher"))
252            .expect("publisher is joined");
253
254        match outcome {
255            Delivery::Ready(flights) => assert_eq!(flights.len(), 2, "both verdicts arrive"),
256            other => panic!("expected a release, got {other:?}"),
257        }
258    }
259
260    #[test]
261    fn two_chains_waiting_on_the_same_agent_do_not_satisfy_each_other() {
262        // A barrier is keyed by itinerary as well as agent. Sharing one would let a verdict about
263        // one work item release the publisher for another.
264        let barriers = Barriers::new();
265        let graph = graph();
266        let first = ItineraryId::generate();
267        let second = ItineraryId::generate();
268
269        barriers.deliver(&graph, flight(&first, "tester", "publisher"));
270        let outcome = barriers
271            .deliver(&graph, flight(&second, "reviewer", "publisher"))
272            .expect("publisher is joined");
273
274        assert!(
275            matches!(outcome, Delivery::Parked { .. }),
276            "a different chain must not complete this one: {outcome:?}"
277        );
278        assert_eq!(barriers.count(), 2, "one barrier per chain");
279    }
280
281    #[test]
282    fn a_barrier_nothing_can_still_satisfy_is_given_up_and_says_what_was_lost() {
283        // Waiting for an upstream no live run can produce is not patience, it is a hang — and a
284        // silent one, which is the worst outcome in the system.
285        let barriers = Barriers::new();
286        let graph = graph();
287        let chain = ItineraryId::generate();
288
289        barriers.deliver(&graph, flight(&chain, "tester", "publisher"));
290
291        // Nothing is running that could ever reach `reviewer`.
292        let given_up = barriers.abandon_unreachable(&graph, &live(&["publisher"]));
293
294        assert_eq!(given_up.len(), 1);
295        assert_eq!(given_up[0].missing, [AgentName::new("reviewer")]);
296        assert_eq!(
297            given_up[0].stranded, 1,
298            "the parked flight is accounted for"
299        );
300        assert!(
301            given_up[0].to_string().contains("will never wake"),
302            "{}",
303            given_up[0]
304        );
305        assert_eq!(barriers.count(), 0, "a dead barrier is not kept");
306    }
307
308    #[test]
309    fn a_barrier_whose_upstream_is_still_reachable_is_left_alone() {
310        let barriers = Barriers::new();
311        let graph = graph();
312        let chain = ItineraryId::generate();
313
314        barriers.deliver(&graph, flight(&chain, "tester", "publisher"));
315
316        // `developer` is live and can still reach `reviewer`.
317        let given_up = barriers.abandon_unreachable(&graph, &live(&["developer"]));
318
319        assert!(
320            given_up.is_empty(),
321            "patience is correct here: {given_up:?}"
322        );
323        assert_eq!(barriers.count(), 1);
324    }
325
326    #[test]
327    fn a_released_barrier_is_kept_so_the_next_wave_can_reset_it() {
328        // A loop re-runs the same join. Discarding the barrier on release would lose the record
329        // that it ever fired, and an `any` join would wake its agent once per straggler.
330        let barriers = Barriers::new();
331        let graph = graph();
332        let chain = ItineraryId::generate();
333
334        barriers.deliver(&graph, flight(&chain, "tester", "publisher"));
335        barriers.deliver(&graph, flight(&chain, "reviewer", "publisher"));
336
337        let given_up = barriers.abandon_unreachable(&graph, &live(&["publisher"]));
338
339        assert!(given_up.is_empty(), "a released barrier waits for nothing");
340        assert_eq!(barriers.count(), 1);
341    }
342
343    #[test]
344    fn a_second_verdict_from_one_upstream_starts_a_fresh_wave() {
345        // The failure loop: the developer fixes what the tester found and the tester reports
346        // again. The reviewer's earlier approval must not count for the new code.
347        let barriers = Barriers::new();
348        let graph = graph();
349        let chain = ItineraryId::generate();
350
351        barriers.deliver(&graph, flight(&chain, "tester", "publisher"));
352        barriers.deliver(&graph, flight(&chain, "reviewer", "publisher"));
353
354        // Round two: the tester reports on the fix.
355        let outcome = barriers
356            .deliver(&graph, flight(&chain, "tester", "publisher"))
357            .expect("publisher is joined");
358
359        match outcome {
360            Delivery::Parked { waiting_for } => {
361                assert_eq!(
362                    waiting_for,
363                    [AgentName::new("reviewer")],
364                    "the reviewer must look again at the new code"
365                );
366            }
367            other => panic!("a stale approval must not release the publisher: {other:?}"),
368        }
369    }
370
371    #[test]
372    fn a_human_can_still_reach_an_agent_that_sits_behind_a_join() {
373        // A join says which inputs an agent needs together, not when it is allowed to run.
374        let barriers = Barriers::new();
375        let chain = ItineraryId::generate();
376
377        let direct = Flight::new(
378            chain,
379            Origin::Human,
380            AgentName::new("publisher"),
381            "publish it anyway",
382            3,
383        );
384
385        let outcome = barriers
386            .deliver(&graph(), direct)
387            .expect("publisher is joined");
388
389        assert!(
390            matches!(outcome, Delivery::Direct(_)),
391            "a human trigger bypasses the barrier: {outcome:?}"
392        );
393    }
394
395    #[test]
396    fn what_is_waiting_can_be_reported_without_disturbing_it() {
397        let barriers = Barriers::new();
398        let graph = graph();
399        let chain = ItineraryId::generate();
400
401        barriers.deliver(&graph, flight(&chain, "tester", "publisher"));
402
403        let waiting = barriers.waiting();
404
405        assert_eq!(waiting.len(), 1);
406        assert_eq!(waiting[0].0.to, AgentName::new("publisher"));
407        assert_eq!(waiting[0].1, [AgentName::new("reviewer")]);
408    }
409}