use khive_types::hash::Hash32;
pub use khive_types::{AgentRecord, AgentState, TerminalReason};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trigger {
Dispatch,
Activity,
Suspend,
Resume,
Kill,
Complete,
Fail,
Abandon,
HostRestart,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Transition {
pub state: AgentState,
pub terminal_reason: Option<TerminalReason>,
pub changed: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IllegalTransition {
pub from: AgentState,
pub trigger: Trigger,
}
pub fn apply_transition(
from: AgentState,
terminal_reason: Option<TerminalReason>,
trigger: Trigger,
) -> Result<Transition, IllegalTransition> {
use AgentState::*;
use Trigger::*;
match (from, trigger) {
(Spawned, Dispatch) => Ok(Transition {
state: Running,
terminal_reason: None,
changed: true,
}),
(Running, Activity) => Ok(Transition {
state: Running,
terminal_reason: None,
changed: false,
}),
(Running, Suspend) => Ok(Transition {
state: Suspended,
terminal_reason: None,
changed: true,
}),
(Suspended, Suspend) => Ok(Transition {
state: Suspended,
terminal_reason: None,
changed: false,
}),
(Suspended, Resume) => Ok(Transition {
state: Running,
terminal_reason: None,
changed: true,
}),
(Running, Resume) => Ok(Transition {
state: Running,
terminal_reason: None,
changed: false,
}),
(Spawned, Kill) | (Running, Kill) | (Suspended, Kill) => Ok(Transition {
state: Terminal,
terminal_reason: Some(TerminalReason::Killed),
changed: true,
}),
(Terminal, Kill) => Ok(Transition {
state: Terminal,
terminal_reason,
changed: false,
}),
(Running, Complete) => Ok(Transition {
state: Terminal,
terminal_reason: Some(TerminalReason::Completed),
changed: true,
}),
(Running, Fail) => Ok(Transition {
state: Terminal,
terminal_reason: Some(TerminalReason::Failed),
changed: true,
}),
(Running, Abandon) => Ok(Transition {
state: Terminal,
terminal_reason: Some(TerminalReason::Abandoned),
changed: true,
}),
(Spawned, HostRestart) | (Running, HostRestart) | (Suspended, HostRestart) => {
Ok(Transition {
state: Terminal,
terminal_reason: Some(TerminalReason::HostRestart),
changed: true,
})
}
(Terminal, HostRestart) => Ok(Transition {
state: Terminal,
terminal_reason,
changed: false,
}),
_ => Err(IllegalTransition { from, trigger }),
}
}
pub fn spawn_fingerprint(
provider: &str,
task: &str,
provider_session_id: Option<&str>,
checkpoint_session_id: Option<&str>,
) -> String {
let mut fields: Vec<(&str, &str)> = vec![("provider", provider), ("task", task)];
if let Some(value) = provider_session_id {
fields.push(("provider_session_id", value));
}
if let Some(value) = checkpoint_session_id {
fields.push(("checkpoint_session_id", value));
}
let mut canonical = String::from("{");
for (index, (key, value)) in fields.iter().enumerate() {
if index > 0 {
canonical.push(',');
}
canonical.push_str(&serde_json::to_string(key).expect("string key always serializes"));
canonical.push(':');
canonical.push_str(&serde_json::to_string(value).expect("string value always serializes"));
}
canonical.push('}');
Hash32::from_blake3(canonical.as_bytes()).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dispatch_from_spawned_transitions_to_running() {
let outcome = apply_transition(AgentState::Spawned, None, Trigger::Dispatch).unwrap();
assert_eq!(outcome.state, AgentState::Running);
assert_eq!(outcome.terminal_reason, None);
assert!(outcome.changed, "spawned -> running is a real transition");
}
#[test]
fn activity_on_running_is_a_no_op() {
let outcome = apply_transition(AgentState::Running, None, Trigger::Activity).unwrap();
assert_eq!(outcome.state, AgentState::Running);
assert!(
!outcome.changed,
"activity on running must be a no-op, not a transition"
);
}
#[test]
fn suspend_from_running_transitions_to_suspended() {
let outcome = apply_transition(AgentState::Running, None, Trigger::Suspend).unwrap();
assert_eq!(outcome.state, AgentState::Suspended);
assert!(outcome.changed, "running -> suspended is a real transition");
}
#[test]
fn suspend_on_already_suspended_is_a_no_op_not_an_error() {
let outcome = apply_transition(AgentState::Suspended, None, Trigger::Suspend).unwrap();
assert_eq!(outcome.state, AgentState::Suspended);
assert!(!outcome.changed, "suspend on suspended must be a no-op");
}
#[test]
fn resume_from_suspended_transitions_to_running() {
let outcome = apply_transition(AgentState::Suspended, None, Trigger::Resume).unwrap();
assert_eq!(outcome.state, AgentState::Running);
assert!(outcome.changed, "suspended -> running is a real transition");
}
#[test]
fn resume_on_running_is_a_no_op_not_an_error() {
let outcome = apply_transition(AgentState::Running, None, Trigger::Resume).unwrap();
assert_eq!(outcome.state, AgentState::Running);
assert!(!outcome.changed, "resume on running must be a no-op");
}
#[test]
fn resume_on_spawned_is_an_illegal_transition_error() {
let err = apply_transition(AgentState::Spawned, None, Trigger::Resume).unwrap_err();
assert_eq!(
err,
IllegalTransition {
from: AgentState::Spawned,
trigger: Trigger::Resume,
}
);
}
#[test]
fn resume_on_terminal_is_an_illegal_transition_error() {
let err = apply_transition(
AgentState::Terminal,
Some(TerminalReason::Completed),
Trigger::Resume,
)
.unwrap_err();
assert_eq!(
err,
IllegalTransition {
from: AgentState::Terminal,
trigger: Trigger::Resume,
}
);
}
#[test]
fn kill_from_spawned_transitions_to_terminal_killed() {
let outcome = apply_transition(AgentState::Spawned, None, Trigger::Kill).unwrap();
assert_eq!(outcome.state, AgentState::Terminal);
assert_eq!(outcome.terminal_reason, Some(TerminalReason::Killed));
assert!(outcome.changed);
}
#[test]
fn kill_from_running_transitions_to_terminal_killed() {
let outcome = apply_transition(AgentState::Running, None, Trigger::Kill).unwrap();
assert_eq!(outcome.state, AgentState::Terminal);
assert_eq!(outcome.terminal_reason, Some(TerminalReason::Killed));
assert!(outcome.changed);
}
#[test]
fn kill_from_suspended_transitions_to_terminal_killed() {
let outcome = apply_transition(AgentState::Suspended, None, Trigger::Kill).unwrap();
assert_eq!(outcome.state, AgentState::Terminal);
assert_eq!(outcome.terminal_reason, Some(TerminalReason::Killed));
assert!(outcome.changed);
}
#[test]
fn kill_on_terminal_is_a_no_op_that_returns_current_state_never_an_error() {
let outcome = apply_transition(
AgentState::Terminal,
Some(TerminalReason::Completed),
Trigger::Kill,
)
.expect("kill on terminal must be Ok, never Err");
assert_eq!(outcome.state, AgentState::Terminal);
assert_eq!(outcome.terminal_reason, Some(TerminalReason::Completed));
assert!(!outcome.changed, "kill on terminal must be a no-op");
}
#[test]
fn complete_from_running_transitions_to_terminal_completed() {
let outcome = apply_transition(AgentState::Running, None, Trigger::Complete).unwrap();
assert_eq!(outcome.state, AgentState::Terminal);
assert_eq!(outcome.terminal_reason, Some(TerminalReason::Completed));
assert!(outcome.changed);
}
#[test]
fn fail_from_running_transitions_to_terminal_failed() {
let outcome = apply_transition(AgentState::Running, None, Trigger::Fail).unwrap();
assert_eq!(outcome.state, AgentState::Terminal);
assert_eq!(outcome.terminal_reason, Some(TerminalReason::Failed));
assert!(outcome.changed);
}
#[test]
fn abandon_from_running_transitions_to_terminal_abandoned() {
let outcome = apply_transition(AgentState::Running, None, Trigger::Abandon).unwrap();
assert_eq!(outcome.state, AgentState::Terminal);
assert_eq!(outcome.terminal_reason, Some(TerminalReason::Abandoned));
assert!(outcome.changed);
}
#[test]
fn host_restart_from_spawned_transitions_to_terminal_host_restart() {
let outcome = apply_transition(AgentState::Spawned, None, Trigger::HostRestart).unwrap();
assert_eq!(outcome.state, AgentState::Terminal);
assert_eq!(outcome.terminal_reason, Some(TerminalReason::HostRestart));
assert!(outcome.changed);
}
#[test]
fn host_restart_from_running_transitions_to_terminal_host_restart() {
let outcome = apply_transition(AgentState::Running, None, Trigger::HostRestart).unwrap();
assert_eq!(outcome.state, AgentState::Terminal);
assert_eq!(outcome.terminal_reason, Some(TerminalReason::HostRestart));
assert!(outcome.changed);
}
#[test]
fn host_restart_from_suspended_transitions_to_terminal_host_restart() {
let outcome = apply_transition(AgentState::Suspended, None, Trigger::HostRestart).unwrap();
assert_eq!(outcome.state, AgentState::Terminal);
assert_eq!(outcome.terminal_reason, Some(TerminalReason::HostRestart));
assert!(outcome.changed);
}
#[test]
fn host_restart_on_terminal_is_a_no_op_boot_scan_only_touches_non_terminal() {
let outcome = apply_transition(
AgentState::Terminal,
Some(TerminalReason::Failed),
Trigger::HostRestart,
)
.expect("host_restart on an already-terminal record must be Ok");
assert_eq!(outcome.state, AgentState::Terminal);
assert_eq!(outcome.terminal_reason, Some(TerminalReason::Failed));
assert!(!outcome.changed);
}
#[test]
fn suspend_on_spawned_is_an_illegal_transition_error() {
let err = apply_transition(AgentState::Spawned, None, Trigger::Suspend).unwrap_err();
assert_eq!(
err,
IllegalTransition {
from: AgentState::Spawned,
trigger: Trigger::Suspend,
}
);
}
#[test]
fn suspend_on_terminal_is_an_illegal_transition_error() {
let err = apply_transition(
AgentState::Terminal,
Some(TerminalReason::Completed),
Trigger::Suspend,
)
.unwrap_err();
assert_eq!(
err,
IllegalTransition {
from: AgentState::Terminal,
trigger: Trigger::Suspend,
}
);
}
#[test]
fn complete_on_suspended_is_an_illegal_transition_error() {
let err = apply_transition(AgentState::Suspended, None, Trigger::Complete).unwrap_err();
assert_eq!(
err,
IllegalTransition {
from: AgentState::Suspended,
trigger: Trigger::Complete,
}
);
}
#[test]
fn fingerprint_omitting_absent_optional_matches_the_same_call_shape() {
let a = spawn_fingerprint("anthropic", "do the thing", None, None);
let b = spawn_fingerprint("anthropic", "do the thing", None, None);
assert_eq!(a, b, "identical input must digest identically");
}
#[test]
fn fingerprint_absent_optional_differs_from_present_optional() {
let without = spawn_fingerprint("anthropic", "do the thing", None, None);
let with_session = spawn_fingerprint("anthropic", "do the thing", Some("sess-1"), None);
assert_ne!(
without, with_session,
"an omitted optional must not digest the same as a present one"
);
}
#[test]
fn fingerprint_changing_task_changes_the_digest() {
let original = spawn_fingerprint("anthropic", "do the thing", None, None);
let changed = spawn_fingerprint("anthropic", "do a different thing", None, None);
assert_ne!(original, changed, "changing task must change the digest");
}
#[test]
fn fingerprint_is_stable_across_both_optionals_present() {
let a = spawn_fingerprint("anthropic", "do the thing", Some("sess-1"), Some("chk-1"));
let b = spawn_fingerprint("anthropic", "do the thing", Some("sess-1"), Some("chk-1"));
assert_eq!(a, b);
}
#[test]
fn fingerprint_field_order_is_not_confused_with_adjacent_field_content() {
let first = spawn_fingerprint("a", "bc", None, None);
let second = spawn_fingerprint("ab", "c", None, None);
assert_ne!(first, second);
}
}