use jiff::Timestamp;
use std::fmt;
use std::fmt::Write as _;
use serde::{Deserialize, Serialize};
use crate::flight::{Flight, ItineraryId, RunId};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Interruption {
TowerRestart,
LostContact,
Timeout,
Crashed {
exit_code: Option<i32>,
},
GroundStop,
}
impl Interruption {
#[must_use]
pub fn is_retryable(&self) -> bool {
!matches!(self, Self::GroundStop)
}
#[must_use]
pub fn child_may_still_be_running(&self) -> bool {
matches!(self, Self::TowerRestart | Self::LostContact)
}
}
impl fmt::Display for Interruption {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::TowerRestart => f.write_str("the Tower restarted while it was running"),
Self::LostContact => f.write_str("the Tower lost contact with the process"),
Self::Timeout => f.write_str("it ran past its timeout"),
Self::Crashed {
exit_code: Some(code),
} => write!(f, "it exited with code {code}"),
Self::Crashed { exit_code: None } => f.write_str("it exited abnormally"),
Self::GroundStop => f.write_str("a Ground Stop halted it"),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum RecoveryPolicy {
#[default]
Automatic,
Manual,
Never,
}
impl RecoveryPolicy {
#[must_use]
pub fn is_automatic(&self) -> bool {
matches!(self, Self::Automatic)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct Recovery {
pub previous: RunId,
pub interruption: Interruption,
pub attempt: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct Steer {
pub previous: RunId,
pub note: String,
pub at: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct Resumption {
pub booked_by: ItineraryId,
pub waiting_for: String,
pub booked_at: Timestamp,
pub checks: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Cause {
Dispatch,
Recovered(Recovery),
Steered(Steer),
Resumed(Resumption),
}
impl Cause {
#[must_use]
pub fn repeats_earlier_work(&self) -> bool {
matches!(self, Self::Recovered(_) | Self::Steered(_))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct Handover {
pub cause: Cause,
pub flights: Vec<Flight>,
pub progress: Vec<String>,
}
impl Handover {
#[must_use]
pub fn dispatch(flights: Vec<Flight>) -> Self {
Self {
cause: Cause::Dispatch,
flights,
progress: Vec::new(),
}
}
#[must_use]
pub fn recovered(recovery: Recovery, flights: Vec<Flight>) -> Self {
Self {
cause: Cause::Recovered(recovery),
flights,
progress: Vec::new(),
}
}
#[must_use]
pub fn steered(steer: Steer, flights: Vec<Flight>) -> Self {
Self {
cause: Cause::Steered(steer),
flights,
progress: Vec::new(),
}
}
#[must_use]
pub fn resumed(resumption: Resumption, flights: Vec<Flight>) -> Self {
Self {
cause: Cause::Resumed(resumption),
flights,
progress: Vec::new(),
}
}
#[must_use]
pub fn with_progress(mut self, progress: Vec<String>) -> Self {
self.progress = progress;
self
}
#[must_use]
pub fn brief(&self) -> String {
let mut out = String::new();
match &self.cause {
Cause::Dispatch => return out,
Cause::Recovered(recovery) => {
out.push_str("## You are continuing interrupted work\n\n");
let _ = writeln!(
out,
"A previous run ({}) started this work and did not finish: {}. This is \
attempt {}.\n",
recovery.previous, recovery.interruption, recovery.attempt
);
out.push_str(
"You are a new process and remember none of it. Before repeating anything \
that changes the world — a commit, a comment, a published pull request — \
check whether the earlier run already did it. Doing it twice is worse than \
doing it late.\n\n",
);
}
Cause::Steered(steer) => {
out.push_str("## A human has redirected this work\n\n");
let _ = writeln!(
out,
"A previous run ({}) was working on this. Their instruction takes precedence \
over the original request where the two disagree:\n",
steer.previous
);
let _ = writeln!(out, "> {}\n", steer.note.trim());
}
Cause::Resumed(resumption) => {
out.push_str("## You are picking up work that was set down\n\n");
let _ = writeln!(
out,
"An earlier chain ({}) finished what it could and chose to come back to this \
later. It was waiting for: {}\n",
resumption.booked_by.as_str(),
resumption.waiting_for.trim()
);
let _ = writeln!(
out,
"It was set down at {}, and this is check {}.\n",
resumption.booked_at,
resumption.checks.saturating_add(1)
);
out.push_str(
"Nothing was left half-done: the earlier run ended cleanly. Your job is to \
see whether the thing it was waiting for has happened, and to act on it if \
it has. If it has not, set the work down again rather than waiting.\n\n",
);
}
}
if self.progress.is_empty() {
if self.cause.repeats_earlier_work() {
out.push_str(
"Nothing was recorded about what the earlier run had done, so assume it may \
have got anywhere from nowhere to almost finished.\n",
);
}
} else {
out.push_str("What the earlier run recorded, oldest first:\n\n");
for note in &self.progress {
let _ = writeln!(out, "- {}", note.trim());
}
out.push_str(
"\nThat list is what it managed to write down, not necessarily everything it \
did.\n",
);
}
out
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChildState {
Gone,
Unknown,
}
pub fn authorize_recovery(
policy: RecoveryPolicy,
interruption: &Interruption,
child: ChildState,
attempts_so_far: u32,
max_attempts: u32,
) -> Result<(), RecoveryDenied> {
if !interruption.is_retryable() {
return Err(RecoveryDenied::NotRetryable);
}
if interruption.child_may_still_be_running() && child == ChildState::Unknown {
return Err(RecoveryDenied::ChildUnaccountedFor);
}
match policy {
RecoveryPolicy::Never => return Err(RecoveryDenied::PolicyForbids),
RecoveryPolicy::Manual => return Err(RecoveryDenied::NeedsAHuman),
RecoveryPolicy::Automatic => {}
}
if attempts_so_far >= max_attempts {
return Err(RecoveryDenied::OutOfAttempts { max_attempts });
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum RecoveryDenied {
#[error("the interruption was not retryable")]
NotRetryable,
#[error("the previous run's process has not been confirmed gone")]
ChildUnaccountedFor,
#[error("this agent's `recovery` policy is `never`")]
PolicyForbids,
#[error("this agent's `recovery` policy is `manual`; a human decides")]
NeedsAHuman,
#[error("already restarted {max_attempts} time(s); a crash loop is not resilience")]
OutOfAttempts {
max_attempts: u32,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::flight::{ItineraryId, Origin};
fn flight() -> Flight {
Flight::new(
ItineraryId::generate(),
Origin::Agent("analyst".into()),
"developer".into(),
"implement the retry policy",
9,
)
}
fn recovery() -> Recovery {
Recovery {
previous: RunId::from("run_01ABC"),
interruption: Interruption::TowerRestart,
attempt: 2,
}
}
#[test]
fn an_ordinary_dispatch_carries_no_briefing() {
let handover = Handover::dispatch(vec![flight()]);
assert!(handover.brief().is_empty());
assert!(!handover.cause.repeats_earlier_work());
}
#[test]
fn a_recovered_run_is_told_what_happened_and_warned_about_side_effects() {
let brief = Handover::recovered(recovery(), vec![flight()]).brief();
assert!(brief.contains("continuing interrupted work"));
assert!(brief.contains("run_01ABC"));
assert!(brief.contains("the Tower restarted"));
assert!(brief.contains("attempt 2"));
assert!(
brief.contains("Doing it twice is worse than doing it late"),
"a restarted run must be warned before repeating a side effect"
);
}
#[test]
fn a_recovered_run_is_told_the_record_may_be_incomplete() {
let brief = Handover::recovered(recovery(), vec![flight()]).with_progress(vec![
"Read the work item".into(),
"Wrote the failing test".into(),
]);
let brief = brief.brief();
assert!(brief.contains("- Read the work item"));
assert!(brief.contains("- Wrote the failing test"));
assert!(
brief.contains("not necessarily everything it did"),
"an interrupted run stops mid-sentence, and the brief must say so"
);
}
#[test]
fn a_recovered_run_with_no_record_is_told_that_too() {
let brief = Handover::recovered(recovery(), vec![flight()]).brief();
assert!(brief.contains("anywhere from nowhere to almost finished"));
}
#[test]
fn a_steered_run_carries_the_instruction_and_its_precedence() {
let brief = Handover::steered(
Steer {
previous: RunId::from("run_01XYZ"),
note: " Use the existing retry helper, do not write a new one. ".to_owned(),
at: Timestamp::now(),
},
vec![flight()],
)
.brief();
assert!(brief.contains("A human has redirected"));
assert!(brief.contains("> Use the existing retry helper"));
assert!(
brief.contains("takes precedence"),
"steering that does not override the original request is just a suggestion"
);
}
#[test]
fn steering_and_recovery_both_repeat_earlier_work() {
assert!(Cause::Recovered(recovery()).repeats_earlier_work());
assert!(
Cause::Steered(Steer {
previous: RunId::from("run_1"),
note: "stop".into(),
at: Timestamp::now(),
})
.repeats_earlier_work()
);
}
#[test]
fn a_ground_stop_is_never_restarted_through() {
assert!(!Interruption::GroundStop.is_retryable());
assert_eq!(
authorize_recovery(
RecoveryPolicy::Automatic,
&Interruption::GroundStop,
ChildState::Gone,
0,
3
),
Err(RecoveryDenied::NotRetryable)
);
}
#[test]
fn ordinary_interruptions_are_retryable() {
for interruption in [
Interruption::TowerRestart,
Interruption::LostContact,
Interruption::Timeout,
Interruption::Crashed { exit_code: Some(1) },
Interruption::Crashed { exit_code: None },
] {
assert!(interruption.is_retryable(), "{interruption}");
assert_eq!(
authorize_recovery(
RecoveryPolicy::Automatic,
&interruption,
ChildState::Gone,
0,
3
),
Ok(())
);
}
}
#[test]
fn a_process_that_might_still_be_running_is_not_recovered_over() {
for interruption in [Interruption::TowerRestart, Interruption::LostContact] {
assert!(interruption.child_may_still_be_running(), "{interruption}");
assert_eq!(
authorize_recovery(
RecoveryPolicy::Automatic,
&interruption,
ChildState::Unknown,
0,
3
),
Err(RecoveryDenied::ChildUnaccountedFor),
"{interruption} must be checked before it is restarted"
);
}
}
#[test]
fn an_interruption_the_tower_watched_needs_no_liveness_check() {
for interruption in [
Interruption::Timeout,
Interruption::Crashed { exit_code: Some(1) },
] {
assert!(!interruption.child_may_still_be_running(), "{interruption}");
assert_eq!(
authorize_recovery(
RecoveryPolicy::Automatic,
&interruption,
ChildState::Unknown,
0,
3
),
Ok(())
);
}
}
#[test]
fn a_crash_loop_is_bounded() {
assert_eq!(
authorize_recovery(
RecoveryPolicy::Automatic,
&Interruption::Timeout,
ChildState::Gone,
3,
3
),
Err(RecoveryDenied::OutOfAttempts { max_attempts: 3 })
);
assert_eq!(
authorize_recovery(
RecoveryPolicy::Automatic,
&Interruption::Timeout,
ChildState::Gone,
2,
3
),
Ok(())
);
}
#[test]
fn a_policy_of_never_or_manual_stops_automatic_restarts() {
assert_eq!(
authorize_recovery(
RecoveryPolicy::Never,
&Interruption::Timeout,
ChildState::Gone,
0,
3
),
Err(RecoveryDenied::PolicyForbids)
);
assert_eq!(
authorize_recovery(
RecoveryPolicy::Manual,
&Interruption::Timeout,
ChildState::Gone,
0,
3
),
Err(RecoveryDenied::NeedsAHuman)
);
assert!(RecoveryPolicy::Automatic.is_automatic());
assert!(!RecoveryPolicy::Manual.is_automatic());
}
#[test]
fn a_zero_attempt_limit_disables_automatic_restarts_entirely() {
assert_eq!(
authorize_recovery(
RecoveryPolicy::Automatic,
&Interruption::Timeout,
ChildState::Gone,
0,
0
),
Err(RecoveryDenied::OutOfAttempts { max_attempts: 0 })
);
}
#[test]
fn the_flights_the_earlier_run_received_come_with_it() {
let handover = Handover::recovered(recovery(), vec![flight()]);
assert_eq!(handover.flights.len(), 1);
assert_eq!(handover.flights[0].body, "implement the retry policy");
}
}