1use 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#[derive(Debug, Clone)]
31pub struct Abandoned {
32 pub key: BarrierKey,
34 pub missing: Vec<AgentName>,
36 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#[derive(Debug, Default)]
62pub struct Barriers {
63 live: Mutex<BTreeMap<BarrierKey, Barrier>>,
64}
65
66impl Barriers {
67 #[must_use]
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 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 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 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 #[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 #[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 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 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 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 let barriers = Barriers::new();
286 let graph = graph();
287 let chain = ItineraryId::generate();
288
289 barriers.deliver(&graph, flight(&chain, "tester", "publisher"));
290
291 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 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 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 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 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 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}