Skip to main content

layover_core/
layover.rs

1//! A Layover: work set down now and picked up later.
2//!
3//! The project is named after this and, until now, did not have one.
4//!
5//! # The gap
6//!
7//! An itinerary is a burst. A trigger opens it, flights move through it, it ends. That is the
8//! right shape for most work and the wrong shape for the most valuable kind: a chain that
9//! publishes a pull request and then wants to react to the comments that arrive on it over the
10//! following days.
11//!
12//! Neither option available before this worked. Keeping the chain alive and polling spends a hop
13//! and real money on every tick, so Hops kills it long before a human replies — and the whole
14//! point of Hops is that it should. Re-triggering on a schedule works mechanically but arrives
15//! knowing nothing: a fresh itinerary has no idea which work item it is following up, what was
16//! already tried, or what the earlier chain concluded.
17//!
18//! # The shape
19//!
20//! A run *books a layover* instead of waiting. The work is set down with everything needed to
21//! resume it, and a scheduled pipeline picks up whatever is due and opens a **new** itinerary
22//! seeded with that context.
23//!
24//! Nothing stays alive in between. No process, no parked chain, no held budget — which is the
25//! same answer recovery and steering arrived at, and for the same reason: what the later run
26//! needs is not the earlier one's *process* but its *context*.
27//!
28//! It also reuses [`crate::handover::Handover`] rather than inventing a second way to say "here
29//! is what an earlier run knew". That type already carries the flights and the progress; a
30//! layover adds only *when* to come back and *what to look for*.
31
32use std::fmt;
33
34use jiff::{Timestamp, ToSpan};
35use serde::{Deserialize, Serialize};
36use ulid::Ulid;
37
38use crate::agent::AgentName;
39use crate::flight::ItineraryId;
40use crate::handover::Handover;
41
42/// Identifier of a booked layover.
43#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
44#[serde(transparent)]
45pub struct LayoverId(String);
46
47impl LayoverId {
48    /// Mints a new identifier.
49    #[must_use]
50    pub fn generate() -> Self {
51        Self(format!("lay_{}", Ulid::new()))
52    }
53
54    /// Returns the identifier as a string slice.
55    #[must_use]
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59}
60
61impl fmt::Display for LayoverId {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        f.write_str(&self.0)
64    }
65}
66
67/// Where a booked layover has got to.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
69#[serde(rename_all = "snake_case")]
70pub enum Standing {
71    /// Waiting for its time to come round.
72    Booked,
73    /// A new itinerary has been opened for it.
74    Resumed,
75    /// Given up on: it was checked too many times without the thing it waits for happening.
76    Expired,
77    /// Cancelled, because the work it was following stopped mattering.
78    Cancelled,
79}
80
81impl Standing {
82    /// Returns `true` when this layover may still be picked up.
83    #[must_use]
84    pub fn is_pending(self) -> bool {
85        matches!(self, Self::Booked)
86    }
87
88    /// The identifier used in JSON.
89    #[must_use]
90    pub fn slug(self) -> &'static str {
91        match self {
92            Self::Booked => "booked",
93            Self::Resumed => "resumed",
94            Self::Expired => "expired",
95            Self::Cancelled => "cancelled",
96        }
97    }
98
99    /// Parses a slug.
100    #[must_use]
101    pub fn from_slug(slug: &str) -> Option<Self> {
102        [Self::Booked, Self::Resumed, Self::Expired, Self::Cancelled]
103            .into_iter()
104            .find(|standing| standing.slug() == slug)
105    }
106}
107
108impl fmt::Display for Standing {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        f.write_str(self.slug())
111    }
112}
113
114/// Work set down now to be picked up later.
115#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
116pub struct Layover {
117    /// Identifier.
118    pub id: LayoverId,
119    /// Which agent the resumed work should go to.
120    pub agent: AgentName,
121    /// The itinerary that booked it.
122    pub booked_by: ItineraryId,
123    /// One line saying what this is waiting for, for a human reading a list.
124    pub waiting_for: String,
125    /// What the resumed run needs to know.
126    pub handover: Handover,
127    /// When it was booked.
128    pub booked_at: Timestamp,
129    /// The soonest it should be picked up.
130    pub due_at: Timestamp,
131    /// How many times it has been picked up and set down again.
132    ///
133    /// A layover waiting on a human is checked repeatedly and usually finds nothing. Counting
134    /// gives the difference between "waiting patiently" and "waiting forever", which is the
135    /// difference between a follow-up and a leak.
136    pub checks: u32,
137    /// How many checks it may have before it is given up on.
138    pub max_checks: u32,
139    /// Where it has got to.
140    pub standing: Standing,
141}
142
143impl Layover {
144    /// Books a layover.
145    #[must_use]
146    pub fn book(
147        agent: AgentName,
148        booked_by: ItineraryId,
149        waiting_for: impl Into<String>,
150        handover: Handover,
151        booked_at: Timestamp,
152        due_at: Timestamp,
153        max_checks: u32,
154    ) -> Self {
155        Self {
156            id: LayoverId::generate(),
157            agent,
158            booked_by,
159            waiting_for: waiting_for.into(),
160            handover,
161            booked_at,
162            // A layover due before it was booked would be picked up instantly and spin, so the
163            // booking time is the floor.
164            due_at: due_at.max(booked_at),
165            checks: 0,
166            max_checks,
167            standing: Standing::Booked,
168        }
169    }
170
171    /// Returns `true` when this should be picked up at `now`.
172    #[must_use]
173    pub fn is_due(&self, now: Timestamp) -> bool {
174        self.standing.is_pending() && now >= self.due_at
175    }
176
177    /// Records that it was picked up and the thing it waits for had not happened.
178    ///
179    /// Each check pushes the next one further out, so a layover waiting on a human does not poll
180    /// at the same rate on day six as on minute one. Backing off is what makes a long wait
181    /// affordable — the alternative is paying for a run every few minutes to be told nothing has
182    /// changed.
183    pub fn set_down_again(&mut self, now: Timestamp) {
184        self.checks = self.checks.saturating_add(1);
185
186        if self.checks >= self.max_checks {
187            self.standing = Standing::Expired;
188            return;
189        }
190
191        let minutes = backoff_minutes(self.checks);
192        self.due_at = now.checked_add(minutes.minutes()).unwrap_or(Timestamp::MAX);
193    }
194
195    /// Records that a new itinerary has been opened for this work.
196    pub fn resumed(&mut self) {
197        self.standing = Standing::Resumed;
198    }
199
200    /// Gives up on it, because the work it was following stopped mattering.
201    pub fn cancel(&mut self) {
202        self.standing = Standing::Cancelled;
203    }
204
205    /// How long until it is next due, in whole minutes, or `None` when it is due now.
206    #[must_use]
207    pub fn minutes_until_due(&self, now: Timestamp) -> Option<i64> {
208        let seconds = self.due_at.as_second() - now.as_second();
209        (seconds > 0).then_some(seconds / 60)
210    }
211}
212
213/// How long to wait before the next check, after `checks` fruitless ones.
214///
215/// Doubling from fifteen minutes, capped at six hours. The cap matters: without it the tenth
216/// check would be days out, so a comment arriving on a quiet pull request would sit unanswered
217/// for longer than the work took.
218fn backoff_minutes(checks: u32) -> i64 {
219    const FIRST: i64 = 15;
220    const CAP: i64 = 6 * 60;
221
222    let doublings = checks.saturating_sub(1).min(8);
223    (FIRST << doublings).min(CAP)
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::handover::{Handover, Steer};
230
231    fn at(rfc3339: &str) -> Timestamp {
232        rfc3339.parse().expect("valid timestamp")
233    }
234
235    fn booked(due: &str) -> Layover {
236        Layover::book(
237            "developer".into(),
238            ItineraryId::generate(),
239            "comments on PR 1543477",
240            Handover::steered(
241                Steer {
242                    previous: crate::flight::RunId::generate(),
243                    note: "address review comments".to_owned(),
244                    at: at("2026-09-16T10:00:00Z"),
245                },
246                Vec::new(),
247            ),
248            at("2026-09-16T10:00:00Z"),
249            at(due),
250            8,
251        )
252    }
253
254    #[test]
255    fn a_layover_starts_booked_and_becomes_due_at_its_time() {
256        let layover = booked("2026-09-16T11:00:00Z");
257
258        assert_eq!(layover.standing, Standing::Booked);
259        assert!(!layover.is_due(at("2026-09-16T10:30:00Z")));
260        assert!(layover.is_due(at("2026-09-16T11:00:00Z")));
261        assert_eq!(
262            layover.minutes_until_due(at("2026-09-16T10:30:00Z")),
263            Some(30)
264        );
265    }
266
267    #[test]
268    fn a_layover_due_before_it_was_booked_is_held_to_the_booking_time() {
269        // Otherwise it is picked up instantly, finds nothing, and spins.
270        let layover = booked("2026-09-16T09:00:00Z");
271
272        assert_eq!(layover.due_at, at("2026-09-16T10:00:00Z"));
273    }
274
275    #[test]
276    fn checking_and_finding_nothing_pushes_the_next_check_further_out() {
277        // A layover waiting on a human should not poll at the same rate on day six as on minute
278        // one. Paying for a run every few minutes to be told nothing changed is how a follow-up
279        // becomes more expensive than the work.
280        let mut layover = booked("2026-09-16T11:00:00Z");
281
282        layover.set_down_again(at("2026-09-16T11:00:00Z"));
283        let first = layover.minutes_until_due(at("2026-09-16T11:00:00Z"));
284
285        layover.set_down_again(at("2026-09-16T11:15:00Z"));
286        let second = layover.minutes_until_due(at("2026-09-16T11:15:00Z"));
287
288        assert_eq!(first, Some(15));
289        assert_eq!(second, Some(30));
290        assert!(second > first);
291    }
292
293    #[test]
294    fn the_backoff_is_capped_so_a_late_comment_is_not_ignored_for_days() {
295        // Without a cap the tenth check lands days out, and a comment on a quiet pull request
296        // waits longer than the work took.
297        let mut layover = booked("2026-09-16T11:00:00Z");
298        layover.max_checks = 40;
299
300        for _ in 0..20 {
301            layover.set_down_again(at("2026-09-16T11:00:00Z"));
302        }
303
304        assert_eq!(
305            layover.minutes_until_due(at("2026-09-16T11:00:00Z")),
306            Some(6 * 60)
307        );
308    }
309
310    #[test]
311    fn a_layover_that_waits_forever_is_given_up_on() {
312        // The difference between waiting patiently and leaking. Something waiting on a human who
313        // has moved on must eventually stop costing money.
314        let mut layover = booked("2026-09-16T11:00:00Z");
315
316        for _ in 0..8 {
317            layover.set_down_again(at("2026-09-16T11:00:00Z"));
318        }
319
320        assert_eq!(layover.standing, Standing::Expired);
321        assert!(!layover.is_due(at("2027-01-01T00:00:00Z")));
322    }
323
324    #[test]
325    fn resuming_and_cancelling_both_take_it_out_of_the_queue() {
326        let mut resumed = booked("2026-09-16T11:00:00Z");
327        resumed.resumed();
328
329        let mut cancelled = booked("2026-09-16T11:00:00Z");
330        cancelled.cancel();
331
332        for layover in [&resumed, &cancelled] {
333            assert!(!layover.standing.is_pending());
334            assert!(!layover.is_due(at("2026-09-16T12:00:00Z")));
335        }
336    }
337
338    #[test]
339    fn the_resumed_run_is_given_what_the_earlier_one_knew() {
340        // The whole reason this is not just a scheduled pipeline. A fresh itinerary that arrives
341        // knowing nothing cannot follow anything up.
342        let layover = booked("2026-09-16T11:00:00Z");
343
344        assert!(layover.handover.brief().contains("address review comments"));
345    }
346
347    #[test]
348    fn standings_round_trip() {
349        for standing in [
350            Standing::Booked,
351            Standing::Resumed,
352            Standing::Expired,
353            Standing::Cancelled,
354        ] {
355            assert_eq!(Standing::from_slug(standing.slug()), Some(standing));
356        }
357        assert_eq!(Standing::from_slug("parked"), None);
358    }
359
360    #[test]
361    fn a_layover_serialises_readably() {
362        let line = serde_json::to_string(&booked("2026-09-16T11:00:00Z")).expect("serialises");
363
364        assert!(line.contains(r#""standing":"booked""#), "{line}");
365        assert!(
366            line.contains(r#""due_at":"2026-09-16T11:00:00Z""#),
367            "{line}"
368        );
369        assert!(line.contains("comments on PR 1543477"), "{line}");
370    }
371}