Skip to main content

khive_runtime/
agent_lifecycle.rs

1//! Pure lifecycle logic for the runtime-owned agent process record (ADR-142 §1).
2//!
3//! No database, no I/O: state transitions and the spawn fingerprint are derived
4//! entirely from in-memory values. `AgentState`, `TerminalReason`, and
5//! `AgentRecord` live in `khive_types` (re-exported here) so `khive-db` and
6//! `khive-pack-agent` can share them without depending on `khive-runtime`.
7
8use khive_types::hash::Hash32;
9pub use khive_types::{AgentRecord, AgentState, TerminalReason};
10
11/// The event driving a lifecycle transition attempt. Distinct from the wire-level verbs
12/// (`agent.spawn`/`agent.resume`/`agent.kill`/`agent.suspend`/`agent.observe`): `Dispatch`,
13/// `Activity`, `Complete`, `Fail`, `Abandon`, and `HostRestart` are runtime-internal triggers
14/// that ADR-142's table drives automatically rather than through a caller-issued verb.
15/// `agent.spawn` itself is not a `Trigger` — it creates a record rather than transitioning
16/// an existing one, so it is out of scope for this function (see the module-level tests).
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Trigger {
19    /// The provider begins producing the first turn (`spawned` -> `running`).
20    Dispatch,
21    /// A subsequent round of an already-running turn (`running` -> `running`, a no-op:
22    /// no state change is persisted and `state_changed_at` is not refreshed).
23    Activity,
24    Suspend,
25    Resume,
26    Kill,
27    /// The provider returns its terminal result with no further tool calls pending.
28    Complete,
29    /// An unrecoverable provider or dispatch error.
30    Fail,
31    /// The record's persistent native attachment disconnects without an explicit kill.
32    Abandon,
33    /// Runtime restart boot scan.
34    HostRestart,
35}
36
37/// The outcome of a successful transition attempt, including no-ops.
38///
39/// `changed` distinguishes a genuine state transition from a no-op that returns the
40/// current state unchanged — the no-op rows in ADR-142's table are load-bearing, not
41/// an absence of behavior, so callers must be able to tell the two apart without
42/// re-deriving it from `state`/`terminal_reason` alone.
43#[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/// A trigger that is not legal from the given state. Never raised for the ADR's
51/// explicit no-op rows — those return `Ok(Transition { changed: false, .. })` instead.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct IllegalTransition {
54    pub from: AgentState,
55    pub trigger: Trigger,
56}
57
58/// Apply one lifecycle trigger to a record currently in `from` (with `terminal_reason`
59/// if `from` is already `Terminal`), per the transition table in ADR-142 §1.
60pub 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
146/// Canonical digest of a spawn's compared argument set (ADR-142 §1, "Persistent process
147/// record"): `provider`, `task`, `provider_session_id`, `checkpoint_session_id`, in that
148/// key order, absent optionals omitted entirely rather than written as `null`. `idempotency_key`
149/// is excluded — it is the replay lookup key, not compared content.
150///
151/// The key order is built by hand rather than through a `serde_json::Map`/`Value::Object`
152/// because `serde_json`'s default map type sorts keys alphabetically (BTreeMap) unless the
153/// crate-wide `preserve_order` feature is enabled elsewhere in the dependency graph; hand-
154/// ordering keeps this digest's byte layout independent of that feature flag. Digested with
155/// BLAKE3 (`khive_types::hash::Hash32`), the same content-hash primitive `khive-db` uses for
156/// content-addressed blob refs.
157pub 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    // -- spawn: (none) -> spawned, via agent.spawn --------------------------------------
190    //
191    // Record creation, not a transition of an existing record, so it has no
192    // `apply_transition` case; `spawn_fingerprint` below covers its comparison logic.
193
194    // -- spawned -> running, via dispatch start (automatic) ------------------------------
195
196    #[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    // -- running -> running, subsequent round (no state change) --------------------------
205
206    #[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    // -- running -> suspended, via agent.suspend; suspended -> suspended no-op -----------
217
218    #[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    // -- suspended -> running, via agent.resume; running -> running no-op ----------------
233
234    #[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    // -- {spawned, running, suspended} -> terminal(killed), via agent.kill ---------------
278    // -- terminal -> terminal, kill is a no-op, NEVER an error ---------------------------
279
280    #[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    // -- running -> terminal(completed), provider returns with no pending tool calls -----
318
319    #[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    // -- running -> terminal(failed), unrecoverable provider or dispatch error -----------
328
329    #[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    // -- running -> terminal(abandoned), persistent attachment disconnects ---------------
338
339    #[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    // -- {spawned, running, suspended} -> terminal(host_restart), boot scan --------------
348
349    #[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    // -- transitions the table does not license are illegal-transition errors -----------
387
388    #[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    // -- spawn_fingerprint -----------------------------------------------------------------
430
431    #[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        // provider="a", task="bc" must not collide with provider="ab", task="c" — each
465        // field is length-prefixed by JSON string encoding, not naively concatenated.
466        let first = spawn_fingerprint("a", "bc", None, None);
467        let second = spawn_fingerprint("ab", "c", None, None);
468        assert_ne!(first, second);
469    }
470}