Skip to main content

layover_core/
run.rs

1//! What a run was, once it is over.
2//!
3//! [`crate::cost::RunCost`] records what a run *spent*. This records what it *did*: which agent,
4//! in which chain, started by which pipeline, and how it ended. The dashboard needs both, and
5//! they are separate types because cost is a rail that must work even when history is turned off.
6
7use jiff::Timestamp;
8use serde::{Deserialize, Serialize};
9
10use crate::agent::AgentName;
11use crate::cost::{CostSource, TokenUsage};
12use crate::flight::{ItineraryId, RunId};
13use crate::pipeline::PipelineName;
14
15/// How a run ended.
16///
17/// `Running` is here rather than in a separate "live" type so that one query answers both "what
18/// is happening now" and "what happened last week". A dashboard that had to stitch two sources
19/// together would show them disagreeing during the moment a run finishes.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum Outcome {
23    /// Still going.
24    Running,
25    /// Exited successfully.
26    Succeeded,
27    /// Exited non-zero, or the runner reported failure.
28    Failed,
29    /// Hit `timeout_sec` and was killed.
30    TimedOut,
31    /// Stopped because a rail refused it: Hops, Fuel, the run cap or the Reserve.
32    Halted,
33    /// Was alive when the Tower went away. See the recovery model.
34    Interrupted,
35}
36
37impl Outcome {
38    /// Returns `true` when the run is still going.
39    #[must_use]
40    pub fn is_live(self) -> bool {
41        matches!(self, Self::Running)
42    }
43
44    /// Returns `true` when the run finished the way it was supposed to.
45    #[must_use]
46    pub fn is_success(self) -> bool {
47        matches!(self, Self::Succeeded)
48    }
49
50    /// Returns `true` when the run ended badly enough to be worth a human's attention.
51    ///
52    /// [`Outcome::Halted`] is deliberately excluded. A rail stopping work is the system doing its
53    /// job, and colouring it like a crash would train people to ignore the colour.
54    #[must_use]
55    pub fn is_failure(self) -> bool {
56        matches!(self, Self::Failed | Self::TimedOut | Self::Interrupted)
57    }
58
59    /// The identifier used in query strings and JSON.
60    #[must_use]
61    pub fn slug(self) -> &'static str {
62        match self {
63            Self::Running => "running",
64            Self::Succeeded => "succeeded",
65            Self::Failed => "failed",
66            Self::TimedOut => "timed_out",
67            Self::Halted => "halted",
68            Self::Interrupted => "interrupted",
69        }
70    }
71
72    /// Parses a slug, as used in a query string.
73    #[must_use]
74    pub fn from_slug(slug: &str) -> Option<Self> {
75        [
76            Self::Running,
77            Self::Succeeded,
78            Self::Failed,
79            Self::TimedOut,
80            Self::Halted,
81            Self::Interrupted,
82        ]
83        .into_iter()
84        .find(|outcome| outcome.slug() == slug)
85    }
86}
87
88impl std::fmt::Display for Outcome {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.write_str(self.slug())
91    }
92}
93
94/// One supervised CLI execution, as recorded in history.
95///
96/// Serialised one per line as JSON. Field names are the wire format: renaming one silently
97/// orphans every record already on disk, so treat them as an API.
98#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
99pub struct RunRecord {
100    /// The run.
101    pub run: RunId,
102    /// The chain it belonged to.
103    pub itinerary: ItineraryId,
104    /// Which agent was run.
105    pub agent: AgentName,
106    /// The pipeline that started the chain, when one did.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub pipeline: Option<PipelineName>,
109    /// Which model, when the runner said.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub model: Option<String>,
112    /// How it ended.
113    pub outcome: Outcome,
114    /// When it started.
115    pub started_at: Timestamp,
116    /// When it ended, or `None` while it is still going.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub finished_at: Option<Timestamp>,
119    /// Cost in US dollars.
120    #[serde(default)]
121    pub usd: f64,
122    /// Where `usd` came from.
123    pub source: CostSource,
124    /// Tokens consumed, as far as they are known.
125    #[serde(default)]
126    pub usage: TokenUsage,
127    /// Why it ended, for the outcomes where that is not obvious.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub detail: Option<String>,
130    /// What the run could not get past, when it asked for help.
131    ///
132    /// Present whether or not the run succeeded, because the two are independent: an agent can
133    /// finish its task and still have been unable to check something. Without this a blocked run
134    /// looks exactly like a clean one on a list, which is the failure mode a lights-out factory
135    /// can least afford — the detail lives with the help request, and this is the one line that
136    /// makes the run worth opening.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub blocked_on: Option<String>,
139    /// The operating system process id, while the run is live.
140    ///
141    /// Recorded so that a Tower coming back from a restart can *check* whether the process is
142    /// still there rather than assume. A child routinely outlives the parent that spawned it on
143    /// Windows, and recovering beside a process that never stopped duplicates its work — see
144    /// [`crate::handover::ChildState`].
145    ///
146    /// A recycled process id can make a dead run look alive, which fails towards refusing to
147    /// recover. That is the safe direction: stalled work is visible, duplicated work is not.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub pid: Option<u32>,
150}
151
152impl RunRecord {
153    /// Records a run that has just started.
154    #[must_use]
155    pub fn started(
156        run: RunId,
157        itinerary: ItineraryId,
158        agent: AgentName,
159        started_at: Timestamp,
160    ) -> Self {
161        Self {
162            run,
163            itinerary,
164            agent,
165            pipeline: None,
166            model: None,
167            outcome: Outcome::Running,
168            started_at,
169            finished_at: None,
170            usd: 0.0,
171            source: CostSource::Unreported,
172            usage: TokenUsage::default(),
173            detail: None,
174            blocked_on: None,
175            pid: None,
176        }
177    }
178
179    /// Attributes the run to the pipeline that started its chain.
180    #[must_use]
181    pub fn from_pipeline(mut self, pipeline: PipelineName) -> Self {
182        self.pipeline = Some(pipeline);
183        self
184    }
185
186    /// Notes which model the runner used.
187    #[must_use]
188    pub fn using_model(mut self, model: impl Into<String>) -> Self {
189        self.model = Some(model.into());
190        self
191    }
192
193    /// Closes the run out with an outcome and a finishing time.
194    #[must_use]
195    pub fn finished(mut self, outcome: Outcome, at: Timestamp) -> Self {
196        self.outcome = outcome;
197        self.finished_at = Some(at);
198        self
199    }
200
201    /// Attaches what the run cost.
202    #[must_use]
203    pub fn costing(mut self, usd: f64, source: CostSource, usage: TokenUsage) -> Self {
204        self.usd = usd;
205        self.source = source;
206        self.usage = usage;
207        self
208    }
209
210    /// Explains an outcome that is not self-evident.
211    #[must_use]
212    pub fn because(mut self, detail: impl Into<String>) -> Self {
213        self.detail = Some(detail.into());
214        self
215    }
216
217    /// Notes that the run asked for help, and what about.
218    #[must_use]
219    pub fn blocked_on(mut self, summary: impl Into<String>) -> Self {
220        self.blocked_on = Some(summary.into());
221        self
222    }
223
224    /// Returns `true` when the run reported something in its way.
225    #[must_use]
226    pub fn needed_help(&self) -> bool {
227        self.blocked_on.is_some()
228    }
229
230    /// Notes the process id, so a later Tower can check whether it is still running.
231    #[must_use]
232    pub fn with_pid(mut self, pid: u32) -> Self {
233        self.pid = Some(pid);
234        self
235    }
236
237    /// How long the run took, in whole seconds, or `None` while it is still going.
238    ///
239    /// Returns `None` rather than a negative number if the clock went backwards between the two
240    /// readings, which NTP correction can do: a negative duration on a dashboard is worse than an
241    /// absent one, because somebody will average it.
242    #[must_use]
243    pub fn duration_secs(&self) -> Option<i64> {
244        let finished = self.finished_at?;
245        let seconds = finished.as_second() - self.started_at.as_second();
246        (seconds >= 0).then_some(seconds)
247    }
248
249    /// The instant this record should be filed under.
250    ///
251    /// Runs are filed by when they *finished*, matching the cost ledger, so that a total for a
252    /// period covers the runs whose money landed in it. A long run started before a window and
253    /// finished inside it belongs to the window it was paid for.
254    #[must_use]
255    pub fn filed_at(&self) -> Timestamp {
256        self.finished_at.unwrap_or(self.started_at)
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn at(rfc3339: &str) -> Timestamp {
265        rfc3339.parse().expect("valid timestamp")
266    }
267
268    fn record() -> RunRecord {
269        RunRecord::started(
270            RunId::generate(),
271            ItineraryId::generate(),
272            "developer".into(),
273            at("2026-09-16T10:00:00Z"),
274        )
275    }
276
277    #[test]
278    fn a_finished_run_reports_how_long_it_took() {
279        let done = record().finished(Outcome::Succeeded, at("2026-09-16T10:04:30Z"));
280
281        assert_eq!(done.duration_secs(), Some(270));
282        assert!(done.outcome.is_success());
283        assert!(!done.outcome.is_live());
284    }
285
286    #[test]
287    fn a_running_run_has_no_duration_yet() {
288        let live = record();
289
290        assert_eq!(live.duration_secs(), None);
291        assert!(live.outcome.is_live());
292        assert_eq!(live.filed_at(), live.started_at);
293    }
294
295    #[test]
296    fn a_backwards_clock_produces_no_duration_rather_than_a_negative_one() {
297        // NTP correction can move the clock between the two readings. A negative duration is
298        // worse than a missing one, because it will end up in an average.
299        let impossible = record().finished(Outcome::Succeeded, at("2026-09-16T09:59:00Z"));
300
301        assert_eq!(impossible.duration_secs(), None);
302    }
303
304    #[test]
305    fn runs_are_filed_by_when_they_finished() {
306        // Matching the cost ledger, so that a period's total covers the runs whose money landed
307        // in it rather than the ones that happened to begin in it.
308        let done = record().finished(Outcome::Succeeded, at("2026-09-16T10:04:30Z"));
309
310        assert_eq!(done.filed_at(), at("2026-09-16T10:04:30Z"));
311    }
312
313    #[test]
314    fn a_rail_stopping_work_is_not_a_failure() {
315        // Hops, Fuel and the Reserve doing their job is the system working. Colouring it like a
316        // crash teaches people to ignore the colour, which is how a real crash gets missed.
317        assert!(!Outcome::Halted.is_failure());
318        assert!(Outcome::Failed.is_failure());
319        assert!(Outcome::TimedOut.is_failure());
320        assert!(Outcome::Interrupted.is_failure());
321    }
322
323    #[test]
324    fn outcome_slugs_round_trip() {
325        for outcome in [
326            Outcome::Running,
327            Outcome::Succeeded,
328            Outcome::Failed,
329            Outcome::TimedOut,
330            Outcome::Halted,
331            Outcome::Interrupted,
332        ] {
333            assert_eq!(Outcome::from_slug(outcome.slug()), Some(outcome));
334        }
335
336        assert_eq!(Outcome::from_slug("exploded"), None);
337    }
338
339    #[test]
340    fn a_record_serialises_to_one_readable_line() {
341        // The file is meant to be openable in a text editor, which is most of why it is JSON
342        // Lines rather than a database. Timestamps must read as dates, not as epoch structs.
343        let done = record()
344            .from_pipeline("triage".into())
345            .using_model("claude-opus-5")
346            .finished(Outcome::Succeeded, at("2026-09-16T10:04:30Z"))
347            .costing(1.25, CostSource::Reported, TokenUsage::default());
348
349        let line = serde_json::to_string(&done).expect("serialises");
350
351        assert!(
352            !line.contains('\n'),
353            "a record must occupy exactly one line"
354        );
355        assert!(
356            line.contains(r#""started_at":"2026-09-16T10:00:00Z""#),
357            "{line}"
358        );
359        assert!(line.contains(r#""outcome":"succeeded""#), "{line}");
360        assert!(line.contains(r#""pipeline":"triage""#), "{line}");
361    }
362
363    #[test]
364    fn absent_optional_fields_are_left_out_rather_than_written_as_null() {
365        let line = serde_json::to_string(&record()).expect("serialises");
366
367        assert!(!line.contains("null"), "{line}");
368        assert!(!line.contains("finished_at"), "{line}");
369    }
370
371    #[test]
372    fn a_run_can_succeed_and_still_have_been_blocked() {
373        // The two are independent. An agent that finished its task but could not check one thing
374        // is worth opening, and on a list it would otherwise look identical to a clean run.
375        let limited = record()
376            .finished(Outcome::Succeeded, at("2026-09-16T10:05:00Z"))
377            .blocked_on("could not read the linked file: permission denied");
378
379        assert!(limited.outcome.is_success());
380        assert!(limited.needed_help());
381        assert!(!record().needed_help());
382    }
383
384    #[test]
385    fn a_live_run_records_the_process_it_is_waiting_on() {
386        // Without this a Tower coming back from a restart has nothing to check, and must either
387        // assume the child died -- which duplicates work when it did not -- or never recover.
388        let spawned = record().with_pid(4242);
389
390        let line = serde_json::to_string(&spawned).expect("serialises");
391        assert!(line.contains(r#""pid":4242"#), "{line}");
392
393        let closed = spawned.finished(Outcome::Succeeded, at("2026-09-16T10:01:00Z"));
394        assert_eq!(closed.pid, Some(4242), "the id survives the run ending");
395    }
396
397    #[test]
398    fn a_record_round_trips_through_its_wire_format() {
399        let done = record()
400            .from_pipeline("triage".into())
401            .finished(Outcome::Halted, at("2026-09-16T10:01:00Z"))
402            .because("out of Fuel");
403
404        let line = serde_json::to_string(&done).expect("serialises");
405        let back: RunRecord = serde_json::from_str(&line).expect("deserialises");
406
407        assert_eq!(back, done);
408        assert_eq!(back.detail.as_deref(), Some("out of Fuel"));
409    }
410}