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)]
33#[non_exhaustive]
34pub struct State {
35 pub stage: Stage,
37 pub phase: u32,
39 pub agent: AgentKind,
41 pub mode: Mode,
43 #[serde(default)]
45 pub gate_pending: bool,
46 #[serde(default)]
50 pub consecutive_failures: u32,
51 #[serde(default)]
60 pub infra_failures: u32,
61 #[serde(default)]
72 pub preflight_retries: u32,
73 pub started_at: String,
75 pub project_root: PathBuf,
77 #[serde(default)]
82 pub worktree_path: Option<PathBuf>,
83 #[serde(default)]
89 pub monitor_pid: Option<u32>,
90 #[serde(default)]
100 pub session_id: Option<String>,
101 #[serde(default)]
113 pub checkpoint_resumes: u32,
114 #[serde(default)]
120 pub stop_until: Option<Stage>,
121 #[serde(default)]
126 pub stopped: bool,
127 #[serde(default)]
130 pub stop_reason: Option<String>,
131 #[serde(default)]
142 pub yes_ship: bool,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "lowercase")]
148pub enum AgentKind {
149 Claude,
151 Codex,
153 OpenCode,
155}
156
157impl fmt::Display for AgentKind {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 let name = match self {
160 AgentKind::Claude => "claude",
161 AgentKind::Codex => "codex",
162 AgentKind::OpenCode => "opencode",
163 };
164 f.write_str(name)
165 }
166}
167
168impl FromStr for AgentKind {
169 type Err = AgentParseError;
170
171 fn from_str(value: &str) -> Result<Self, Self::Err> {
172 match value.to_ascii_lowercase().as_str() {
173 "claude" => Ok(AgentKind::Claude),
174 "codex" => Ok(AgentKind::Codex),
175 "opencode" | "open-code" => Ok(AgentKind::OpenCode),
176 other => Err(AgentParseError(other.to_string())),
177 }
178 }
179}
180
181#[derive(Debug, Clone, thiserror::Error)]
183#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
184pub struct AgentParseError(String);
185
186impl State {
187 pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
189 State {
190 stage: Stage::Define,
191 phase,
192 agent,
193 mode,
194 gate_pending: false,
195 consecutive_failures: 0,
196 infra_failures: 0,
197 preflight_retries: 0,
198 started_at: timestamp_now(),
199 project_root,
200 worktree_path: None,
201 monitor_pid: None,
202 session_id: None,
203 checkpoint_resumes: 0,
204 stop_until: None,
205 stopped: false,
206 stop_reason: None,
207 yes_ship: false,
208 }
209 }
210}
211
212fn timestamp_now() -> String {
213 match SystemTime::now().duration_since(UNIX_EPOCH) {
214 Ok(duration) => format!("{}", duration.as_secs()),
215 Err(_) => String::from("0"),
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use std::path::PathBuf;
223
224 #[test]
225 fn agent_name_and_display() {
226 use crate::agents::adapter_for;
227 assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
228 assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
229 assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
230
231 assert_eq!(AgentKind::Claude.to_string(), "claude");
232 assert_eq!(AgentKind::Codex.to_string(), "codex");
233 assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
234 }
235
236 #[test]
237 fn agent_from_str_accepts_canonical_and_aliases() {
238 assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
239 assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
240 assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
241 assert_eq!(
242 "opencode".parse::<AgentKind>().unwrap(),
243 AgentKind::OpenCode
244 );
245 assert_eq!(
246 "open-code".parse::<AgentKind>().unwrap(),
247 AgentKind::OpenCode
248 );
249 }
250
251 #[test]
252 fn agent_from_str_rejects_unknown() {
253 let err = "aider".parse::<AgentKind>().unwrap_err();
254 assert!(err.to_string().contains("aider"));
255 }
256
257 #[test]
258 fn new_state_starts_at_define() {
259 let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
260 assert_eq!(state.stage, Stage::Define);
261 assert_eq!(state.phase, 2);
262 assert_eq!(state.agent, AgentKind::Claude);
263 assert_eq!(state.mode, Mode::Auto);
264 assert!(!state.gate_pending);
265 assert_eq!(state.consecutive_failures, 0);
266 assert_eq!(state.infra_failures, 0);
267 assert_eq!(state.preflight_retries, 0);
268 assert!(!state.started_at.is_empty());
269 assert_eq!(state.monitor_pid, None);
270 assert_eq!(state.stop_until, None);
271 assert!(!state.stopped);
272 assert_eq!(state.stop_reason, None);
273 assert!(!state.yes_ship);
274 }
275
276 #[test]
277 fn state_serde_round_trips() {
278 let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
279 let json = serde_json::to_string(&state).unwrap();
280 let back: State = serde_json::from_str(&json).unwrap();
281 assert_eq!(back.phase, 9);
282 assert_eq!(back.agent, AgentKind::Codex);
283 assert_eq!(back.stage, Stage::Define);
284 assert_eq!(back.mode, Mode::Supervise);
285 }
286
287 #[test]
288 fn consecutive_failures_persists_across_advance_calls() {
289 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
290 state.consecutive_failures = 3;
291 let json = serde_json::to_string(&state).unwrap();
292 assert!(
293 json.contains("consecutive_failures"),
294 "consecutive_failures must appear in persisted JSON"
295 );
296 let loaded: State = serde_json::from_str(&json).unwrap();
297 assert_eq!(
298 loaded.consecutive_failures, 3,
299 "consecutive_failures must round-trip through serde"
300 );
301 }
302
303 #[test]
306 fn infra_failures_round_trips_through_serde() {
307 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
308 state.infra_failures = 4;
309 let json = serde_json::to_string(&state).unwrap();
310 assert!(
311 json.contains("infra_failures"),
312 "infra_failures must appear in persisted JSON"
313 );
314 let loaded: State = serde_json::from_str(&json).unwrap();
315 assert_eq!(
316 loaded.infra_failures, 4,
317 "infra_failures must round-trip through serde"
318 );
319 }
320
321 #[test]
324 fn infra_failures_absent_from_json_defaults_to_zero() {
325 let json = r#"{
326 "stage": "code",
327 "phase": 1,
328 "agent": "claude",
329 "mode": "auto",
330 "started_at": "0",
331 "project_root": "/repo"
332 }"#;
333 let loaded: State = serde_json::from_str(json).unwrap();
334 assert_eq!(loaded.infra_failures, 0);
335 }
336
337 #[test]
343 fn preflight_retries_round_trips_through_serde() {
344 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
345 state.preflight_retries = 2;
346 let json = serde_json::to_string(&state).unwrap();
347 assert!(
348 json.contains("preflight_retries"),
349 "preflight_retries must appear in persisted JSON"
350 );
351 let loaded: State = serde_json::from_str(&json).unwrap();
352 assert_eq!(
353 loaded.preflight_retries, 2,
354 "preflight_retries must round-trip through serde"
355 );
356
357 let absent_json = r#"{
358 "stage": "code",
359 "phase": 1,
360 "agent": "claude",
361 "mode": "auto",
362 "started_at": "0",
363 "project_root": "/repo"
364 }"#;
365 let loaded_absent: State = serde_json::from_str(absent_json).unwrap();
366 assert_eq!(loaded_absent.preflight_retries, 0);
367 }
368
369 #[test]
371 fn monitor_pid_round_trips_through_serde() {
372 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
373 state.monitor_pid = Some(4242);
374 let json = serde_json::to_string(&state).unwrap();
375 assert!(
376 json.contains("monitor_pid"),
377 "monitor_pid must appear in persisted JSON"
378 );
379 let loaded: State = serde_json::from_str(&json).unwrap();
380 assert_eq!(
381 loaded.monitor_pid,
382 Some(4242),
383 "monitor_pid must round-trip through serde"
384 );
385 }
386
387 #[test]
391 fn monitor_pid_absent_from_json_defaults_to_none() {
392 let json = r#"{
393 "stage": "code",
394 "phase": 1,
395 "agent": "claude",
396 "mode": "auto",
397 "started_at": "0",
398 "project_root": "/repo"
399 }"#;
400 let loaded: State = serde_json::from_str(json).unwrap();
401 assert_eq!(loaded.monitor_pid, None);
402 }
403
404 #[test]
407 fn session_id_round_trips_through_serde() {
408 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
409 state.session_id = Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e".to_string());
410 let json = serde_json::to_string(&state).unwrap();
411 assert!(
412 json.contains("session_id"),
413 "session_id must appear in persisted JSON"
414 );
415 let loaded: State = serde_json::from_str(&json).unwrap();
416 assert_eq!(
417 loaded.session_id.as_deref(),
418 Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e"),
419 "session_id must round-trip through serde"
420 );
421 }
422
423 #[test]
426 fn session_id_absent_from_json_defaults_to_none() {
427 let json = r#"{
428 "stage": "code",
429 "phase": 1,
430 "agent": "claude",
431 "mode": "auto",
432 "started_at": "0",
433 "project_root": "/repo"
434 }"#;
435 let loaded: State = serde_json::from_str(json).unwrap();
436 assert_eq!(loaded.session_id, None);
437 }
438
439 #[test]
442 fn checkpoint_resumes_round_trips_through_serde() {
443 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
444 state.checkpoint_resumes = 2;
445 let json = serde_json::to_string(&state).unwrap();
446 assert!(
447 json.contains("checkpoint_resumes"),
448 "checkpoint_resumes must appear in persisted JSON"
449 );
450 let loaded: State = serde_json::from_str(&json).unwrap();
451 assert_eq!(
452 loaded.checkpoint_resumes, 2,
453 "checkpoint_resumes must round-trip through serde"
454 );
455 }
456
457 #[test]
460 fn checkpoint_resumes_absent_from_json_defaults_to_zero() {
461 let json = r#"{
462 "stage": "code",
463 "phase": 1,
464 "agent": "claude",
465 "mode": "auto",
466 "started_at": "0",
467 "project_root": "/repo"
468 }"#;
469 let loaded: State = serde_json::from_str(json).unwrap();
470 assert_eq!(loaded.checkpoint_resumes, 0);
471 }
472
473 #[test]
477 fn yes_ship_round_trips_through_serde() {
478 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
479 state.yes_ship = true;
480 let json = serde_json::to_string(&state).unwrap();
481 assert!(
482 json.contains("yes_ship"),
483 "yes_ship must appear in persisted JSON"
484 );
485 let loaded: State = serde_json::from_str(&json).unwrap();
486 assert!(loaded.yes_ship, "yes_ship must round-trip through serde");
487 }
488
489 #[test]
494 fn yes_ship_absent_from_json_defaults_to_false() {
495 let json = r#"{
496 "stage": "code",
497 "phase": 1,
498 "agent": "claude",
499 "mode": "auto",
500 "started_at": "0",
501 "project_root": "/repo"
502 }"#;
503 let loaded: State = serde_json::from_str(json).unwrap();
504 assert!(!loaded.yes_ship);
505 }
506
507 #[test]
511 fn stop_fields_round_trip_through_serde() {
512 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
513 state.stop_until = Some(Stage::Plan);
514 state.stopped = true;
515 state.stop_reason = Some("stopped after plan completed (--until plan)".to_string());
516 let json = serde_json::to_string(&state).unwrap();
517 assert!(
518 json.contains("stop_until") && json.contains("stopped") && json.contains("stop_reason"),
519 "all three stop fields must appear in persisted JSON: {json}"
520 );
521 let loaded: State = serde_json::from_str(&json).unwrap();
522 assert_eq!(
523 loaded.stop_until,
524 Some(Stage::Plan),
525 "stop_until must round-trip through serde"
526 );
527 assert!(loaded.stopped, "stopped must round-trip through serde");
528 assert_eq!(
529 loaded.stop_reason.as_deref(),
530 Some("stopped after plan completed (--until plan)"),
531 "stop_reason must round-trip through serde"
532 );
533 }
534
535 #[test]
540 fn stop_fields_absent_from_json_default() {
541 let json = r#"{
542 "stage": "code",
543 "phase": 1,
544 "agent": "claude",
545 "mode": "auto",
546 "started_at": "0",
547 "project_root": "/repo"
548 }"#;
549 let loaded: State = serde_json::from_str(json).unwrap();
550 assert_eq!(loaded.stop_until, None);
551 assert!(!loaded.stopped);
552 assert_eq!(loaded.stop_reason, None);
553 }
554}