1use serde::{Deserialize, Serialize};
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum AgentLifecycleState {
22 Running,
24 Progressed,
26 AwaitingInput,
28 Suspended,
30 Completed,
32 Failed,
34 Stopped,
36 Cancelled,
38}
39
40#[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 Joined,
57}
58
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct AgentLifecycleProjection {
62 pub wire_name: &'static str,
63 pub terminal: bool,
64 pub resumable: bool,
66 pub a2a_task_state: Option<&'static str>,
68 pub run_record_status: &'static str,
70}
71
72#[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#[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 pub const fn is_resumable(self) -> bool {
142 matches!(self, Self::Suspended | Self::AwaitingInput)
143 }
144
145 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 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 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 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 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 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 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 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 (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 (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 (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, 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}