Skip to main content

leviath_core/
execution.rs

1//! Execution identity: what a run did, told apart from what it was asked to do.
2//!
3//! A tool call is an intended operation: a tool name and its arguments. An
4//! execution is one attempt to carry that out. The two were the same thing here
5//! until now, because the journal used the provider's own call id as identity,
6//! and a provider is under no obligation to make those unique: a retried or
7//! reissued call can arrive with the id an earlier one had. Two attempts sharing
8//! an id cannot be told apart afterwards, which is exactly the question a run
9//! debugger exists to answer.
10//!
11//! So an execution gets an id this crate mints, and the provider's id travels
12//! beside it as correlation. Every record about one attempt carries the
13//! execution id; anything a provider says about it is matched through the
14//! correlation id, which may repeat.
15
16use std::sync::atomic::{AtomicU64, Ordering};
17
18use serde::{Deserialize, Serialize};
19
20/// How one attempt to execute a tool call ended.
21///
22/// The unknown case is a state rather than an absence. A run that crashed
23/// between dispatch and completion left a call whose outcome nobody observed,
24/// and a missing completion is not evidence of success or of safe retry: it is
25/// the one fact there is, and recording it is what lets a person decide.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ToolOutcome {
29    /// The tool ran and answered.
30    Succeeded,
31    /// The tool ran and failed.
32    Failed,
33    /// A gate refused it before it ran: the taint gate, a permission rule.
34    Blocked,
35    /// A person refused it.
36    Denied,
37    /// Nobody observed how it ended. A crash between dispatch and completion
38    /// leaves this, and no amount of reading the logs turns it into one of the
39    /// four above.
40    Indeterminate,
41}
42
43impl ToolOutcome {
44    /// The word this outcome goes on the wire as.
45    pub fn wire(self) -> &'static str {
46        match self {
47            Self::Succeeded => "succeeded",
48            Self::Failed => "failed",
49            Self::Blocked => "blocked",
50            Self::Denied => "denied",
51            Self::Indeterminate => "indeterminate",
52        }
53    }
54}
55
56/// Distinguishes two executions minted inside one nanosecond.
57static MINTED: AtomicU64 = AtomicU64::new(0);
58
59/// Mint an id for one execution attempt.
60///
61/// Unique within a machine's lifetime, and ordered: the clock leads, so the ids
62/// sort into the order they were minted and a person reading two of them can tell
63/// which came first. The counter is what keeps two minted inside one nanosecond
64/// apart, which is a thing that happens when a batch dispatches.
65///
66/// Not a hash of the call: two attempts at the same call are two executions, and
67/// an id derived from the call would make them one again.
68pub fn mint_execution_id() -> String {
69    format!("x{}", minted_suffix())
70}
71
72/// The clock and the counter, in a form that sorts.
73///
74/// Both parts are fixed width and separated. Concatenating two hex numbers of
75/// whatever width they happened to need does not sort: the sixteenth id of a
76/// nanosecond reads as `10` and lands before the fifteenth's `f`, and the two
77/// halves cannot be told apart again afterwards either.
78fn minted_suffix() -> String {
79    let nanos = std::time::SystemTime::now()
80        .duration_since(std::time::UNIX_EPOCH)
81        .map(|since| since.as_nanos())
82        .unwrap_or_default();
83    let seq = MINTED.fetch_add(1, Ordering::Relaxed);
84    format!("{nanos:016x}-{seq:08x}")
85}
86
87/// Mint an id for one stay in a stage.
88///
89/// The correlation key for everything that happened during that stay. It has to
90/// be minted rather than taken from a visit's position in the stage ledger,
91/// because that list is capped: the hundred and twenty-ninth visit would take
92/// the first one's identity, and everything correlated to it would move.
93pub fn mint_visit_id() -> String {
94    format!("v{}", minted_suffix())
95}
96
97/// Mint an id for one trip to a provider.
98///
99/// What the answer's consequences name it by. A tool batch records the attempt
100/// whose answer asked for it, and neither the stage nor the attempt number can
101/// serve: a stage makes hundreds of trips, and the number restarts at every
102/// call.
103pub fn mint_attempt_id() -> String {
104    format!("a{}", minted_suffix())
105}
106
107#[cfg(test)]
108mod tests {
109    use super::{ToolOutcome, mint_execution_id, mint_visit_id};
110
111    /// Two ids minted in a row are different, and they sort into the order they
112    /// were minted.
113    ///
114    /// A batch mints several inside one nanosecond, which is what the counter is
115    /// for: without it a dispatch of four calls could hand two of them one id.
116    #[test]
117    fn minted_ids_are_unique_and_ordered() {
118        let ids: Vec<String> = (0..64).map(|_| mint_execution_id()).collect();
119        let unique: std::collections::HashSet<&String> = ids.iter().collect();
120        assert_eq!(unique.len(), ids.len(), "no two alike: {ids:?}");
121        let mut sorted = ids.clone();
122        sorted.sort();
123        assert_eq!(sorted, ids, "minted in order, so they read in order");
124        // Both halves stay readable: a person comparing two ids can see which
125        // nanosecond each came from and which of that nanosecond's it was.
126        let (clock, seq) = ids[0]
127            .strip_prefix('x')
128            .expect("an execution id says which kind it is")
129            .split_once('-')
130            .expect("two parts");
131        assert_eq!(clock.len(), 16, "{clock}");
132        assert_eq!(seq.len(), 8, "{seq}");
133    }
134
135    /// Each kind of id is told apart by its first character, so one pasted
136    /// where another belongs is visibly wrong.
137    #[test]
138    fn the_kinds_of_id_are_told_apart_on_sight() {
139        assert!(mint_execution_id().starts_with('x'));
140        assert!(mint_visit_id().starts_with('v'));
141        assert!(super::mint_attempt_id().starts_with('a'));
142    }
143
144    /// Every outcome has its own word.
145    #[test]
146    fn each_outcome_has_its_own_word() {
147        assert_eq!(ToolOutcome::Succeeded.wire(), "succeeded");
148        assert_eq!(ToolOutcome::Failed.wire(), "failed");
149        assert_eq!(ToolOutcome::Blocked.wire(), "blocked");
150        assert_eq!(ToolOutcome::Denied.wire(), "denied");
151        assert_eq!(ToolOutcome::Indeterminate.wire(), "indeterminate");
152    }
153
154    /// The outcome round-trips through the journal's own encoding.
155    #[test]
156    fn an_outcome_round_trips_as_its_word() {
157        for outcome in [
158            ToolOutcome::Succeeded,
159            ToolOutcome::Failed,
160            ToolOutcome::Blocked,
161            ToolOutcome::Denied,
162            ToolOutcome::Indeterminate,
163        ] {
164            let json = serde_json::to_string(&outcome).expect("it serializes");
165            assert_eq!(json, format!("\"{}\"", outcome.wire()));
166            let back: ToolOutcome = serde_json::from_str(&json).expect("it reads back");
167            assert_eq!(back, outcome);
168        }
169    }
170}