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}