Skip to main content

codewhale_protocol/
agent_run.rs

1//! Dependency-neutral agent-run read model.
2//!
3//! [`AgentRunSnapshot`] is a uniform, serializable projection of "one unit of
4//! agent work" regardless of which subsystem owns it: a direct sub-agent
5//! worker, a workflow run, a fleet run, a core background job, or a managed
6//! task. It carries serialized IDs, neutral enums, and scalar summaries only —
7//! never handles, callbacks, or owner-internal types — so any surface can
8//! consume it without depending on the owning subsystem.
9//!
10//! Ownership contract:
11//! - Each owner keeps its own richer record type and maps it here through a
12//!   pure adapter function living in the owner's crate/module. This crate
13//!   depends on nothing new; owners depend on it, never the reverse.
14//! - Adapters must apply the durable-outranks-live rule: when both a durable
15//!   record and a live in-memory handle exist, a terminal durable state always
16//!   wins over lagging live enrichment. Live state may only refine a run that
17//!   the durable record still considers in-flight.
18//! - Adapters never fabricate values: unknown budgets and timestamps stay
19//!   `None`, and references are logical identifiers within the owner's
20//!   namespace — never filesystem paths and never secrets.
21//!
22//! This is a staging slice: the model compiles and is tested, but nothing
23//! consumes it yet. Consumers arrive with later slices.
24
25use serde::{Deserialize, Serialize};
26
27use super::Status;
28
29/// Which subsystem owns the run behind a snapshot.
30#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
31#[serde(rename_all = "snake_case")]
32pub enum RunSource {
33    /// A directly spawned sub-agent worker.
34    Direct,
35    /// A workflow VM run.
36    Workflow,
37    /// A fleet run reconstructed from the durable ledger.
38    Fleet,
39    /// A core background job.
40    CoreJob,
41    /// A managed background task.
42    Task,
43}
44
45/// Neutral lifecycle state of a run.
46///
47/// The two wait variants beyond model/tool waits exist because real owner
48/// state machines report them: workers can wait on user input, and jobs and
49/// fleet runs can be explicitly paused. Collapsing those into `Running` or
50/// `Queued` would misreport liveness, so the read model keeps them distinct.
51#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "snake_case")]
53pub enum RunState {
54    /// Accepted but not started.
55    Queued,
56    /// Spawn intent recorded; startup in progress.
57    Initializing,
58    /// Actively executing.
59    Running,
60    /// Blocked on a model response.
61    WaitingModel,
62    /// Blocked on a tool execution.
63    WaitingTool,
64    /// Blocked on user input.
65    WaitingInput,
66    /// Explicitly paused by the user or system.
67    Paused,
68    /// Cancellation or shutdown requested, not yet terminal.
69    Stopping,
70    /// Finished; see [`AgentRunSnapshot::terminal`] for the outcome.
71    Terminal,
72}
73
74impl Status for RunState {
75    fn is_terminal(&self) -> bool {
76        matches!(self, Self::Terminal)
77    }
78    fn is_active(&self) -> bool {
79        matches!(
80            self,
81            Self::Queued
82                | Self::Initializing
83                | Self::Running
84                | Self::WaitingModel
85                | Self::WaitingTool
86                | Self::WaitingInput
87                | Self::Stopping
88        )
89    }
90    fn is_paused(&self) -> bool {
91        matches!(self, Self::Paused)
92    }
93}
94
95/// Scalar budget/spend facts for a run.
96///
97/// Every field is optional: owners report only what they actually track, and
98/// adapters must not invent values for the rest.
99#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
100pub struct BudgetSummary {
101    /// Token ceiling configured for the run, if any.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub token_budget: Option<u64>,
104    /// Tokens consumed so far, when the owner tracks spend.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub tokens_used: Option<u64>,
107    /// Steps taken so far, when the owner counts steps.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub steps_taken: Option<u32>,
110    /// Step ceiling configured for the run, if any.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub max_steps: Option<u32>,
113    /// Wall-clock duration in milliseconds, when known.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub duration_ms: Option<u64>,
116}
117
118/// How a terminal run ended.
119#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
120#[serde(rename_all = "snake_case")]
121pub enum TerminalOutcome {
122    /// Ended successfully.
123    Completed,
124    /// Ended with an error.
125    Failed,
126    /// Cancelled by the user or a parent.
127    Cancelled,
128    /// Interrupted (e.g. process restart) before completion.
129    Interrupted,
130    /// Stopped because it exhausted its own budget.
131    BudgetExhausted,
132}
133
134/// Scalar summary of a terminal run.
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
136pub struct TerminalSummary {
137    pub outcome: TerminalOutcome,
138    /// Epoch milliseconds when the run ended, when the owner recorded it.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub ended_at_ms: Option<i64>,
141    /// Short human-readable detail (result summary or error text).
142    /// Never raw logs, reasoning text, or secrets.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub detail: Option<String>,
145}
146
147/// Category of a durable reference attached to a run.
148#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
149#[serde(rename_all = "snake_case")]
150pub enum ReceiptKind {
151    /// A produced artifact.
152    Artifact,
153    /// A gate/verification result.
154    Gate,
155    /// A durable completion receipt.
156    Receipt,
157    /// An approval record.
158    Approval,
159}
160
161/// Reference to a durable artifact, gate, receipt, or approval.
162///
163/// `reference` is a logical identifier within the owner's namespace — never
164/// an absolute filesystem path, never secret-bearing.
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
166pub struct ReceiptRef {
167    pub kind: ReceiptKind,
168    pub reference: String,
169    /// Optional short human-readable label.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub label: Option<String>,
172}
173
174/// Dependency-neutral snapshot of a single agent run.
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
176pub struct AgentRunSnapshot {
177    /// Serialized run identifier in the owner's existing scheme.
178    pub run_id: String,
179    /// Serialized identifier of the parent run/thread, when the owner
180    /// records one.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub parent: Option<String>,
183    /// Which subsystem owns this run.
184    pub source: RunSource,
185    /// Neutral lifecycle state.
186    pub state: RunState,
187    /// Scalar budget facts; defaults to all-unknown.
188    #[serde(default)]
189    pub budget: BudgetSummary,
190    /// Terminal outcome; present exactly when `state == Terminal`.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub terminal: Option<TerminalSummary>,
193    /// Durable references produced by the run.
194    #[serde(default, skip_serializing_if = "Vec::is_empty")]
195    pub refs: Vec<ReceiptRef>,
196}
197
198impl AgentRunSnapshot {
199    /// `true` when the terminal summary and lifecycle state agree:
200    /// `state == Terminal` iff a terminal summary is present.
201    ///
202    /// Adapters uphold this by construction; tests assert it.
203    #[must_use]
204    pub fn is_coherent(&self) -> bool {
205        matches!(self.state, RunState::Terminal) == self.terminal.is_some()
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    fn full_snapshot() -> AgentRunSnapshot {
214        AgentRunSnapshot {
215            run_id: "agent_1234abcd".to_string(),
216            parent: Some("agent_00ff00ff".to_string()),
217            source: RunSource::Direct,
218            state: RunState::Terminal,
219            budget: BudgetSummary {
220                token_budget: Some(50_000),
221                tokens_used: Some(12_345),
222                steps_taken: Some(7),
223                max_steps: Some(40),
224                duration_ms: Some(93_000),
225            },
226            terminal: Some(TerminalSummary {
227                outcome: TerminalOutcome::Completed,
228                ended_at_ms: Some(1_800_000_000_000),
229                detail: Some("verified build".to_string()),
230            }),
231            refs: vec![ReceiptRef {
232                kind: ReceiptKind::Artifact,
233                reference: "runs/agent_1234abcd/result.md".to_string(),
234                label: Some("result".to_string()),
235            }],
236        }
237    }
238
239    fn minimal_snapshot() -> AgentRunSnapshot {
240        AgentRunSnapshot {
241            run_id: "job-42".to_string(),
242            parent: None,
243            source: RunSource::CoreJob,
244            state: RunState::Queued,
245            budget: BudgetSummary::default(),
246            terminal: None,
247            refs: Vec::new(),
248        }
249    }
250
251    #[test]
252    fn full_snapshot_round_trips() {
253        let snapshot = full_snapshot();
254        let json = serde_json::to_string(&snapshot).expect("serialize");
255        let back: AgentRunSnapshot = serde_json::from_str(&json).expect("deserialize");
256        assert_eq!(back, snapshot);
257        assert!(back.is_coherent());
258    }
259
260    #[test]
261    fn minimal_snapshot_round_trips_and_skips_empty_fields() {
262        let snapshot = minimal_snapshot();
263        let json = serde_json::to_string(&snapshot).expect("serialize");
264        // Optional/empty fields stay off the wire entirely.
265        assert!(!json.contains("parent"));
266        assert!(!json.contains("terminal"));
267        assert!(!json.contains("refs"));
268        assert!(!json.contains("token_budget"));
269        let back: AgentRunSnapshot = serde_json::from_str(&json).expect("deserialize");
270        assert_eq!(back, snapshot);
271        assert!(back.is_coherent());
272    }
273
274    #[test]
275    fn enum_wire_names_are_snake_case_and_stable() {
276        assert_eq!(
277            serde_json::to_string(&RunSource::CoreJob).unwrap(),
278            "\"core_job\""
279        );
280        assert_eq!(
281            serde_json::to_string(&RunState::WaitingModel).unwrap(),
282            "\"waiting_model\""
283        );
284        assert_eq!(
285            serde_json::to_string(&TerminalOutcome::BudgetExhausted).unwrap(),
286            "\"budget_exhausted\""
287        );
288        assert_eq!(
289            serde_json::to_string(&ReceiptKind::Gate).unwrap(),
290            "\"gate\""
291        );
292        // Every state deserializes back to itself.
293        for state in [
294            RunState::Queued,
295            RunState::Initializing,
296            RunState::Running,
297            RunState::WaitingModel,
298            RunState::WaitingTool,
299            RunState::WaitingInput,
300            RunState::Paused,
301            RunState::Stopping,
302            RunState::Terminal,
303        ] {
304            let json = serde_json::to_string(&state).unwrap();
305            let back: RunState = serde_json::from_str(&json).unwrap();
306            assert_eq!(back, state);
307        }
308    }
309
310    #[test]
311    fn run_state_status_trait_partitions_all_states() {
312        for state in [
313            RunState::Queued,
314            RunState::Initializing,
315            RunState::Running,
316            RunState::WaitingModel,
317            RunState::WaitingTool,
318            RunState::WaitingInput,
319            RunState::Paused,
320            RunState::Stopping,
321            RunState::Terminal,
322        ] {
323            let classifications = [state.is_terminal(), state.is_active(), state.is_paused()];
324            assert_eq!(
325                classifications.iter().filter(|flag| **flag).count(),
326                1,
327                "state {state:?} must be exactly one of terminal/active/paused"
328            );
329        }
330    }
331
332    #[test]
333    fn incoherent_snapshot_is_detectable() {
334        let mut snapshot = minimal_snapshot();
335        snapshot.terminal = Some(TerminalSummary {
336            outcome: TerminalOutcome::Completed,
337            ended_at_ms: None,
338            detail: None,
339        });
340        assert!(!snapshot.is_coherent());
341    }
342}