Skip to main content

tirea_contract/runtime/run/
lifecycle.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use tirea_state::State;
4
5/// Generic stopped payload emitted when a plugin decides to terminate.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub struct StoppedReason {
8    pub code: String,
9    #[serde(skip_serializing_if = "Option::is_none")]
10    pub detail: Option<String>,
11}
12
13impl StoppedReason {
14    #[must_use]
15    pub fn new(code: impl Into<String>) -> Self {
16        Self {
17            code: code.into(),
18            detail: None,
19        }
20    }
21
22    #[must_use]
23    pub fn with_detail(code: impl Into<String>, detail: impl Into<String>) -> Self {
24        Self {
25            code: code.into(),
26            detail: Some(detail.into()),
27        }
28    }
29}
30
31/// Why a run terminated.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(tag = "type", content = "value", rename_all = "snake_case")]
34pub enum TerminationReason {
35    /// LLM returned a response with no tool calls.
36    NaturalEnd,
37    /// A behavior requested inference skip.
38    #[serde(alias = "plugin_requested")]
39    BehaviorRequested,
40    /// A configured stop condition fired.
41    Stopped(StoppedReason),
42    /// External run cancellation signal was received.
43    Cancelled,
44    /// Run paused waiting for external suspended tool-call resolution.
45    Suspended,
46    /// Run ended due to an error path.
47    Error(String),
48}
49
50impl TerminationReason {
51    #[must_use]
52    pub fn stopped(code: impl Into<String>) -> Self {
53        Self::Stopped(StoppedReason::new(code))
54    }
55
56    #[must_use]
57    pub fn stopped_with_detail(code: impl Into<String>, detail: impl Into<String>) -> Self {
58        Self::Stopped(StoppedReason::with_detail(code, detail))
59    }
60
61    /// Map termination reason to durable run status and optional done_reason string.
62    pub fn to_run_status(&self) -> (RunStatus, Option<String>) {
63        match self {
64            Self::Suspended => (RunStatus::Waiting, None),
65            Self::NaturalEnd => (RunStatus::Done, Some("natural".to_string())),
66            Self::BehaviorRequested => (RunStatus::Done, Some("behavior_requested".to_string())),
67            Self::Cancelled => (RunStatus::Done, Some("cancelled".to_string())),
68            Self::Error(_) => (RunStatus::Done, Some("error".to_string())),
69            Self::Stopped(stopped) => (RunStatus::Done, Some(format!("stopped:{}", stopped.code))),
70        }
71    }
72}
73
74/// Coarse run lifecycle status persisted in thread state.
75#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "snake_case")]
77pub enum RunStatus {
78    /// Run is actively executing.
79    #[default]
80    Running,
81    /// Run is waiting for external decisions.
82    Waiting,
83    /// Run has reached a terminal state.
84    Done,
85}
86
87impl RunStatus {
88    /// Canonical run-lifecycle state machine used by runtime tests.
89    pub const ASCII_STATE_MACHINE: &str = r#"start
90  |
91  v
92running -------> done
93  |
94  v
95waiting -------> done
96  |
97  +-----------> running"#;
98
99    /// Whether this lifecycle status is terminal.
100    pub fn is_terminal(self) -> bool {
101        matches!(self, RunStatus::Done)
102    }
103
104    /// Validate lifecycle transition from `self` to `next`.
105    pub fn can_transition_to(self, next: Self) -> bool {
106        if self == next {
107            return true;
108        }
109
110        match self {
111            RunStatus::Running => {
112                matches!(next, RunStatus::Waiting | RunStatus::Done)
113            }
114            RunStatus::Waiting => {
115                matches!(next, RunStatus::Running | RunStatus::Done)
116            }
117            RunStatus::Done => false,
118        }
119    }
120}
121
122/// Minimal durable run lifecycle envelope stored at `state["__run"]`.
123#[derive(Debug, Clone, Default, Serialize, Deserialize, State, PartialEq, Eq)]
124#[tirea(path = "__run", action = "RunLifecycleAction", scope = "run")]
125pub struct RunLifecycleState {
126    /// Current run id associated with this lifecycle record.
127    #[serde(default)]
128    pub id: String,
129    /// Coarse lifecycle status.
130    #[serde(default)]
131    pub status: RunStatus,
132    /// Optional terminal reason when `status=done`.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub done_reason: Option<String>,
135    /// Last update timestamp (unix millis).
136    #[serde(default)]
137    pub updated_at: u64,
138}
139
140/// Action type for [`RunLifecycleState`] reducer.
141#[derive(Serialize, Deserialize)]
142pub enum RunLifecycleAction {
143    /// Set the entire run lifecycle envelope in one reducer step.
144    Set {
145        id: String,
146        status: RunStatus,
147        done_reason: Option<String>,
148        updated_at: u64,
149    },
150}
151
152impl RunLifecycleState {
153    fn reduce(&mut self, action: RunLifecycleAction) {
154        match action {
155            RunLifecycleAction::Set {
156                id,
157                status,
158                done_reason,
159                updated_at,
160            } => {
161                self.id = id;
162                self.status = status;
163                self.done_reason = done_reason;
164                self.updated_at = updated_at;
165            }
166        }
167    }
168}
169
170/// Parse persisted run lifecycle from a rebuilt state snapshot.
171pub fn run_lifecycle_from_state(state: &Value) -> Option<RunLifecycleState> {
172    state
173        .get(RunLifecycleState::PATH)
174        .and_then(|v| RunLifecycleState::from_value(v).ok())
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::runtime::state::{reduce_state_actions, AnyStateAction, ScopeContext};
181    use tirea_state::apply_patch;
182
183    #[test]
184    fn run_lifecycle_roundtrip_from_state() {
185        let state = serde_json::json!({
186            "__run": {
187                "id": "run_1",
188                "status": "running",
189                "updated_at": 42
190            }
191        });
192
193        let lifecycle = run_lifecycle_from_state(&state).expect("run lifecycle");
194        assert_eq!(lifecycle.id, "run_1");
195        assert_eq!(lifecycle.status, RunStatus::Running);
196        assert_eq!(lifecycle.done_reason, None);
197        assert_eq!(lifecycle.updated_at, 42);
198    }
199
200    #[test]
201    fn run_lifecycle_status_transitions_match_state_machine() {
202        assert!(RunStatus::Running.can_transition_to(RunStatus::Waiting));
203        assert!(RunStatus::Running.can_transition_to(RunStatus::Done));
204        assert!(RunStatus::Waiting.can_transition_to(RunStatus::Running));
205        assert!(RunStatus::Waiting.can_transition_to(RunStatus::Done));
206        assert!(RunStatus::Running.can_transition_to(RunStatus::Running));
207    }
208
209    #[test]
210    fn run_lifecycle_status_rejects_done_reopen_transitions() {
211        assert!(!RunStatus::Done.can_transition_to(RunStatus::Running));
212        assert!(!RunStatus::Done.can_transition_to(RunStatus::Waiting));
213    }
214
215    #[test]
216    fn termination_reason_to_run_status_mapping() {
217        let cases = vec![
218            (TerminationReason::Suspended, RunStatus::Waiting, None),
219            (
220                TerminationReason::NaturalEnd,
221                RunStatus::Done,
222                Some("natural"),
223            ),
224            (
225                TerminationReason::BehaviorRequested,
226                RunStatus::Done,
227                Some("behavior_requested"),
228            ),
229            (
230                TerminationReason::Cancelled,
231                RunStatus::Done,
232                Some("cancelled"),
233            ),
234            (
235                TerminationReason::Error("test error".to_string()),
236                RunStatus::Done,
237                Some("error"),
238            ),
239            (
240                TerminationReason::stopped("max_turns"),
241                RunStatus::Done,
242                Some("stopped:max_turns"),
243            ),
244        ];
245        for (reason, expected_status, expected_done) in cases {
246            let (status, done) = reason.to_run_status();
247            assert_eq!(status, expected_status, "status mismatch for {reason:?}");
248            assert_eq!(
249                done.as_deref(),
250                expected_done,
251                "done_reason mismatch for {reason:?}"
252            );
253        }
254    }
255
256    #[test]
257    fn run_lifecycle_ascii_state_machine_contains_all_states() {
258        let diagram = RunStatus::ASCII_STATE_MACHINE;
259        assert!(diagram.contains("running"));
260        assert!(diagram.contains("waiting"));
261        assert!(diagram.contains("done"));
262        assert!(diagram.contains("start"));
263    }
264
265    #[test]
266    fn run_lifecycle_state_action_reduces_into_run_envelope_patch() {
267        let base = serde_json::json!({});
268        let actions = vec![AnyStateAction::new::<RunLifecycleState>(
269            RunLifecycleAction::Set {
270                id: "run_42".to_string(),
271                status: RunStatus::Waiting,
272                done_reason: None,
273                updated_at: 99,
274            },
275        )];
276
277        let patches = reduce_state_actions(actions, &base, "agent_loop", &ScopeContext::run())
278            .expect("reduce");
279        assert_eq!(patches.len(), 1);
280
281        let merged = apply_patch(&base, patches[0].patch()).expect("apply");
282        assert_eq!(merged["__run"]["id"], serde_json::json!("run_42"));
283        assert_eq!(merged["__run"]["status"], serde_json::json!("waiting"));
284        assert!(merged["__run"]["done_reason"].is_null());
285        assert_eq!(merged["__run"]["updated_at"], serde_json::json!(99u64));
286    }
287}