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}