1use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::path::PathBuf;
9use std::str::FromStr;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use crate::mode::Mode;
13use crate::stage::Stage;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct State {
18 pub stage: Stage,
20 pub phase: u32,
22 pub agent: AgentKind,
24 pub mode: Mode,
26 #[serde(default)]
28 pub gate_pending: bool,
29 #[serde(default)]
33 pub consecutive_failures: u32,
34 #[serde(default)]
43 pub infra_failures: u32,
44 #[serde(default)]
55 pub preflight_retries: u32,
56 pub started_at: String,
58 pub project_root: PathBuf,
60 #[serde(default)]
65 pub worktree_path: Option<PathBuf>,
66 #[serde(default)]
72 pub monitor_pid: Option<u32>,
73 #[serde(default)]
79 pub stop_until: Option<Stage>,
80 #[serde(default)]
85 pub stopped: bool,
86 #[serde(default)]
89 pub stop_reason: Option<String>,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "lowercase")]
95pub enum AgentKind {
96 Claude,
98 Codex,
100 OpenCode,
102}
103
104impl fmt::Display for AgentKind {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 let name = match self {
107 AgentKind::Claude => "claude",
108 AgentKind::Codex => "codex",
109 AgentKind::OpenCode => "opencode",
110 };
111 f.write_str(name)
112 }
113}
114
115impl FromStr for AgentKind {
116 type Err = AgentParseError;
117
118 fn from_str(value: &str) -> Result<Self, Self::Err> {
119 match value.to_ascii_lowercase().as_str() {
120 "claude" => Ok(AgentKind::Claude),
121 "codex" => Ok(AgentKind::Codex),
122 "opencode" | "open-code" => Ok(AgentKind::OpenCode),
123 other => Err(AgentParseError(other.to_string())),
124 }
125 }
126}
127
128#[derive(Debug, Clone, thiserror::Error)]
130#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
131pub struct AgentParseError(String);
132
133impl State {
134 pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
136 State {
137 stage: Stage::Define,
138 phase,
139 agent,
140 mode,
141 gate_pending: false,
142 consecutive_failures: 0,
143 infra_failures: 0,
144 preflight_retries: 0,
145 started_at: timestamp_now(),
146 project_root,
147 worktree_path: None,
148 monitor_pid: None,
149 stop_until: None,
150 stopped: false,
151 stop_reason: None,
152 }
153 }
154}
155
156fn timestamp_now() -> String {
157 match SystemTime::now().duration_since(UNIX_EPOCH) {
158 Ok(duration) => format!("{}", duration.as_secs()),
159 Err(_) => String::from("0"),
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use std::path::PathBuf;
167
168 #[test]
169 fn agent_name_and_display() {
170 use crate::agents::adapter_for;
171 assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
172 assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
173 assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
174
175 assert_eq!(AgentKind::Claude.to_string(), "claude");
176 assert_eq!(AgentKind::Codex.to_string(), "codex");
177 assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
178 }
179
180 #[test]
181 fn agent_from_str_accepts_canonical_and_aliases() {
182 assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
183 assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
184 assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
185 assert_eq!(
186 "opencode".parse::<AgentKind>().unwrap(),
187 AgentKind::OpenCode
188 );
189 assert_eq!(
190 "open-code".parse::<AgentKind>().unwrap(),
191 AgentKind::OpenCode
192 );
193 }
194
195 #[test]
196 fn agent_from_str_rejects_unknown() {
197 let err = "aider".parse::<AgentKind>().unwrap_err();
198 assert!(err.to_string().contains("aider"));
199 }
200
201 #[test]
202 fn new_state_starts_at_define() {
203 let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
204 assert_eq!(state.stage, Stage::Define);
205 assert_eq!(state.phase, 2);
206 assert_eq!(state.agent, AgentKind::Claude);
207 assert_eq!(state.mode, Mode::Auto);
208 assert!(!state.gate_pending);
209 assert_eq!(state.consecutive_failures, 0);
210 assert_eq!(state.infra_failures, 0);
211 assert_eq!(state.preflight_retries, 0);
212 assert!(!state.started_at.is_empty());
213 assert_eq!(state.monitor_pid, None);
214 assert_eq!(state.stop_until, None);
215 assert!(!state.stopped);
216 assert_eq!(state.stop_reason, None);
217 }
218
219 #[test]
220 fn state_serde_round_trips() {
221 let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
222 let json = serde_json::to_string(&state).unwrap();
223 let back: State = serde_json::from_str(&json).unwrap();
224 assert_eq!(back.phase, 9);
225 assert_eq!(back.agent, AgentKind::Codex);
226 assert_eq!(back.stage, Stage::Define);
227 assert_eq!(back.mode, Mode::Supervise);
228 }
229
230 #[test]
231 fn consecutive_failures_persists_across_advance_calls() {
232 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
233 state.consecutive_failures = 3;
234 let json = serde_json::to_string(&state).unwrap();
235 assert!(
236 json.contains("consecutive_failures"),
237 "consecutive_failures must appear in persisted JSON"
238 );
239 let loaded: State = serde_json::from_str(&json).unwrap();
240 assert_eq!(
241 loaded.consecutive_failures, 3,
242 "consecutive_failures must round-trip through serde"
243 );
244 }
245
246 #[test]
249 fn infra_failures_round_trips_through_serde() {
250 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
251 state.infra_failures = 4;
252 let json = serde_json::to_string(&state).unwrap();
253 assert!(
254 json.contains("infra_failures"),
255 "infra_failures must appear in persisted JSON"
256 );
257 let loaded: State = serde_json::from_str(&json).unwrap();
258 assert_eq!(
259 loaded.infra_failures, 4,
260 "infra_failures must round-trip through serde"
261 );
262 }
263
264 #[test]
267 fn infra_failures_absent_from_json_defaults_to_zero() {
268 let json = r#"{
269 "stage": "code",
270 "phase": 1,
271 "agent": "claude",
272 "mode": "auto",
273 "started_at": "0",
274 "project_root": "/repo"
275 }"#;
276 let loaded: State = serde_json::from_str(json).unwrap();
277 assert_eq!(loaded.infra_failures, 0);
278 }
279
280 #[test]
286 fn preflight_retries_round_trips_through_serde() {
287 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
288 state.preflight_retries = 2;
289 let json = serde_json::to_string(&state).unwrap();
290 assert!(
291 json.contains("preflight_retries"),
292 "preflight_retries must appear in persisted JSON"
293 );
294 let loaded: State = serde_json::from_str(&json).unwrap();
295 assert_eq!(
296 loaded.preflight_retries, 2,
297 "preflight_retries must round-trip through serde"
298 );
299
300 let absent_json = r#"{
301 "stage": "code",
302 "phase": 1,
303 "agent": "claude",
304 "mode": "auto",
305 "started_at": "0",
306 "project_root": "/repo"
307 }"#;
308 let loaded_absent: State = serde_json::from_str(absent_json).unwrap();
309 assert_eq!(loaded_absent.preflight_retries, 0);
310 }
311
312 #[test]
314 fn monitor_pid_round_trips_through_serde() {
315 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
316 state.monitor_pid = Some(4242);
317 let json = serde_json::to_string(&state).unwrap();
318 assert!(
319 json.contains("monitor_pid"),
320 "monitor_pid must appear in persisted JSON"
321 );
322 let loaded: State = serde_json::from_str(&json).unwrap();
323 assert_eq!(
324 loaded.monitor_pid,
325 Some(4242),
326 "monitor_pid must round-trip through serde"
327 );
328 }
329
330 #[test]
334 fn monitor_pid_absent_from_json_defaults_to_none() {
335 let json = r#"{
336 "stage": "code",
337 "phase": 1,
338 "agent": "claude",
339 "mode": "auto",
340 "started_at": "0",
341 "project_root": "/repo"
342 }"#;
343 let loaded: State = serde_json::from_str(json).unwrap();
344 assert_eq!(loaded.monitor_pid, None);
345 }
346
347 #[test]
351 fn stop_fields_round_trip_through_serde() {
352 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
353 state.stop_until = Some(Stage::Plan);
354 state.stopped = true;
355 state.stop_reason = Some("stopped after plan completed (--until plan)".to_string());
356 let json = serde_json::to_string(&state).unwrap();
357 assert!(
358 json.contains("stop_until") && json.contains("stopped") && json.contains("stop_reason"),
359 "all three stop fields must appear in persisted JSON: {json}"
360 );
361 let loaded: State = serde_json::from_str(&json).unwrap();
362 assert_eq!(
363 loaded.stop_until,
364 Some(Stage::Plan),
365 "stop_until must round-trip through serde"
366 );
367 assert!(loaded.stopped, "stopped must round-trip through serde");
368 assert_eq!(
369 loaded.stop_reason.as_deref(),
370 Some("stopped after plan completed (--until plan)"),
371 "stop_reason must round-trip through serde"
372 );
373 }
374
375 #[test]
380 fn stop_fields_absent_from_json_default() {
381 let json = r#"{
382 "stage": "code",
383 "phase": 1,
384 "agent": "claude",
385 "mode": "auto",
386 "started_at": "0",
387 "project_root": "/repo"
388 }"#;
389 let loaded: State = serde_json::from_str(json).unwrap();
390 assert_eq!(loaded.stop_until, None);
391 assert!(!loaded.stopped);
392 assert_eq!(loaded.stop_reason, None);
393 }
394}