1use khive_types::hash::Hash32;
9pub use khive_types::{AgentRecord, AgentState, TerminalReason};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Trigger {
19 Dispatch,
21 Activity,
24 Suspend,
25 Resume,
26 Kill,
27 Complete,
29 Fail,
31 Abandon,
33 HostRestart,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct Transition {
45 pub state: AgentState,
46 pub terminal_reason: Option<TerminalReason>,
47 pub changed: bool,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct IllegalTransition {
54 pub from: AgentState,
55 pub trigger: Trigger,
56}
57
58pub fn apply_transition(
61 from: AgentState,
62 terminal_reason: Option<TerminalReason>,
63 trigger: Trigger,
64) -> Result<Transition, IllegalTransition> {
65 use AgentState::*;
66 use Trigger::*;
67
68 match (from, trigger) {
69 (Spawned, Dispatch) => Ok(Transition {
70 state: Running,
71 terminal_reason: None,
72 changed: true,
73 }),
74 (Running, Activity) => Ok(Transition {
75 state: Running,
76 terminal_reason: None,
77 changed: false,
78 }),
79
80 (Running, Suspend) => Ok(Transition {
81 state: Suspended,
82 terminal_reason: None,
83 changed: true,
84 }),
85 (Suspended, Suspend) => Ok(Transition {
86 state: Suspended,
87 terminal_reason: None,
88 changed: false,
89 }),
90
91 (Suspended, Resume) => Ok(Transition {
92 state: Running,
93 terminal_reason: None,
94 changed: true,
95 }),
96 (Running, Resume) => Ok(Transition {
97 state: Running,
98 terminal_reason: None,
99 changed: false,
100 }),
101
102 (Spawned, Kill) | (Running, Kill) | (Suspended, Kill) => Ok(Transition {
103 state: Terminal,
104 terminal_reason: Some(TerminalReason::Killed),
105 changed: true,
106 }),
107 (Terminal, Kill) => Ok(Transition {
108 state: Terminal,
109 terminal_reason,
110 changed: false,
111 }),
112
113 (Running, Complete) => Ok(Transition {
114 state: Terminal,
115 terminal_reason: Some(TerminalReason::Completed),
116 changed: true,
117 }),
118 (Running, Fail) => Ok(Transition {
119 state: Terminal,
120 terminal_reason: Some(TerminalReason::Failed),
121 changed: true,
122 }),
123 (Running, Abandon) => Ok(Transition {
124 state: Terminal,
125 terminal_reason: Some(TerminalReason::Abandoned),
126 changed: true,
127 }),
128
129 (Spawned, HostRestart) | (Running, HostRestart) | (Suspended, HostRestart) => {
130 Ok(Transition {
131 state: Terminal,
132 terminal_reason: Some(TerminalReason::HostRestart),
133 changed: true,
134 })
135 }
136 (Terminal, HostRestart) => Ok(Transition {
137 state: Terminal,
138 terminal_reason,
139 changed: false,
140 }),
141
142 _ => Err(IllegalTransition { from, trigger }),
143 }
144}
145
146pub fn spawn_fingerprint(
158 provider: &str,
159 task: &str,
160 provider_session_id: Option<&str>,
161 checkpoint_session_id: Option<&str>,
162) -> String {
163 let mut fields: Vec<(&str, &str)> = vec![("provider", provider), ("task", task)];
164 if let Some(value) = provider_session_id {
165 fields.push(("provider_session_id", value));
166 }
167 if let Some(value) = checkpoint_session_id {
168 fields.push(("checkpoint_session_id", value));
169 }
170
171 let mut canonical = String::from("{");
172 for (index, (key, value)) in fields.iter().enumerate() {
173 if index > 0 {
174 canonical.push(',');
175 }
176 canonical.push_str(&serde_json::to_string(key).expect("string key always serializes"));
177 canonical.push(':');
178 canonical.push_str(&serde_json::to_string(value).expect("string value always serializes"));
179 }
180 canonical.push('}');
181
182 Hash32::from_blake3(canonical.as_bytes()).to_string()
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
197 fn dispatch_from_spawned_transitions_to_running() {
198 let outcome = apply_transition(AgentState::Spawned, None, Trigger::Dispatch).unwrap();
199 assert_eq!(outcome.state, AgentState::Running);
200 assert_eq!(outcome.terminal_reason, None);
201 assert!(outcome.changed, "spawned -> running is a real transition");
202 }
203
204 #[test]
207 fn activity_on_running_is_a_no_op() {
208 let outcome = apply_transition(AgentState::Running, None, Trigger::Activity).unwrap();
209 assert_eq!(outcome.state, AgentState::Running);
210 assert!(
211 !outcome.changed,
212 "activity on running must be a no-op, not a transition"
213 );
214 }
215
216 #[test]
219 fn suspend_from_running_transitions_to_suspended() {
220 let outcome = apply_transition(AgentState::Running, None, Trigger::Suspend).unwrap();
221 assert_eq!(outcome.state, AgentState::Suspended);
222 assert!(outcome.changed, "running -> suspended is a real transition");
223 }
224
225 #[test]
226 fn suspend_on_already_suspended_is_a_no_op_not_an_error() {
227 let outcome = apply_transition(AgentState::Suspended, None, Trigger::Suspend).unwrap();
228 assert_eq!(outcome.state, AgentState::Suspended);
229 assert!(!outcome.changed, "suspend on suspended must be a no-op");
230 }
231
232 #[test]
235 fn resume_from_suspended_transitions_to_running() {
236 let outcome = apply_transition(AgentState::Suspended, None, Trigger::Resume).unwrap();
237 assert_eq!(outcome.state, AgentState::Running);
238 assert!(outcome.changed, "suspended -> running is a real transition");
239 }
240
241 #[test]
242 fn resume_on_running_is_a_no_op_not_an_error() {
243 let outcome = apply_transition(AgentState::Running, None, Trigger::Resume).unwrap();
244 assert_eq!(outcome.state, AgentState::Running);
245 assert!(!outcome.changed, "resume on running must be a no-op");
246 }
247
248 #[test]
249 fn resume_on_spawned_is_an_illegal_transition_error() {
250 let err = apply_transition(AgentState::Spawned, None, Trigger::Resume).unwrap_err();
251 assert_eq!(
252 err,
253 IllegalTransition {
254 from: AgentState::Spawned,
255 trigger: Trigger::Resume,
256 }
257 );
258 }
259
260 #[test]
261 fn resume_on_terminal_is_an_illegal_transition_error() {
262 let err = apply_transition(
263 AgentState::Terminal,
264 Some(TerminalReason::Completed),
265 Trigger::Resume,
266 )
267 .unwrap_err();
268 assert_eq!(
269 err,
270 IllegalTransition {
271 from: AgentState::Terminal,
272 trigger: Trigger::Resume,
273 }
274 );
275 }
276
277 #[test]
281 fn kill_from_spawned_transitions_to_terminal_killed() {
282 let outcome = apply_transition(AgentState::Spawned, None, Trigger::Kill).unwrap();
283 assert_eq!(outcome.state, AgentState::Terminal);
284 assert_eq!(outcome.terminal_reason, Some(TerminalReason::Killed));
285 assert!(outcome.changed);
286 }
287
288 #[test]
289 fn kill_from_running_transitions_to_terminal_killed() {
290 let outcome = apply_transition(AgentState::Running, None, Trigger::Kill).unwrap();
291 assert_eq!(outcome.state, AgentState::Terminal);
292 assert_eq!(outcome.terminal_reason, Some(TerminalReason::Killed));
293 assert!(outcome.changed);
294 }
295
296 #[test]
297 fn kill_from_suspended_transitions_to_terminal_killed() {
298 let outcome = apply_transition(AgentState::Suspended, None, Trigger::Kill).unwrap();
299 assert_eq!(outcome.state, AgentState::Terminal);
300 assert_eq!(outcome.terminal_reason, Some(TerminalReason::Killed));
301 assert!(outcome.changed);
302 }
303
304 #[test]
305 fn kill_on_terminal_is_a_no_op_that_returns_current_state_never_an_error() {
306 let outcome = apply_transition(
307 AgentState::Terminal,
308 Some(TerminalReason::Completed),
309 Trigger::Kill,
310 )
311 .expect("kill on terminal must be Ok, never Err");
312 assert_eq!(outcome.state, AgentState::Terminal);
313 assert_eq!(outcome.terminal_reason, Some(TerminalReason::Completed));
314 assert!(!outcome.changed, "kill on terminal must be a no-op");
315 }
316
317 #[test]
320 fn complete_from_running_transitions_to_terminal_completed() {
321 let outcome = apply_transition(AgentState::Running, None, Trigger::Complete).unwrap();
322 assert_eq!(outcome.state, AgentState::Terminal);
323 assert_eq!(outcome.terminal_reason, Some(TerminalReason::Completed));
324 assert!(outcome.changed);
325 }
326
327 #[test]
330 fn fail_from_running_transitions_to_terminal_failed() {
331 let outcome = apply_transition(AgentState::Running, None, Trigger::Fail).unwrap();
332 assert_eq!(outcome.state, AgentState::Terminal);
333 assert_eq!(outcome.terminal_reason, Some(TerminalReason::Failed));
334 assert!(outcome.changed);
335 }
336
337 #[test]
340 fn abandon_from_running_transitions_to_terminal_abandoned() {
341 let outcome = apply_transition(AgentState::Running, None, Trigger::Abandon).unwrap();
342 assert_eq!(outcome.state, AgentState::Terminal);
343 assert_eq!(outcome.terminal_reason, Some(TerminalReason::Abandoned));
344 assert!(outcome.changed);
345 }
346
347 #[test]
350 fn host_restart_from_spawned_transitions_to_terminal_host_restart() {
351 let outcome = apply_transition(AgentState::Spawned, None, Trigger::HostRestart).unwrap();
352 assert_eq!(outcome.state, AgentState::Terminal);
353 assert_eq!(outcome.terminal_reason, Some(TerminalReason::HostRestart));
354 assert!(outcome.changed);
355 }
356
357 #[test]
358 fn host_restart_from_running_transitions_to_terminal_host_restart() {
359 let outcome = apply_transition(AgentState::Running, None, Trigger::HostRestart).unwrap();
360 assert_eq!(outcome.state, AgentState::Terminal);
361 assert_eq!(outcome.terminal_reason, Some(TerminalReason::HostRestart));
362 assert!(outcome.changed);
363 }
364
365 #[test]
366 fn host_restart_from_suspended_transitions_to_terminal_host_restart() {
367 let outcome = apply_transition(AgentState::Suspended, None, Trigger::HostRestart).unwrap();
368 assert_eq!(outcome.state, AgentState::Terminal);
369 assert_eq!(outcome.terminal_reason, Some(TerminalReason::HostRestart));
370 assert!(outcome.changed);
371 }
372
373 #[test]
374 fn host_restart_on_terminal_is_a_no_op_boot_scan_only_touches_non_terminal() {
375 let outcome = apply_transition(
376 AgentState::Terminal,
377 Some(TerminalReason::Failed),
378 Trigger::HostRestart,
379 )
380 .expect("host_restart on an already-terminal record must be Ok");
381 assert_eq!(outcome.state, AgentState::Terminal);
382 assert_eq!(outcome.terminal_reason, Some(TerminalReason::Failed));
383 assert!(!outcome.changed);
384 }
385
386 #[test]
389 fn suspend_on_spawned_is_an_illegal_transition_error() {
390 let err = apply_transition(AgentState::Spawned, None, Trigger::Suspend).unwrap_err();
391 assert_eq!(
392 err,
393 IllegalTransition {
394 from: AgentState::Spawned,
395 trigger: Trigger::Suspend,
396 }
397 );
398 }
399
400 #[test]
401 fn suspend_on_terminal_is_an_illegal_transition_error() {
402 let err = apply_transition(
403 AgentState::Terminal,
404 Some(TerminalReason::Completed),
405 Trigger::Suspend,
406 )
407 .unwrap_err();
408 assert_eq!(
409 err,
410 IllegalTransition {
411 from: AgentState::Terminal,
412 trigger: Trigger::Suspend,
413 }
414 );
415 }
416
417 #[test]
418 fn complete_on_suspended_is_an_illegal_transition_error() {
419 let err = apply_transition(AgentState::Suspended, None, Trigger::Complete).unwrap_err();
420 assert_eq!(
421 err,
422 IllegalTransition {
423 from: AgentState::Suspended,
424 trigger: Trigger::Complete,
425 }
426 );
427 }
428
429 #[test]
432 fn fingerprint_omitting_absent_optional_matches_the_same_call_shape() {
433 let a = spawn_fingerprint("anthropic", "do the thing", None, None);
434 let b = spawn_fingerprint("anthropic", "do the thing", None, None);
435 assert_eq!(a, b, "identical input must digest identically");
436 }
437
438 #[test]
439 fn fingerprint_absent_optional_differs_from_present_optional() {
440 let without = spawn_fingerprint("anthropic", "do the thing", None, None);
441 let with_session = spawn_fingerprint("anthropic", "do the thing", Some("sess-1"), None);
442 assert_ne!(
443 without, with_session,
444 "an omitted optional must not digest the same as a present one"
445 );
446 }
447
448 #[test]
449 fn fingerprint_changing_task_changes_the_digest() {
450 let original = spawn_fingerprint("anthropic", "do the thing", None, None);
451 let changed = spawn_fingerprint("anthropic", "do a different thing", None, None);
452 assert_ne!(original, changed, "changing task must change the digest");
453 }
454
455 #[test]
456 fn fingerprint_is_stable_across_both_optionals_present() {
457 let a = spawn_fingerprint("anthropic", "do the thing", Some("sess-1"), Some("chk-1"));
458 let b = spawn_fingerprint("anthropic", "do the thing", Some("sess-1"), Some("chk-1"));
459 assert_eq!(a, b);
460 }
461
462 #[test]
463 fn fingerprint_field_order_is_not_confused_with_adjacent_field_content() {
464 let first = spawn_fingerprint("a", "bc", None, None);
467 let second = spawn_fingerprint("ab", "c", None, None);
468 assert_ne!(first, second);
469 }
470}