Skip to main content

ljos_cli/
plugin.rs

1//! A harness child, restarted the way OTP restarts one.
2//!
3//! Permanent restarts however it exited, transient only on a crash,
4//! temporary never. `max_restarts` exits inside `window_secs` give up
5//! (Armstrong, *Making reliable distributed systems in the presence of
6//! software errors*). `ljos supervise` prints a `herdr agent start`
7//! line and does not run it. A persona pane is opened by the `[[tool]]`
8//! table in `persona_session`, not by that line.
9
10use std::path::Path;
11
12use serde::Serialize;
13
14/// How a child is restarted. The three OTP values.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Restart {
17    /// Restart however it exited.
18    Permanent,
19    /// Restart only when it crashed.
20    Transient,
21    /// Never restart.
22    Temporary,
23}
24
25impl Restart {
26    pub fn parse(raw: &str) -> Option<Self> {
27        match raw {
28            "permanent" => Some(Self::Permanent),
29            "transient" => Some(Self::Transient),
30            "temporary" => Some(Self::Temporary),
31            _ => None,
32        }
33    }
34}
35
36/// Whether the child exited on its own or failed.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ExitKind {
39    Clean,
40    Crash,
41}
42
43impl ExitKind {
44    pub fn parse(raw: &str) -> Option<Self> {
45        match raw {
46            "clean" => Some(Self::Clean),
47            "crash" => Some(Self::Crash),
48            _ => None,
49        }
50    }
51}
52
53/// What the supervisor does with one exit.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "kebab-case")]
56pub enum Action {
57    Restart,
58    Stop,
59    /// The intensity was exceeded: stop, and do not try again in this window.
60    GiveUp,
61}
62
63/// One harness child. `max_restarts` exits inside `window_secs` give up.
64#[derive(Debug, Clone)]
65pub struct Child {
66    pub name: String,
67    pub harness: String,
68    pub restart: Restart,
69    pub max_restarts: u32,
70    pub window_secs: u64,
71}
72
73/// The exits already counted, as seconds since the epoch.
74#[derive(Debug, Clone)]
75pub struct Supervisor {
76    pub child: Child,
77    pub failures: Vec<u64>,
78}
79
80impl Supervisor {
81    /// Classify `kind` at time `now`. A restart records `now` so the next
82    /// exit sees it. A give-up does not record another failure.
83    pub fn on_exit(&mut self, kind: ExitKind, now: u64) -> Action {
84        match (self.child.restart, kind) {
85            (Restart::Temporary, _) | (Restart::Transient, ExitKind::Clean) => Action::Stop,
86            (Restart::Permanent, _) | (Restart::Transient, ExitKind::Crash) => {
87                let window = self.child.window_secs;
88                self.failures.retain(|t| now.saturating_sub(*t) < window);
89                if self.failures.len() as u32 >= self.child.max_restarts {
90                    Action::GiveUp
91                } else {
92                    self.failures.push(now);
93                    Action::Restart
94                }
95            }
96        }
97    }
98}
99
100/// The herdr line that would start this child again: `herdr agent start
101/// persona-NAME --no-focus --cwd HOME -- sh SCRIPT`. The script is the
102/// one that execs the harness. `ljos supervise` prints the line and does
103/// not run it.
104#[must_use]
105pub fn herdr_argv(name: &str, home: &Path, script: &Path) -> Vec<String> {
106    vec![
107        "herdr".into(),
108        "agent".into(),
109        "start".into(),
110        format!("persona-{name}"),
111        "--no-focus".into(),
112        "--cwd".into(),
113        home.display().to_string(),
114        "--".into(),
115        "sh".into(),
116        script.display().to_string(),
117    ]
118}
119
120/// What `ljos supervise` prints.
121#[derive(Debug, Serialize)]
122pub struct Decision {
123    pub action: Action,
124    pub harness: String,
125    pub herdr: Vec<String>,
126    pub failures: Vec<u64>,
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::path::Path;
133
134    fn child(restart: Restart) -> Supervisor {
135        Supervisor {
136            child: Child {
137                name: "grok-1".into(),
138                harness: "grok".into(),
139                restart,
140                max_restarts: 3,
141                window_secs: 60,
142            },
143            failures: Vec::new(),
144        }
145    }
146
147    #[test]
148    fn a_permanent_child_restarts_and_then_gives_up() {
149        let mut sup = child(Restart::Permanent);
150        assert_eq!(sup.on_exit(ExitKind::Crash, 10), Action::Restart);
151        assert_eq!(sup.on_exit(ExitKind::Clean, 20), Action::Restart);
152        assert_eq!(sup.on_exit(ExitKind::Crash, 30), Action::Restart);
153        assert_eq!(sup.on_exit(ExitKind::Crash, 40), Action::GiveUp);
154        assert_eq!(sup.failures, vec![10, 20, 30]);
155    }
156
157    #[test]
158    fn an_old_failure_leaves_the_window() {
159        let mut sup = child(Restart::Permanent);
160        sup.failures = vec![10, 20, 30];
161        assert_eq!(sup.on_exit(ExitKind::Crash, 100), Action::Restart);
162        assert_eq!(sup.failures, vec![100]);
163    }
164
165    #[test]
166    fn transient_stops_on_a_clean_exit_and_temporary_never_restarts() {
167        let mut sup = child(Restart::Transient);
168        assert_eq!(sup.on_exit(ExitKind::Clean, 5), Action::Stop);
169        assert!(sup.failures.is_empty());
170        assert_eq!(sup.on_exit(ExitKind::Crash, 6), Action::Restart);
171        let mut once = child(Restart::Temporary);
172        assert_eq!(once.on_exit(ExitKind::Crash, 1), Action::Stop);
173    }
174
175    #[test]
176    fn herdr_starts_a_grok_child_and_a_claude_child_the_same_way() {
177        let grok = herdr_argv(
178            "grok-1",
179            Path::new("/s/personas/grok-1"),
180            Path::new("/s/run.sh"),
181        );
182        let claude = herdr_argv(
183            "claude-1",
184            Path::new("/s/personas/claude-1"),
185            Path::new("/s/run.sh"),
186        );
187        assert_eq!(
188            grok,
189            [
190                "herdr",
191                "agent",
192                "start",
193                "persona-grok-1",
194                "--no-focus",
195                "--cwd",
196                "/s/personas/grok-1",
197                "--",
198                "sh",
199                "/s/run.sh",
200            ]
201        );
202        assert_eq!(claude[3], "persona-claude-1");
203        assert_eq!(grok[1..3], claude[1..3]);
204    }
205}