Skip to main content

layover_core/
handover.rs

1//! Starting a run that carries what an earlier one knew.
2//!
3//! Two problems turn out to be the same problem. A run dies when the machine restarts, and a human
4//! watching a run go the wrong way wants to redirect it. Both were tempting to solve by keeping a
5//! process alive — resuming a conversation, or piping input into a live child — and both are
6//! solved here instead by **starting a new run and handing it what the old one had**.
7//!
8//! That choice keeps the most opinionated decision in the project intact: every run is still a
9//! clean slate *process*. Nothing is resumed, no session is held open, and resident agents stay
10//! out of scope along with the reentrancy hazard they bring. What changes is only how much context
11//! a new run opens with.
12//!
13//! # The rails still apply
14//!
15//! A recovered or steered run is an ordinary run: it spends a hop, debits Fuel, counts against the
16//! run cap and draws on the Reserve. Recovery additionally has [`RecoveryPolicy`] and an attempt
17//! limit, because a crash loop that restarts itself forever is a fork bomb that looks like
18//! resilience.
19//!
20//! # What this module does not decide
21//!
22//! [`Handover::brief`] renders a block of text *about* the previous run. Where that block sits in
23//! the payload is settled and lives in [`crate::payload`]: immediately above the flight body, so
24//! that "you are continuing work that did not finish" reads next to what the work is. This
25//! produces the block; `payload::compose` places it.
26
27use jiff::Timestamp;
28use std::fmt;
29use std::fmt::Write as _;
30
31use serde::{Deserialize, Serialize};
32
33use crate::flight::{Flight, ItineraryId, RunId};
34
35/// Why a run stopped without finishing.
36#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
37#[serde(rename_all = "snake_case")]
38pub enum Interruption {
39    /// The Tower went away — a restart, a crash, a reboot — while the run was live.
40    TowerRestart,
41    /// The Tower stayed up but lost contact with the child: its pipes closed unexpectedly, or a
42    /// suspend and resume left the handle unusable.
43    ///
44    /// Deliberately distinct from [`Interruption::Crashed`], which is what the Tower reports when
45    /// it *watched* the process exit. Here it did not, and the difference is the whole question:
46    /// losing sight of a process is not the same as the process ending.
47    LostContact,
48    /// The run exceeded `timeout_sec`.
49    Timeout,
50    /// The process exited non-zero.
51    Crashed {
52        /// Exit code, when the operating system reported one.
53        exit_code: Option<i32>,
54    },
55    /// A Ground Stop halted it.
56    GroundStop,
57}
58
59impl Interruption {
60    /// Returns `true` when restarting the work could plausibly succeed.
61    ///
62    /// A Ground Stop is excluded deliberately: somebody pulled the handle, and a factory that
63    /// restarts through its own kill switch is not one anybody can stop.
64    #[must_use]
65    pub fn is_retryable(&self) -> bool {
66        !matches!(self, Self::GroundStop)
67    }
68
69    /// Returns `true` when the child process might still be running.
70    ///
71    /// This is the difference between an interruption the Tower *observed* and one it merely
72    /// *inferred*. A timeout or a non-zero exit means the Tower watched the process end. A Tower
73    /// restart or a lost pipe means only that the Tower stopped being able to see it — and on
74    /// Windows in particular a child routinely outlives the parent that spawned it.
75    ///
76    /// Recovering in that state is how one interrupted publisher becomes two open pull requests.
77    /// So these interruptions require the child to be confirmed gone before a new run is started;
78    /// see [`authorize_recovery`].
79    #[must_use]
80    pub fn child_may_still_be_running(&self) -> bool {
81        matches!(self, Self::TowerRestart | Self::LostContact)
82    }
83}
84
85impl fmt::Display for Interruption {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Self::TowerRestart => f.write_str("the Tower restarted while it was running"),
89            Self::LostContact => f.write_str("the Tower lost contact with the process"),
90            Self::Timeout => f.write_str("it ran past its timeout"),
91            Self::Crashed {
92                exit_code: Some(code),
93            } => write!(f, "it exited with code {code}"),
94            Self::Crashed { exit_code: None } => f.write_str("it exited abnormally"),
95            Self::GroundStop => f.write_str("a Ground Stop halted it"),
96        }
97    }
98}
99
100/// Whether an interrupted agent may be restarted without asking.
101///
102/// The question this answers is not "can we?" but "is it safe to do the work twice?". An agent
103/// that reads and reports is harmless to re-run. One that opened a pull request is not, and
104/// re-running it would open a second.
105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
106#[serde(rename_all = "kebab-case")]
107pub enum RecoveryPolicy {
108    /// Restart automatically, up to the attempt limit.
109    #[default]
110    Automatic,
111    /// Record the interruption and wait for a human to ask.
112    Manual,
113    /// Never restart. The itinerary is interrupted and stays that way.
114    Never,
115}
116
117impl RecoveryPolicy {
118    /// Returns `true` when the Tower may restart this agent on its own.
119    #[must_use]
120    pub fn is_automatic(&self) -> bool {
121        matches!(self, Self::Automatic)
122    }
123}
124
125/// A restart of work that was interrupted.
126#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
127pub struct Recovery {
128    /// The run that did not finish.
129    pub previous: RunId,
130    /// What happened to it.
131    pub interruption: Interruption,
132    /// Which attempt this is. The first restart is attempt 2.
133    pub attempt: u32,
134}
135
136/// A human redirecting work that is already under way.
137#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
138pub struct Steer {
139    /// The run being redirected.
140    pub previous: RunId,
141    /// What the human said to do differently.
142    pub note: String,
143    /// When they said it.
144    pub at: Timestamp,
145}
146
147/// Work being picked up after a deliberate wait.
148#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
149pub struct Resumption {
150    /// The chain that set this work down.
151    pub booked_by: ItineraryId,
152    /// What the earlier run said it was waiting for.
153    pub waiting_for: String,
154    /// When it was set down.
155    pub booked_at: Timestamp,
156    /// How many times it has been picked up and found nothing yet.
157    ///
158    /// Told to the agent because it changes what a reasonable response is. Finding nothing on the
159    /// first check is normal; finding nothing on the twelfth is worth saying out loud rather than
160    /// quietly booking a thirteenth.
161    pub checks: u32,
162}
163
164/// Why a run is being started.
165#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
166#[serde(rename_all = "snake_case")]
167pub enum Cause {
168    /// A flight arrived in the ordinary way.
169    Dispatch,
170    /// An earlier run was interrupted and the work is being restarted.
171    Recovered(Recovery),
172    /// A human redirected an earlier run.
173    Steered(Steer),
174    /// Work an earlier chain set down deliberately, now picked up again.
175    ///
176    /// Distinct from [`Self::Recovered`], and the difference matters to the agent reading it. A
177    /// recovered run is repeating work that may be half-done; a resumed layover is not. The
178    /// earlier run *finished*, having chosen to come back later, so nothing is half-applied and
179    /// the warning about doing things twice would be misleading here.
180    Resumed(Resumption),
181}
182
183impl Cause {
184    /// Returns `true` when this run is repeating work an earlier one may have partly done.
185    ///
186    /// The distinction matters to an agent: work already done may need checking before it is done
187    /// again, and a side effect already applied must not be applied twice.
188    ///
189    /// A resumed layover is deliberately **not** repeating work. The earlier run set it down on
190    /// purpose and ended cleanly, so telling this one to check for half-applied side effects would
191    /// send it looking for something that is not there.
192    #[must_use]
193    pub fn repeats_earlier_work(&self) -> bool {
194        matches!(self, Self::Recovered(_) | Self::Steered(_))
195    }
196}
197
198/// Everything a new run is told about the run it is taking over from.
199#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
200pub struct Handover {
201    /// Why this run is starting.
202    pub cause: Cause,
203    /// The flights the earlier run was given, which this one receives again.
204    pub flights: Vec<Flight>,
205    /// What the earlier run had recorded before it stopped, newest last.
206    ///
207    /// Whatever the store can honestly supply: lines the agent wrote to its memory, a summary of
208    /// its transcript. Never a promise that the list is complete — an interrupted run stops
209    /// mid-sentence by definition.
210    pub progress: Vec<String>,
211}
212
213impl Handover {
214    /// An ordinary first run, taking over from nothing.
215    #[must_use]
216    pub fn dispatch(flights: Vec<Flight>) -> Self {
217        Self {
218            cause: Cause::Dispatch,
219            flights,
220            progress: Vec::new(),
221        }
222    }
223
224    /// A restart of interrupted work.
225    #[must_use]
226    pub fn recovered(recovery: Recovery, flights: Vec<Flight>) -> Self {
227        Self {
228            cause: Cause::Recovered(recovery),
229            flights,
230            progress: Vec::new(),
231        }
232    }
233
234    /// A redirection of work already under way.
235    #[must_use]
236    pub fn steered(steer: Steer, flights: Vec<Flight>) -> Self {
237        Self {
238            cause: Cause::Steered(steer),
239            flights,
240            progress: Vec::new(),
241        }
242    }
243
244    /// Work an earlier chain set down deliberately.
245    #[must_use]
246    pub fn resumed(resumption: Resumption, flights: Vec<Flight>) -> Self {
247        Self {
248            cause: Cause::Resumed(resumption),
249            flights,
250            progress: Vec::new(),
251        }
252    }
253
254    /// Adds what the earlier run had managed to record.
255    #[must_use]
256    pub fn with_progress(mut self, progress: Vec<String>) -> Self {
257        self.progress = progress;
258        self
259    }
260
261    /// Renders the block telling this run what it is taking over.
262    ///
263    /// Empty for an ordinary dispatch: a first run is taking over nothing, and a paragraph
264    /// explaining that would be noise in every prompt in the factory.
265    #[must_use]
266    pub fn brief(&self) -> String {
267        let mut out = String::new();
268
269        match &self.cause {
270            Cause::Dispatch => return out,
271            Cause::Recovered(recovery) => {
272                out.push_str("## You are continuing interrupted work\n\n");
273                let _ = writeln!(
274                    out,
275                    "A previous run ({}) started this work and did not finish: {}. This is \
276                     attempt {}.\n",
277                    recovery.previous, recovery.interruption, recovery.attempt
278                );
279                out.push_str(
280                    "You are a new process and remember none of it. Before repeating anything \
281                     that changes the world — a commit, a comment, a published pull request — \
282                     check whether the earlier run already did it. Doing it twice is worse than \
283                     doing it late.\n\n",
284                );
285            }
286            Cause::Steered(steer) => {
287                out.push_str("## A human has redirected this work\n\n");
288                let _ = writeln!(
289                    out,
290                    "A previous run ({}) was working on this. Their instruction takes precedence \
291                     over the original request where the two disagree:\n",
292                    steer.previous
293                );
294                let _ = writeln!(out, "> {}\n", steer.note.trim());
295            }
296            Cause::Resumed(resumption) => {
297                out.push_str("## You are picking up work that was set down\n\n");
298                let _ = writeln!(
299                    out,
300                    "An earlier chain ({}) finished what it could and chose to come back to this \
301                     later. It was waiting for: {}\n",
302                    resumption.booked_by.as_str(),
303                    resumption.waiting_for.trim()
304                );
305                let _ = writeln!(
306                    out,
307                    "It was set down at {}, and this is check {}.\n",
308                    resumption.booked_at,
309                    resumption.checks.saturating_add(1)
310                );
311                out.push_str(
312                    "Nothing was left half-done: the earlier run ended cleanly. Your job is to \
313                     see whether the thing it was waiting for has happened, and to act on it if \
314                     it has. If it has not, set the work down again rather than waiting.\n\n",
315                );
316            }
317        }
318
319        // Only said when the earlier run may have stopped mid-sentence. A resumed layover ended on
320        // purpose, so warning that it might have got "anywhere from nowhere to almost finished"
321        // would send this run looking for damage that was never done.
322        if self.progress.is_empty() {
323            if self.cause.repeats_earlier_work() {
324                out.push_str(
325                    "Nothing was recorded about what the earlier run had done, so assume it may \
326                     have got anywhere from nowhere to almost finished.\n",
327                );
328            }
329        } else {
330            out.push_str("What the earlier run recorded, oldest first:\n\n");
331            for note in &self.progress {
332                let _ = writeln!(out, "- {}", note.trim());
333            }
334            out.push_str(
335                "\nThat list is what it managed to write down, not necessarily everything it \
336                 did.\n",
337            );
338        }
339
340        out
341    }
342}
343
344/// Whether the child process from the interrupted run has been confirmed gone.
345///
346/// A separate type rather than a `bool` because the two values are not interchangeable at a call
347/// site: passing the wrong one silently authorises a duplicate run, which is the exact failure
348/// recovery is meant to avoid.
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350pub enum ChildState {
351    /// The process is known to be gone: the Tower watched it exit, or checked and it was not
352    /// there.
353    Gone,
354    /// Nobody has checked, or the check was inconclusive.
355    Unknown,
356}
357
358/// Decides whether interrupted work may be restarted.
359///
360/// `child` is what the Tower currently knows about the previous run's process. For an
361/// interruption the Tower only *inferred* — a restart, a lost pipe — it must have checked before
362/// a new run is authorised, because recovering alongside a process that is still going duplicates
363/// whatever that process was doing.
364///
365/// # Errors
366///
367/// Returns [`RecoveryDenied`] when the policy forbids it, the interruption is not retryable, the
368/// previous process cannot be confirmed gone, or the attempt limit is reached.
369pub fn authorize_recovery(
370    policy: RecoveryPolicy,
371    interruption: &Interruption,
372    child: ChildState,
373    attempts_so_far: u32,
374    max_attempts: u32,
375) -> Result<(), RecoveryDenied> {
376    if !interruption.is_retryable() {
377        return Err(RecoveryDenied::NotRetryable);
378    }
379
380    if interruption.child_may_still_be_running() && child == ChildState::Unknown {
381        return Err(RecoveryDenied::ChildUnaccountedFor);
382    }
383
384    match policy {
385        RecoveryPolicy::Never => return Err(RecoveryDenied::PolicyForbids),
386        RecoveryPolicy::Manual => return Err(RecoveryDenied::NeedsAHuman),
387        RecoveryPolicy::Automatic => {}
388    }
389
390    if attempts_so_far >= max_attempts {
391        return Err(RecoveryDenied::OutOfAttempts { max_attempts });
392    }
393
394    Ok(())
395}
396
397/// Why interrupted work was not restarted.
398#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
399pub enum RecoveryDenied {
400    /// The interruption was not the kind you restart through.
401    #[error("the interruption was not retryable")]
402    NotRetryable,
403    /// The previous run's process has not been confirmed gone.
404    ///
405    /// Not a failure so much as an unanswered question. Starting a new run beside a process that
406    /// is still going duplicates its work, so the Tower has to look before it restarts.
407    #[error("the previous run's process has not been confirmed gone")]
408    ChildUnaccountedFor,
409    /// The agent is configured never to restart.
410    #[error("this agent's `recovery` policy is `never`")]
411    PolicyForbids,
412    /// The agent is configured to wait for a person.
413    #[error("this agent's `recovery` policy is `manual`; a human decides")]
414    NeedsAHuman,
415    /// The work has already been restarted as often as it is allowed.
416    #[error("already restarted {max_attempts} time(s); a crash loop is not resilience")]
417    OutOfAttempts {
418        /// The limit that was reached.
419        max_attempts: u32,
420    },
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::flight::{ItineraryId, Origin};
427
428    fn flight() -> Flight {
429        Flight::new(
430            ItineraryId::generate(),
431            Origin::Agent("analyst".into()),
432            "developer".into(),
433            "implement the retry policy",
434            9,
435        )
436    }
437
438    fn recovery() -> Recovery {
439        Recovery {
440            previous: RunId::from("run_01ABC"),
441            interruption: Interruption::TowerRestart,
442            attempt: 2,
443        }
444    }
445
446    #[test]
447    fn an_ordinary_dispatch_carries_no_briefing() {
448        // A paragraph explaining that nothing happened would be noise in every prompt.
449        let handover = Handover::dispatch(vec![flight()]);
450
451        assert!(handover.brief().is_empty());
452        assert!(!handover.cause.repeats_earlier_work());
453    }
454
455    #[test]
456    fn a_recovered_run_is_told_what_happened_and_warned_about_side_effects() {
457        let brief = Handover::recovered(recovery(), vec![flight()]).brief();
458
459        assert!(brief.contains("continuing interrupted work"));
460        assert!(brief.contains("run_01ABC"));
461        assert!(brief.contains("the Tower restarted"));
462        assert!(brief.contains("attempt 2"));
463        assert!(
464            brief.contains("Doing it twice is worse than doing it late"),
465            "a restarted run must be warned before repeating a side effect"
466        );
467    }
468
469    #[test]
470    fn a_recovered_run_is_told_the_record_may_be_incomplete() {
471        let brief = Handover::recovered(recovery(), vec![flight()]).with_progress(vec![
472            "Read the work item".into(),
473            "Wrote the failing test".into(),
474        ]);
475
476        let brief = brief.brief();
477        assert!(brief.contains("- Read the work item"));
478        assert!(brief.contains("- Wrote the failing test"));
479        assert!(
480            brief.contains("not necessarily everything it did"),
481            "an interrupted run stops mid-sentence, and the brief must say so"
482        );
483    }
484
485    #[test]
486    fn a_recovered_run_with_no_record_is_told_that_too() {
487        let brief = Handover::recovered(recovery(), vec![flight()]).brief();
488
489        assert!(brief.contains("anywhere from nowhere to almost finished"));
490    }
491
492    #[test]
493    fn a_steered_run_carries_the_instruction_and_its_precedence() {
494        let brief = Handover::steered(
495            Steer {
496                previous: RunId::from("run_01XYZ"),
497                note: "  Use the existing retry helper, do not write a new one.  ".to_owned(),
498                at: Timestamp::now(),
499            },
500            vec![flight()],
501        )
502        .brief();
503
504        assert!(brief.contains("A human has redirected"));
505        assert!(brief.contains("> Use the existing retry helper"));
506        assert!(
507            brief.contains("takes precedence"),
508            "steering that does not override the original request is just a suggestion"
509        );
510    }
511
512    #[test]
513    fn steering_and_recovery_both_repeat_earlier_work() {
514        assert!(Cause::Recovered(recovery()).repeats_earlier_work());
515        assert!(
516            Cause::Steered(Steer {
517                previous: RunId::from("run_1"),
518                note: "stop".into(),
519                at: Timestamp::now(),
520            })
521            .repeats_earlier_work()
522        );
523    }
524
525    #[test]
526    fn a_ground_stop_is_never_restarted_through() {
527        // A factory that restarts through its own kill switch is not one anybody can stop.
528        assert!(!Interruption::GroundStop.is_retryable());
529        assert_eq!(
530            authorize_recovery(
531                RecoveryPolicy::Automatic,
532                &Interruption::GroundStop,
533                ChildState::Gone,
534                0,
535                3
536            ),
537            Err(RecoveryDenied::NotRetryable)
538        );
539    }
540
541    #[test]
542    fn ordinary_interruptions_are_retryable() {
543        for interruption in [
544            Interruption::TowerRestart,
545            Interruption::LostContact,
546            Interruption::Timeout,
547            Interruption::Crashed { exit_code: Some(1) },
548            Interruption::Crashed { exit_code: None },
549        ] {
550            assert!(interruption.is_retryable(), "{interruption}");
551            assert_eq!(
552                authorize_recovery(
553                    RecoveryPolicy::Automatic,
554                    &interruption,
555                    ChildState::Gone,
556                    0,
557                    3
558                ),
559                Ok(())
560            );
561        }
562    }
563
564    #[test]
565    fn a_process_that_might_still_be_running_is_not_recovered_over() {
566        // Losing sight of a process is not the same as the process ending. On Windows a child
567        // routinely outlives the parent that spawned it, so a Tower that restarts and finds a
568        // record still marked `running` cannot assume the work stopped. Recovering anyway is how
569        // one interrupted publisher becomes two open pull requests.
570        for interruption in [Interruption::TowerRestart, Interruption::LostContact] {
571            assert!(interruption.child_may_still_be_running(), "{interruption}");
572            assert_eq!(
573                authorize_recovery(
574                    RecoveryPolicy::Automatic,
575                    &interruption,
576                    ChildState::Unknown,
577                    0,
578                    3
579                ),
580                Err(RecoveryDenied::ChildUnaccountedFor),
581                "{interruption} must be checked before it is restarted"
582            );
583        }
584    }
585
586    #[test]
587    fn an_interruption_the_tower_watched_needs_no_liveness_check() {
588        // A timeout or a non-zero exit means the Tower saw the process end. Demanding a check it
589        // has already effectively done would strand work for no benefit.
590        for interruption in [
591            Interruption::Timeout,
592            Interruption::Crashed { exit_code: Some(1) },
593        ] {
594            assert!(!interruption.child_may_still_be_running(), "{interruption}");
595            assert_eq!(
596                authorize_recovery(
597                    RecoveryPolicy::Automatic,
598                    &interruption,
599                    ChildState::Unknown,
600                    0,
601                    3
602                ),
603                Ok(())
604            );
605        }
606    }
607
608    #[test]
609    fn a_crash_loop_is_bounded() {
610        assert_eq!(
611            authorize_recovery(
612                RecoveryPolicy::Automatic,
613                &Interruption::Timeout,
614                ChildState::Gone,
615                3,
616                3
617            ),
618            Err(RecoveryDenied::OutOfAttempts { max_attempts: 3 })
619        );
620        assert_eq!(
621            authorize_recovery(
622                RecoveryPolicy::Automatic,
623                &Interruption::Timeout,
624                ChildState::Gone,
625                2,
626                3
627            ),
628            Ok(())
629        );
630    }
631
632    #[test]
633    fn a_policy_of_never_or_manual_stops_automatic_restarts() {
634        assert_eq!(
635            authorize_recovery(
636                RecoveryPolicy::Never,
637                &Interruption::Timeout,
638                ChildState::Gone,
639                0,
640                3
641            ),
642            Err(RecoveryDenied::PolicyForbids)
643        );
644        assert_eq!(
645            authorize_recovery(
646                RecoveryPolicy::Manual,
647                &Interruption::Timeout,
648                ChildState::Gone,
649                0,
650                3
651            ),
652            Err(RecoveryDenied::NeedsAHuman)
653        );
654        assert!(RecoveryPolicy::Automatic.is_automatic());
655        assert!(!RecoveryPolicy::Manual.is_automatic());
656    }
657
658    #[test]
659    fn a_zero_attempt_limit_disables_automatic_restarts_entirely() {
660        assert_eq!(
661            authorize_recovery(
662                RecoveryPolicy::Automatic,
663                &Interruption::Timeout,
664                ChildState::Gone,
665                0,
666                0
667            ),
668            Err(RecoveryDenied::OutOfAttempts { max_attempts: 0 })
669        );
670    }
671
672    #[test]
673    fn the_flights_the_earlier_run_received_come_with_it() {
674        // The new process remembers nothing, so it needs the work item again, not just a note
675        // that one existed.
676        let handover = Handover::recovered(recovery(), vec![flight()]);
677
678        assert_eq!(handover.flights.len(), 1);
679        assert_eq!(handover.flights[0].body, "implement the retry policy");
680    }
681}