Skip to main content

harn_vm/agent_events/
lifecycle.rs

1//! Shared agent/run lifecycle registry (harn#6049).
2//!
3//! One compile-time owner for the coarse agent/run/worker status vocabulary
4//! that crosses runtime reducers, session persistence, replay, ACP, A2A, and
5//! protocol artifacts. Tool-call, task-list, provider-job, session-retention,
6//! and host-lease states stay in their own owners — this module is only the
7//! shared agent/run meaning.
8//!
9//! Projections:
10//! - `WorkerEvent` maps 1:1 onto [`AgentLifecycleEvent`] (minus join);
11//! - protocol dumps enumerate [`AgentLifecycleState::ALL`];
12//! - adapters may map overlapping A2A task states through
13//!   [`AgentLifecycleState::a2a_task_state`].
14
15use serde::{Deserialize, Serialize};
16
17/// Coarse agent/run lifecycle state. Wire names are stable; aliases parse
18/// into these variants without becoming new canonical states.
19#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum AgentLifecycleState {
22    /// Active execution (spawned or resumed).
23    Running,
24    /// Non-terminal milestone while still active.
25    Progressed,
26    /// Retriggerable park awaiting the next host trigger payload.
27    AwaitingInput,
28    /// Cooperative mid-loop park; resumable.
29    Suspended,
30    /// Natural successful completion.
31    Completed,
32    /// Terminal failure.
33    Failed,
34    /// Graceful stop with typed handoff.
35    Stopped,
36    /// Hard cancel / abort.
37    Cancelled,
38}
39
40/// Lifecycle event that advances [`AgentLifecycle`].
41///
42/// Worker bridge events are a strict subset; [`Self::Joined`] records that a
43/// parent observed a terminal delegated worker and sealed join evidence.
44#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
45pub enum AgentLifecycleEvent {
46    Spawned,
47    Progressed,
48    WaitingForInput,
49    Suspended,
50    Resumed,
51    Completed,
52    Failed,
53    Stopped,
54    Cancelled,
55    /// Parent recorded join boundaries against an already-terminal child.
56    Joined,
57}
58
59/// Projection metadata published for schemas and docs.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct AgentLifecycleProjection {
62    pub wire_name: &'static str,
63    pub terminal: bool,
64    /// Cooperative park that may later resume without starting a new run.
65    pub resumable: bool,
66    /// Overlapping A2A `TaskState` wire value, when one exists.
67    pub a2a_task_state: Option<&'static str>,
68    /// Run-record / report status projection (same as wire for this vocabulary).
69    pub run_record_status: &'static str,
70}
71
72/// Why a lifecycle transition was rejected.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct LifecycleTransitionError {
75    pub from: Option<AgentLifecycleState>,
76    pub event: AgentLifecycleEvent,
77    pub reason: &'static str,
78}
79
80impl std::fmt::Display for LifecycleTransitionError {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self.from {
83            Some(from) => write!(
84                f,
85                "invalid agent lifecycle transition from {} via {:?}: {}",
86                from.wire_name(),
87                self.event,
88                self.reason
89            ),
90            None => write!(
91                f,
92                "invalid agent lifecycle transition from <unstarted> via {:?}: {}",
93                self.event, self.reason
94            ),
95        }
96    }
97}
98
99impl std::error::Error for LifecycleTransitionError {}
100
101/// Reducer state for one agent/run/worker lifecycle.
102#[derive(Clone, Debug, Default, Eq, PartialEq)]
103pub struct AgentLifecycle {
104    state: Option<AgentLifecycleState>,
105    joined: bool,
106}
107
108impl AgentLifecycleState {
109    pub const ALL: [Self; 8] = [
110        Self::Running,
111        Self::Progressed,
112        Self::AwaitingInput,
113        Self::Suspended,
114        Self::Completed,
115        Self::Failed,
116        Self::Stopped,
117        Self::Cancelled,
118    ];
119
120    pub const fn wire_name(self) -> &'static str {
121        match self {
122            Self::Running => "running",
123            Self::Progressed => "progressed",
124            Self::AwaitingInput => "awaiting_input",
125            Self::Suspended => "suspended",
126            Self::Completed => "completed",
127            Self::Failed => "failed",
128            Self::Stopped => "stopped",
129            Self::Cancelled => "cancelled",
130        }
131    }
132
133    pub const fn is_terminal(self) -> bool {
134        matches!(
135            self,
136            Self::Completed | Self::Failed | Self::Stopped | Self::Cancelled
137        )
138    }
139
140    /// Suspended work may resume; awaiting-input is also resumable by trigger.
141    pub const fn is_resumable(self) -> bool {
142        matches!(self, Self::Suspended | Self::AwaitingInput)
143    }
144
145    /// Explicit compatibility aliases accepted by [`Self::from_wire`].
146    /// These never appear in [`Self::ALL`] wire projections.
147    pub const fn aliases(self) -> &'static [&'static str] {
148        match self {
149            Self::AwaitingInput => &["awaiting"],
150            Self::Completed => &["done", "succeeded", "success", "ok"],
151            Self::Failed => &["error", "errored", "timeout", "timed_out"],
152            Self::Cancelled => &["canceled", "aborted"],
153            Self::Running | Self::Progressed | Self::Suspended | Self::Stopped => &[],
154        }
155    }
156
157    pub const fn projection(self) -> AgentLifecycleProjection {
158        AgentLifecycleProjection {
159            wire_name: self.wire_name(),
160            terminal: self.is_terminal(),
161            resumable: self.is_resumable(),
162            a2a_task_state: self.a2a_task_state(),
163            run_record_status: self.wire_name(),
164        }
165    }
166
167    /// Overlapping A2A task-state projection. A2A-only states
168    /// (`submitted`, `auth-required`, `rejected`) stay adapter-local.
169    pub const fn a2a_task_state(self) -> Option<&'static str> {
170        match self {
171            Self::Running | Self::Progressed => Some("working"),
172            Self::AwaitingInput => Some("input-required"),
173            // Protocol contribution: docs/src/protocol-contributions/a2a-paused-state.md
174            Self::Suspended => Some("paused"),
175            Self::Completed => Some("completed"),
176            Self::Failed => Some("failed"),
177            Self::Cancelled | Self::Stopped => Some("cancelled"),
178        }
179    }
180
181    pub fn from_wire(status: &str) -> Option<Self> {
182        let trimmed = status.trim();
183        for &state in &Self::ALL {
184            if state.wire_name() == trimmed {
185                return Some(state);
186            }
187            if state.aliases().contains(&trimmed) {
188                return Some(state);
189            }
190        }
191        None
192    }
193
194    pub fn status_is_terminal(status: &str) -> bool {
195        Self::from_wire(status).is_some_and(Self::is_terminal)
196    }
197
198    /// Normalize a status string to the canonical wire name when recognized.
199    pub fn canonicalize(status: &str) -> Option<&'static str> {
200        Self::from_wire(status).map(Self::wire_name)
201    }
202}
203
204impl AgentLifecycleEvent {
205    pub const ALL: [Self; 10] = [
206        Self::Spawned,
207        Self::Progressed,
208        Self::WaitingForInput,
209        Self::Suspended,
210        Self::Resumed,
211        Self::Completed,
212        Self::Failed,
213        Self::Stopped,
214        Self::Cancelled,
215        Self::Joined,
216    ];
217
218    pub const fn as_str(self) -> &'static str {
219        match self {
220            Self::Spawned => "Spawned",
221            Self::Progressed => "Progressed",
222            Self::WaitingForInput => "WaitingForInput",
223            Self::Suspended => "Suspended",
224            Self::Resumed => "Resumed",
225            Self::Completed => "Completed",
226            Self::Failed => "Failed",
227            Self::Stopped => "Stopped",
228            Self::Cancelled => "Cancelled",
229            Self::Joined => "Joined",
230        }
231    }
232
233    /// State this event installs when the transition is accepted.
234    /// [`Self::Joined`] does not change status — only the join flag.
235    pub const fn target_state(self) -> Option<AgentLifecycleState> {
236        match self {
237            Self::Spawned | Self::Resumed => Some(AgentLifecycleState::Running),
238            Self::Progressed => Some(AgentLifecycleState::Progressed),
239            Self::WaitingForInput => Some(AgentLifecycleState::AwaitingInput),
240            Self::Suspended => Some(AgentLifecycleState::Suspended),
241            Self::Completed => Some(AgentLifecycleState::Completed),
242            Self::Failed => Some(AgentLifecycleState::Failed),
243            Self::Stopped => Some(AgentLifecycleState::Stopped),
244            Self::Cancelled => Some(AgentLifecycleState::Cancelled),
245            Self::Joined => None,
246        }
247    }
248}
249
250impl AgentLifecycle {
251    pub const fn new() -> Self {
252        Self {
253            state: None,
254            joined: false,
255        }
256    }
257
258    pub const fn state(&self) -> Option<AgentLifecycleState> {
259        self.state
260    }
261
262    pub const fn joined(&self) -> bool {
263        self.joined
264    }
265
266    pub const fn is_terminal(&self) -> bool {
267        matches!(self.state, Some(state) if state.is_terminal())
268    }
269
270    /// Apply one lifecycle event, enforcing the shared transition table.
271    pub fn apply(&mut self, event: AgentLifecycleEvent) -> Result<(), LifecycleTransitionError> {
272        match event {
273            AgentLifecycleEvent::Joined => self.apply_join(),
274            _ => self.apply_status_event(event),
275        }
276    }
277
278    /// Replay an event sequence deterministically. Same inputs always yield
279    /// the same terminal state or the same rejection.
280    pub fn replay(
281        events: impl IntoIterator<Item = AgentLifecycleEvent>,
282    ) -> Result<Self, LifecycleTransitionError> {
283        let mut life = Self::new();
284        for event in events {
285            life.apply(event)?;
286        }
287        Ok(life)
288    }
289
290    fn apply_join(&mut self) -> Result<(), LifecycleTransitionError> {
291        match self.state {
292            Some(state) if state.is_terminal() => {
293                self.joined = true;
294                Ok(())
295            }
296            from => Err(LifecycleTransitionError {
297                from,
298                event: AgentLifecycleEvent::Joined,
299                reason: "join requires a terminal lifecycle state",
300            }),
301        }
302    }
303
304    fn apply_status_event(
305        &mut self,
306        event: AgentLifecycleEvent,
307    ) -> Result<(), LifecycleTransitionError> {
308        let target = event.target_state().expect("status events have targets");
309        match self.state {
310            None => {
311                if matches!(event, AgentLifecycleEvent::Spawned) {
312                    self.state = Some(AgentLifecycleState::Running);
313                    Ok(())
314                } else {
315                    Err(LifecycleTransitionError {
316                        from: None,
317                        event,
318                        reason: "unstarted lifecycle accepts only Spawned",
319                    })
320                }
321            }
322            Some(current) if current.is_terminal() => {
323                if self.joined {
324                    return Err(LifecycleTransitionError {
325                        from: Some(current),
326                        event,
327                        reason: "joined terminal lifecycle rejects further status events",
328                    });
329                }
330                if current == target {
331                    // Duplicate terminal of the same kind is idempotent.
332                    Ok(())
333                } else if target.is_terminal() {
334                    Err(LifecycleTransitionError {
335                        from: Some(current),
336                        event,
337                        reason: "conflicting terminal event",
338                    })
339                } else {
340                    Err(LifecycleTransitionError {
341                        from: Some(current),
342                        event,
343                        reason: "terminal lifecycle rejects non-terminal events",
344                    })
345                }
346            }
347            Some(current) => {
348                if !may_transition(current, event) {
349                    return Err(LifecycleTransitionError {
350                        from: Some(current),
351                        event,
352                        reason: "transition not permitted",
353                    });
354                }
355                self.state = Some(target);
356                Ok(())
357            }
358        }
359    }
360}
361
362fn may_transition(from: AgentLifecycleState, event: AgentLifecycleEvent) -> bool {
363    use AgentLifecycleEvent as E;
364    use AgentLifecycleState as S;
365    match (from, event) {
366        // Active / progressed share the same outbound edges.
367        (S::Running | S::Progressed, E::Spawned | E::Resumed | E::Progressed) => true,
368        (S::Running | S::Progressed, E::WaitingForInput | E::Suspended) => true,
369        (S::Running | S::Progressed, E::Completed | E::Failed | E::Stopped | E::Cancelled) => true,
370
371        // Retriggerable park.
372        (S::AwaitingInput, E::WaitingForInput) => true,
373        (S::AwaitingInput, E::Progressed | E::Resumed | E::Spawned) => true,
374        (S::AwaitingInput, E::Suspended) => true,
375        (S::AwaitingInput, E::Completed | E::Failed | E::Stopped | E::Cancelled) => true,
376
377        // Cooperative suspend: idempotent park, resume, or abandon.
378        (S::Suspended, E::Suspended) => true,
379        (S::Suspended, E::Resumed | E::Spawned) => true,
380        (S::Suspended, E::Failed | E::Stopped | E::Cancelled) => true,
381
382        _ => false,
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    fn wire_names() -> Vec<&'static str> {
391        AgentLifecycleState::ALL
392            .iter()
393            .map(|state| state.wire_name())
394            .collect()
395    }
396
397    #[test]
398    fn canonical_wire_names_are_unique_and_stable() {
399        let names = wire_names();
400        assert_eq!(
401            names,
402            vec![
403                "running",
404                "progressed",
405                "awaiting_input",
406                "suspended",
407                "completed",
408                "failed",
409                "stopped",
410                "cancelled",
411            ]
412        );
413        let mut sorted = names.clone();
414        sorted.sort_unstable();
415        sorted.dedup();
416        assert_eq!(sorted.len(), names.len());
417    }
418
419    #[test]
420    fn aliases_parse_without_becoming_canonical_states() {
421        assert_eq!(
422            AgentLifecycleState::from_wire("awaiting"),
423            Some(AgentLifecycleState::AwaitingInput)
424        );
425        assert_eq!(
426            AgentLifecycleState::from_wire("canceled"),
427            Some(AgentLifecycleState::Cancelled)
428        );
429        assert_eq!(
430            AgentLifecycleState::from_wire("done"),
431            Some(AgentLifecycleState::Completed)
432        );
433        assert_eq!(
434            AgentLifecycleState::canonicalize("awaiting"),
435            Some("awaiting_input")
436        );
437        assert_eq!(
438            AgentLifecycleState::canonicalize("aborted"),
439            Some("cancelled")
440        );
441        for &state in &AgentLifecycleState::ALL {
442            for alias in state.aliases() {
443                assert!(
444                    !AgentLifecycleState::ALL
445                        .iter()
446                        .any(|canonical| canonical.wire_name() == *alias),
447                    "alias `{alias}` must not be a canonical wire name"
448                );
449            }
450        }
451    }
452
453    #[test]
454    fn terminal_and_resumable_classification() {
455        for state in [
456            AgentLifecycleState::Completed,
457            AgentLifecycleState::Failed,
458            AgentLifecycleState::Stopped,
459            AgentLifecycleState::Cancelled,
460        ] {
461            assert!(state.is_terminal());
462            assert!(!state.is_resumable());
463        }
464        assert!(AgentLifecycleState::Suspended.is_resumable());
465        assert!(AgentLifecycleState::AwaitingInput.is_resumable());
466        assert!(!AgentLifecycleState::Running.is_terminal());
467        assert!(!AgentLifecycleState::Progressed.is_resumable());
468    }
469
470    #[test]
471    fn normal_completion_path() {
472        let life = AgentLifecycle::replay([
473            AgentLifecycleEvent::Spawned,
474            AgentLifecycleEvent::Progressed,
475            AgentLifecycleEvent::Completed,
476        ])
477        .expect("completion path");
478        assert_eq!(life.state(), Some(AgentLifecycleState::Completed));
479        assert!(!life.joined());
480    }
481
482    #[test]
483    fn failure_and_cancellation_paths() {
484        let failed =
485            AgentLifecycle::replay([AgentLifecycleEvent::Spawned, AgentLifecycleEvent::Failed])
486                .unwrap();
487        assert_eq!(failed.state(), Some(AgentLifecycleState::Failed));
488
489        let cancelled =
490            AgentLifecycle::replay([AgentLifecycleEvent::Spawned, AgentLifecycleEvent::Cancelled])
491                .unwrap();
492        assert_eq!(cancelled.state(), Some(AgentLifecycleState::Cancelled));
493    }
494
495    #[test]
496    fn suspend_resume_round_trip() {
497        let life = AgentLifecycle::replay([
498            AgentLifecycleEvent::Spawned,
499            AgentLifecycleEvent::Suspended,
500            AgentLifecycleEvent::Suspended, // idempotent
501            AgentLifecycleEvent::Resumed,
502            AgentLifecycleEvent::Completed,
503        ])
504        .unwrap();
505        assert_eq!(life.state(), Some(AgentLifecycleState::Completed));
506    }
507
508    #[test]
509    fn duplicate_terminal_is_idempotent_conflict_is_rejected() {
510        let mut life =
511            AgentLifecycle::replay([AgentLifecycleEvent::Spawned, AgentLifecycleEvent::Completed])
512                .unwrap();
513        life.apply(AgentLifecycleEvent::Completed)
514            .expect("duplicate completed");
515        let err = life
516            .apply(AgentLifecycleEvent::Failed)
517            .expect_err("conflicting terminal");
518        assert_eq!(err.reason, "conflicting terminal event");
519
520        let mut life =
521            AgentLifecycle::replay([AgentLifecycleEvent::Spawned, AgentLifecycleEvent::Completed])
522                .unwrap();
523        let err = life
524            .apply(AgentLifecycleEvent::Progressed)
525            .expect_err("out-of-order non-terminal");
526        assert_eq!(err.reason, "terminal lifecycle rejects non-terminal events");
527    }
528
529    #[test]
530    fn delegated_worker_join_requires_terminal_and_is_idempotent() {
531        let err =
532            AgentLifecycle::replay([AgentLifecycleEvent::Spawned, AgentLifecycleEvent::Joined])
533                .expect_err("join before terminal");
534        assert_eq!(err.reason, "join requires a terminal lifecycle state");
535
536        let mut life = AgentLifecycle::replay([
537            AgentLifecycleEvent::Spawned,
538            AgentLifecycleEvent::Completed,
539            AgentLifecycleEvent::Joined,
540        ])
541        .unwrap();
542        assert!(life.joined());
543        life.apply(AgentLifecycleEvent::Joined)
544            .expect("duplicate join");
545        assert!(life.joined());
546        let err = life
547            .apply(AgentLifecycleEvent::Completed)
548            .expect_err("status after join");
549        assert_eq!(
550            err.reason,
551            "joined terminal lifecycle rejects further status events"
552        );
553    }
554
555    #[test]
556    fn replay_is_deterministic() {
557        let events = [
558            AgentLifecycleEvent::Spawned,
559            AgentLifecycleEvent::WaitingForInput,
560            AgentLifecycleEvent::Progressed,
561            AgentLifecycleEvent::Suspended,
562            AgentLifecycleEvent::Resumed,
563            AgentLifecycleEvent::Stopped,
564            AgentLifecycleEvent::Joined,
565        ];
566        let a = AgentLifecycle::replay(events).unwrap();
567        let b = AgentLifecycle::replay(events).unwrap();
568        assert_eq!(a, b);
569        assert_eq!(a.state(), Some(AgentLifecycleState::Stopped));
570        assert!(a.joined());
571
572        let invalid = [
573            AgentLifecycleEvent::Spawned,
574            AgentLifecycleEvent::Completed,
575            AgentLifecycleEvent::Cancelled,
576        ];
577        let err_a = AgentLifecycle::replay(invalid).unwrap_err();
578        let err_b = AgentLifecycle::replay(invalid).unwrap_err();
579        assert_eq!(err_a, err_b);
580    }
581
582    #[test]
583    fn projections_expose_protocol_metadata() {
584        let suspended = AgentLifecycleState::Suspended.projection();
585        assert!(suspended.resumable);
586        assert!(!suspended.terminal);
587        assert_eq!(suspended.a2a_task_state, Some("paused"));
588        assert_eq!(suspended.run_record_status, "suspended");
589
590        let cancelled = AgentLifecycleState::Cancelled.projection();
591        assert!(cancelled.terminal);
592        assert_eq!(cancelled.a2a_task_state, Some("cancelled"));
593    }
594}