返回 CodeWhale
agent_run.rs
根目录 / crates / protocol / src / 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
25 use serde::{Deserialize, Serialize};
26
27 use 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")]
32 pub 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")]
53 pub 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
74 impl 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)]
100 pub 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")]
121 pub 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)]
136 pub 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")]
150 pub 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)]
166 pub 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)]
176 pub 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
198 impl 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)]
210 mod 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 }
343
343 lines RUST