Skip to main content

humanize_cli_core/
state.rs

1//! State management for Humanize loops.
2//!
3//! This module provides parsing and serialization for the state.md files
4//! used to track RLCR and PR loop progress.
5//!
6//! IMPORTANT: This schema must remain compatible with the historical shell-era
7//! state contract so existing loop directories continue to parse correctly.
8
9use serde::{Deserialize, Serialize, Serializer};
10use std::path::{Path, PathBuf};
11
12use crate::constants::{YAML_FRONTMATTER_END, YAML_FRONTMATTER_START};
13
14/// Serialize Option<String> as empty string when None (not null).
15/// This matches the shell behavior: `session_id:` (empty, not `session_id: null`).
16fn serialize_optional_empty<S>(value: &Option<String>, serializer: S) -> Result<S::Ok, S::Error>
17where
18    S: Serializer,
19{
20    match value {
21        Some(s) => serializer.serialize_str(s),
22        None => serializer.serialize_str(""),
23    }
24}
25
26fn normalize_empty_session_id(raw: &str) -> Option<String> {
27    let trimmed = raw.trim();
28    if trimmed.is_empty() || matches!(trimmed, "''" | "\"\"" | "~" | "null") {
29        None
30    } else {
31        Some(trimmed.to_string())
32    }
33}
34
35/// Represents the state of an RLCR or PR loop.
36///
37/// Schema matches setup-rlcr-loop.sh exactly:
38/// All field names use snake_case as per YAML convention.
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct State {
41    /// Current round number (0-indexed).
42    #[serde(default)]
43    pub current_round: u32,
44
45    /// Maximum number of iterations allowed.
46    #[serde(default = "default_max_iterations")]
47    pub max_iterations: u32,
48
49    /// Codex model name (e.g., "gpt-5.4").
50    #[serde(default = "default_codex_model")]
51    pub codex_model: String,
52
53    /// Codex reasoning effort (e.g., "high", "xhigh").
54    #[serde(default = "default_codex_effort")]
55    pub codex_effort: String,
56
57    /// Codex timeout in seconds.
58    #[serde(default = "default_codex_timeout")]
59    pub codex_timeout: u64,
60
61    /// Whether to push after each round.
62    #[serde(default)]
63    pub push_every_round: bool,
64
65    /// Interval for full alignment checks (round N-1 for N, 2N-1, etc.).
66    #[serde(default = "default_full_review_round")]
67    pub full_review_round: u32,
68
69    /// Path to the plan file (relative to project root).
70    #[serde(default)]
71    pub plan_file: String,
72
73    /// Whether the plan file is tracked in git.
74    #[serde(default)]
75    pub plan_tracked: bool,
76
77    /// Branch where the loop started.
78    #[serde(default)]
79    pub start_branch: String,
80
81    /// Base branch for code review.
82    #[serde(default)]
83    pub base_branch: String,
84
85    /// Base commit SHA.
86    #[serde(default)]
87    pub base_commit: String,
88
89    /// Whether review phase has started.
90    #[serde(default)]
91    pub review_started: bool,
92
93    /// Whether to ask Codex for clarification.
94    #[serde(default = "default_ask_codex_question")]
95    pub ask_codex_question: bool,
96
97    /// Session identifier for this loop.
98    /// Always serialized as empty string when None (shell contract).
99    #[serde(default, serialize_with = "serialize_optional_empty")]
100    pub session_id: Option<String>,
101
102    /// Whether agent teams mode is enabled.
103    #[serde(default)]
104    pub agent_teams: bool,
105
106    /// Timestamp when the loop was created (ISO 8601).
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub started_at: Option<String>,
109
110    // PR loop specific fields
111    /// PR number for PR loops.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub pr_number: Option<u32>,
114
115    /// List of configured bots for PR review.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub configured_bots: Option<Vec<String>>,
118
119    /// List of active bots for PR review.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub active_bots: Option<Vec<String>>,
122
123    /// Polling interval for PR state checks (seconds).
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub poll_interval: Option<u64>,
126
127    /// Timeout for PR polling (seconds).
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub poll_timeout: Option<u64>,
130
131    /// Startup case for PR loop (e.g., "new_pr", "existing_pr").
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub startup_case: Option<String>,
134
135    /// Latest commit SHA for PR.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub latest_commit_sha: Option<String>,
138
139    /// Timestamp of latest commit.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub latest_commit_at: Option<String>,
142
143    /// Timestamp of last trigger.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub last_trigger_at: Option<String>,
146
147    /// ID of trigger comment.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub trigger_comment_id: Option<String>,
150}
151
152// Default functions for serde
153fn default_max_iterations() -> u32 {
154    42
155}
156
157fn default_codex_model() -> String {
158    "gpt-5.4".to_string()
159}
160
161fn default_codex_effort() -> String {
162    "xhigh".to_string()
163}
164
165fn default_codex_timeout() -> u64 {
166    5400
167}
168
169fn default_full_review_round() -> u32 {
170    5
171}
172
173fn default_ask_codex_question() -> bool {
174    true
175}
176
177impl Default for State {
178    fn default() -> Self {
179        Self {
180            current_round: 0,
181            max_iterations: default_max_iterations(),
182            codex_model: default_codex_model(),
183            codex_effort: default_codex_effort(),
184            codex_timeout: default_codex_timeout(),
185            push_every_round: false,
186            full_review_round: default_full_review_round(),
187            plan_file: String::new(),
188            plan_tracked: false,
189            start_branch: String::new(),
190            base_branch: String::new(),
191            base_commit: String::new(),
192            review_started: false,
193            ask_codex_question: default_ask_codex_question(),
194            session_id: None,
195            agent_teams: false,
196            started_at: None,
197            // PR loop fields
198            pr_number: None,
199            configured_bots: None,
200            active_bots: None,
201            poll_interval: None,
202            poll_timeout: None,
203            startup_case: None,
204            latest_commit_sha: None,
205            latest_commit_at: None,
206            last_trigger_at: None,
207            trigger_comment_id: None,
208        }
209    }
210}
211
212impl State {
213    /// Parse state from a file containing YAML frontmatter.
214    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, StateError> {
215        let content = std::fs::read_to_string(path.as_ref())
216            .map_err(|e| StateError::IoError(e.to_string()))?;
217        Self::from_markdown(&content)
218    }
219
220    /// Parse state from markdown content with YAML frontmatter.
221    pub fn from_markdown(content: &str) -> Result<Self, StateError> {
222        let content = content.trim();
223
224        // Check for YAML frontmatter start
225        if !content.starts_with(YAML_FRONTMATTER_START) {
226            return Err(StateError::MissingFrontmatter);
227        }
228
229        // Find the closing delimiter (must be on its own line)
230        let rest = &content[YAML_FRONTMATTER_START.len()..];
231        let end_pos = rest
232            .find("\n---")
233            .ok_or(StateError::MissingFrontmatterEnd)?;
234
235        let yaml_content = &rest[..end_pos];
236
237        // Parse YAML
238        let state: State = serde_yaml::from_str(yaml_content)
239            .map_err(|e| StateError::YamlParseError(e.to_string()))?;
240
241        // Ensure defaults are applied for missing fields
242        // (serde's default attribute handles this for Option types and defaults)
243
244        Ok(state)
245    }
246
247    /// Parse state from markdown with strict validation of required fields.
248    ///
249    /// This matches the shell behavior in loop-common.sh parse_state_file_strict()
250    /// which rejects missing required fields: current_round, max_iterations,
251    /// review_started, and base_branch.
252    pub fn from_markdown_strict(content: &str) -> Result<Self, StateError> {
253        let content = content.trim();
254
255        // Check for YAML frontmatter start
256        if !content.starts_with(YAML_FRONTMATTER_START) {
257            return Err(StateError::MissingFrontmatter);
258        }
259
260        // Find the closing delimiter
261        let rest = &content[YAML_FRONTMATTER_START.len()..];
262        let end_pos = rest
263            .find("\n---")
264            .ok_or(StateError::MissingFrontmatterEnd)?;
265
266        let yaml_content = &rest[..end_pos];
267
268        // First parse as generic YAML to check for required fields
269        let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_content)
270            .map_err(|e| StateError::YamlParseError(e.to_string()))?;
271
272        let mapping = yaml_value.as_mapping().ok_or_else(|| {
273            StateError::MissingRequiredField("YAML must be a mapping".to_string())
274        })?;
275
276        // Validate required fields per loop-common.sh parse_state_file_strict
277        let required_fields = [
278            "current_round",
279            "max_iterations",
280            "review_started",
281            "base_branch",
282        ];
283        for field in &required_fields {
284            if !mapping.contains_key(serde_yaml::Value::String(field.to_string())) {
285                return Err(StateError::MissingRequiredField(field.to_string()));
286            }
287        }
288
289        // Now parse into State struct
290        let state: State = serde_yaml::from_str(yaml_content)
291            .map_err(|e| StateError::YamlParseError(e.to_string()))?;
292
293        Ok(state)
294    }
295
296    /// Serialize state to markdown with YAML frontmatter.
297    pub fn to_markdown(&self) -> Result<String, StateError> {
298        let mut yaml = serde_yaml::to_string(self)
299            .map_err(|e| StateError::YamlSerializeError(e.to_string()))?;
300        yaml = yaml.replace("session_id: ''\n", "session_id:\n");
301        yaml = yaml.replace("session_id: \"\"\n", "session_id:\n");
302
303        // Format: ---\n<yaml>\n---\n\n
304        // This matches the Bash implementation's format
305        Ok(format!(
306            "{}\n{}\n{}\n\n",
307            YAML_FRONTMATTER_START,
308            yaml.trim_end(),
309            YAML_FRONTMATTER_END
310        ))
311    }
312
313    /// Save state to a file.
314    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), StateError> {
315        let content = self.to_markdown()?;
316        std::fs::write(path.as_ref(), content).map_err(|e| StateError::IoError(e.to_string()))?;
317        Ok(())
318    }
319
320    /// Create a new RLCR state with the given parameters.
321    pub fn new_rlcr(
322        plan_file: String,
323        plan_tracked: bool,
324        start_branch: String,
325        base_branch: String,
326        base_commit: String,
327        max_iterations: Option<u32>,
328        codex_model: Option<String>,
329        codex_effort: Option<String>,
330        codex_timeout: Option<u64>,
331        push_every_round: bool,
332        full_review_round: Option<u32>,
333        ask_codex_question: bool,
334        agent_teams: bool,
335        review_started: bool,
336    ) -> Self {
337        let now = chrono_lite_now();
338        Self {
339            current_round: 0,
340            max_iterations: max_iterations.unwrap_or_else(default_max_iterations),
341            codex_model: codex_model.unwrap_or_else(default_codex_model),
342            codex_effort: codex_effort.unwrap_or_else(default_codex_effort),
343            codex_timeout: codex_timeout.unwrap_or_else(default_codex_timeout),
344            push_every_round,
345            full_review_round: full_review_round.unwrap_or_else(default_full_review_round),
346            plan_file,
347            plan_tracked,
348            start_branch,
349            base_branch,
350            base_commit,
351            review_started,
352            ask_codex_question,
353            session_id: None, // Empty initially, filled by PostToolUse hook
354            agent_teams,
355            started_at: Some(now),
356            // PR loop fields (all None for RLCR)
357            pr_number: None,
358            configured_bots: None,
359            active_bots: None,
360            poll_interval: None,
361            poll_timeout: None,
362            startup_case: None,
363            latest_commit_sha: None,
364            latest_commit_at: None,
365            last_trigger_at: None,
366            trigger_comment_id: None,
367        }
368    }
369
370    /// Increment the round counter.
371    pub fn increment_round(&mut self) {
372        self.current_round += 1;
373    }
374
375    /// Check if max iterations have been reached.
376    pub fn is_max_iterations_reached(&self) -> bool {
377        self.current_round >= self.max_iterations
378    }
379
380    /// Check if this is a terminal state filename.
381    pub fn is_terminal_state_file(filename: &str) -> bool {
382        let terminal_states = [
383            "complete-state.md",
384            "cancel-state.md",
385            "maxiter-state.md",
386            "stop-state.md",
387            "unexpected-state.md",
388            "approve-state.md",
389            "merged-state.md",
390            "closed-state.md",
391        ];
392        terminal_states.contains(&filename)
393    }
394
395    /// Check if a reason is a valid terminal state reason.
396    pub fn is_valid_terminal_reason(reason: &str) -> bool {
397        matches!(
398            reason,
399            "complete"
400                | "cancel"
401                | "maxiter"
402                | "stop"
403                | "unexpected"
404                | "approve"
405                | "merged"
406                | "closed"
407        )
408    }
409
410    /// Get the terminal state filename for a given exit reason.
411    ///
412    /// Returns None if the reason is not a valid terminal reason.
413    /// Shell contract: invalid reasons should error, not silently map to unexpected.
414    pub fn terminal_state_filename(reason: &str) -> Option<&'static str> {
415        match reason {
416            "complete" => Some("complete-state.md"),
417            "cancel" => Some("cancel-state.md"),
418            "maxiter" => Some("maxiter-state.md"),
419            "stop" => Some("stop-state.md"),
420            "unexpected" => Some("unexpected-state.md"),
421            "approve" => Some("approve-state.md"),
422            "merged" => Some("merged-state.md"),
423            "closed" => Some("closed-state.md"),
424            _ => None,
425        }
426    }
427
428    /// Rename state file to terminal state file.
429    ///
430    /// This implements the end-loop rename behavior from loop-common.sh:
431    /// After determining the exit reason, rename state.md to <reason>-state.md
432    ///
433    /// Returns error if reason is not valid (matching shell end_loop behavior).
434    pub fn rename_to_terminal<P: AsRef<Path>>(
435        state_path: P,
436        reason: &str,
437    ) -> Result<PathBuf, StateError> {
438        let terminal_name = Self::terminal_state_filename(reason)
439            .ok_or_else(|| StateError::InvalidTerminalReason(reason.to_string()))?;
440
441        let state_path = state_path.as_ref();
442        let dir = state_path
443            .parent()
444            .ok_or_else(|| StateError::IoError("Cannot determine parent directory".to_string()))?;
445
446        let terminal_path = dir.join(terminal_name);
447
448        std::fs::rename(state_path, &terminal_path)
449            .map_err(|e| StateError::IoError(e.to_string()))?;
450
451        Ok(terminal_path)
452    }
453}
454
455/// Find the active RLCR loop directory.
456///
457/// Matches loop-common.sh find_active_loop behavior exactly:
458/// - Without session filter: only check the single newest directory (zombie-loop protection)
459/// - With session filter: iterate newest-to-oldest, find first matching session
460/// - Empty stored session_id matches any filter (backward compatibility)
461/// - Only return if still active (has active state file, not terminal)
462pub fn find_active_loop(base_dir: &Path, session_id: Option<&str>) -> Option<PathBuf> {
463    if !base_dir.exists() {
464        return None;
465    }
466
467    let entries = match std::fs::read_dir(base_dir) {
468        Ok(e) => e,
469        Err(_) => return None,
470    };
471
472    let mut dirs = entries
473        .flatten()
474        .map(|entry| entry.path())
475        .filter(|path| path.is_dir())
476        .collect::<Vec<_>>();
477    dirs.sort();
478    dirs.reverse();
479
480    if session_id.is_none() {
481        // No filter: only check the single newest directory (zombie-loop protection)
482        if let Some(newest_dir) = dirs.first() {
483            // Only return if it has an active state file
484            if resolve_active_state_file(newest_dir).is_some() {
485                return Some(newest_dir.clone());
486            }
487        }
488        return None;
489    }
490
491    // Session filter: iterate newest-to-oldest
492    let filter_sid = session_id.unwrap();
493
494    for loop_dir in dirs {
495        // Check if this directory has any state file (active or terminal)
496        let any_state = resolve_any_state_file(&loop_dir);
497        if any_state.is_none() {
498            continue;
499        }
500
501        // Read session_id from the state file
502        let stored_session_id = any_state
503            .and_then(|path| std::fs::read_to_string(path).ok())
504            .and_then(|content| extract_session_id_from_frontmatter(&content));
505
506        // Empty stored session_id matches any session (backward compatibility)
507        let matches_session = match stored_session_id {
508            None => true, // No stored session_id, matches any
509            Some(ref stored) => stored == filter_sid,
510        };
511
512        if matches_session {
513            // This is the newest dir for this session -- only return if active
514            if resolve_active_state_file(&loop_dir).is_some() {
515                return Some(loop_dir);
516            }
517            // The session's newest loop is terminal; do not fall through to older dirs.
518            return None;
519        }
520    }
521
522    None
523}
524
525fn extract_session_id_from_frontmatter(content: &str) -> Option<String> {
526    let mut lines = content.lines();
527    if lines.next()? != "---" {
528        return None;
529    }
530
531    for line in lines {
532        if line == "---" {
533            break;
534        }
535
536        if let Some(value) = line.strip_prefix("session_id:") {
537            return normalize_empty_session_id(value.trim());
538        }
539    }
540
541    None
542}
543
544/// Resolve the active state file in a loop directory.
545///
546/// Checks finalize-state.md FIRST (loop in finalize phase), then state.md.
547/// Does NOT return terminal states - only active states.
548/// Matches loop-common.sh resolve_active_state_file behavior exactly.
549pub fn resolve_active_state_file(loop_dir: &Path) -> Option<PathBuf> {
550    // First check for finalize-state.md (active but in finalize phase)
551    let finalize_file = loop_dir.join("finalize-state.md");
552    if finalize_file.exists() {
553        return Some(finalize_file);
554    }
555
556    // Then check for state.md (normal active state)
557    let state_file = loop_dir.join("state.md");
558    if state_file.exists() {
559        return Some(state_file);
560    }
561
562    None
563}
564
565/// Resolve any state file (active or terminal) in a loop directory.
566///
567/// Prefers active states (finalize-state.md, state.md), then falls back
568/// to any terminal state file (*-state.md).
569/// Matches loop-common.sh resolve_any_state_file behavior exactly.
570pub fn resolve_any_state_file(loop_dir: &Path) -> Option<PathBuf> {
571    // Prefer active states
572    if let Some(active) = resolve_active_state_file(loop_dir) {
573        return Some(active);
574    }
575
576    // Fall back to terminal states (check in order of preference)
577    let terminal_states = [
578        "complete-state.md",
579        "cancel-state.md",
580        "maxiter-state.md",
581        "stop-state.md",
582        "unexpected-state.md",
583        "approve-state.md",
584        "merged-state.md",
585        "closed-state.md",
586    ];
587
588    for terminal in &terminal_states {
589        let path = loop_dir.join(terminal);
590        if path.exists() {
591            return Some(path);
592        }
593    }
594
595    None
596}
597
598/// Check if a loop is in finalize phase.
599///
600/// A loop is in finalize phase if it has a finalize-state.md file.
601pub fn is_finalize_phase(loop_dir: &Path) -> bool {
602    loop_dir.join("finalize-state.md").exists()
603}
604
605/// Check if a loop has a pending session handshake.
606///
607/// Returns true if .pending-session-id signal file exists.
608pub fn has_pending_session(project_root: &Path) -> bool {
609    project_root.join(".humanize/.pending-session-id").exists()
610}
611
612/// Generate a timestamp in ISO 8601 format (UTC).
613fn chrono_lite_now() -> String {
614    use chrono::Utc;
615    Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
616}
617
618/// Errors that can occur when working with state.
619#[derive(Debug, thiserror::Error)]
620pub enum StateError {
621    #[error("IO error: {0}")]
622    IoError(String),
623
624    #[error("Missing YAML frontmatter")]
625    MissingFrontmatter,
626
627    #[error("Missing YAML frontmatter end delimiter")]
628    MissingFrontmatterEnd,
629
630    #[error("YAML parse error: {0}")]
631    YamlParseError(String),
632
633    #[error("YAML serialize error: {0}")]
634    YamlSerializeError(String),
635
636    #[error("Missing required field: {0}")]
637    MissingRequiredField(String),
638
639    #[error("Invalid terminal reason: {0}")]
640    InvalidTerminalReason(String),
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    #[test]
648    fn test_state_to_markdown() {
649        let state = State::default();
650        let md = state.to_markdown().unwrap();
651        assert!(md.starts_with("---\n"));
652        assert!(md.contains("current_round: 0"));
653        assert!(md.contains("session_id:\n"));
654        assert!(!md.contains("session_id: ''"));
655    }
656
657    #[test]
658    fn test_state_from_markdown() {
659        let content = r#"---
660current_round: 1
661max_iterations: 42
662codex_model: gpt-5.4
663codex_effort: high
664codex_timeout: 5400
665push_every_round: false
666full_review_round: 5
667plan_file: docs/plan.md
668plan_tracked: false
669start_branch: master
670base_branch: master
671base_commit: abc123
672review_started: false
673ask_codex_question: true
674session_id:
675agent_teams: false
676---
677
678Some content below.
679"#;
680
681        let state = State::from_markdown(content).unwrap();
682        assert_eq!(state.current_round, 1);
683        assert_eq!(state.plan_file, "docs/plan.md");
684        assert!(state.session_id.is_none());
685    }
686
687    #[test]
688    fn test_state_roundtrip() {
689        let original = State::new_rlcr(
690            "docs/plan.md".to_string(),
691            false,
692            "master".to_string(),
693            "master".to_string(),
694            "abc123".to_string(),
695            None,
696            None,
697            None,
698            None,
699            false,
700            None,
701            true,
702            false,
703            false,
704        );
705
706        let md = original.to_markdown().unwrap();
707        let parsed = State::from_markdown(&md).unwrap();
708
709        assert_eq!(original.current_round, parsed.current_round);
710        assert_eq!(original.plan_file, parsed.plan_file);
711        assert_eq!(original.base_branch, parsed.base_branch);
712    }
713
714    #[test]
715    fn test_terminal_state_filename() {
716        assert_eq!(
717            State::terminal_state_filename("complete"),
718            Some("complete-state.md")
719        );
720        assert_eq!(
721            State::terminal_state_filename("cancel"),
722            Some("cancel-state.md")
723        );
724        assert_eq!(
725            State::terminal_state_filename("maxiter"),
726            Some("maxiter-state.md")
727        );
728        assert_eq!(State::terminal_state_filename("unknown"), None); // Invalid reason returns None
729    }
730
731    #[test]
732    fn test_strict_parsing_rejects_missing_required_fields() {
733        // Missing base_branch
734        let content_missing_base = r#"---
735current_round: 0
736max_iterations: 42
737review_started: false
738---
739"#;
740        let result = State::from_markdown_strict(content_missing_base);
741        assert!(result.is_err());
742        match result {
743            Err(StateError::MissingRequiredField(field)) => {
744                assert_eq!(field, "base_branch");
745            }
746            _ => panic!("Expected MissingRequiredField error for base_branch"),
747        }
748
749        // Missing max_iterations
750        let content_missing_max = r#"---
751current_round: 0
752review_started: false
753base_branch: master
754---
755"#;
756        let result = State::from_markdown_strict(content_missing_max);
757        assert!(result.is_err());
758
759        // Valid state with all required fields
760        let content_valid = r#"---
761current_round: 0
762max_iterations: 42
763review_started: false
764base_branch: master
765---
766"#;
767        let result = State::from_markdown_strict(content_valid);
768        assert!(result.is_ok());
769    }
770
771    #[test]
772    fn find_active_loop_uses_directory_name_order_without_session_filter() {
773        let tempdir = tempfile::tempdir().unwrap();
774        let base = tempdir.path().join("rlcr");
775        std::fs::create_dir_all(&base).unwrap();
776
777        let older = base.join("2026-03-18_00-00-00");
778        let newer = base.join("2026-03-19_00-00-00");
779        std::fs::create_dir_all(&older).unwrap();
780        std::fs::create_dir_all(&newer).unwrap();
781
782        std::fs::write(
783            older.join("state.md"),
784            "---\nsession_id: old\ncurrent_round: 0\nmax_iterations: 1\nplan_file: plan.md\nplan_tracked: false\nstart_branch: main\nbase_branch: main\nbase_commit: abc\nreview_started: false\n---\n",
785        )
786        .unwrap();
787        std::fs::write(
788            newer.join("cancel-state.md"),
789            "---\nsession_id: new\ncurrent_round: 0\nmax_iterations: 1\nplan_file: plan.md\nplan_tracked: false\nstart_branch: main\nbase_branch: main\nbase_commit: abc\nreview_started: false\n---\n",
790        )
791        .unwrap();
792
793        assert_eq!(find_active_loop(&base, None), None);
794    }
795
796    #[test]
797    fn find_active_loop_stops_at_newest_terminal_match_for_session() {
798        let tempdir = tempfile::tempdir().unwrap();
799        let base = tempdir.path().join("rlcr");
800        std::fs::create_dir_all(&base).unwrap();
801
802        let older = base.join("2026-03-18_00-00-00");
803        let newer = base.join("2026-03-19_00-00-00");
804        std::fs::create_dir_all(&older).unwrap();
805        std::fs::create_dir_all(&newer).unwrap();
806
807        let state = "---\nsession_id: session-1\ncurrent_round: 0\nmax_iterations: 1\nplan_file: plan.md\nplan_tracked: false\nstart_branch: main\nbase_branch: main\nbase_commit: abc\nreview_started: false\n---\n";
808        std::fs::write(older.join("state.md"), state).unwrap();
809        std::fs::write(newer.join("cancel-state.md"), state).unwrap();
810
811        assert_eq!(find_active_loop(&base, Some("session-1")), None);
812    }
813}