Skip to main content

ralph_workflow/reducer/
state.rs

1//! Pipeline state types for reducer architecture.
2//!
3//! Defines immutable state structures that capture complete pipeline execution context.
4//! These state structures can be serialized as checkpoints for resume functionality.
5
6use crate::agents::AgentRole;
7use crate::checkpoint::execution_history::ExecutionStep;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11use super::event::PipelinePhase;
12
13/// Immutable pipeline state (this IS the checkpoint).
14///
15/// Contains all information needed to resume pipeline execution at any point.
16/// The reducer updates this state by returning new immutable copies on each event.
17#[derive(Clone, Serialize, Deserialize, Debug)]
18pub struct PipelineState {
19    pub phase: PipelinePhase,
20    pub previous_phase: Option<PipelinePhase>,
21    pub iteration: u32,
22    pub total_iterations: u32,
23    pub reviewer_pass: u32,
24    pub total_reviewer_passes: u32,
25    pub review_issues_found: bool,
26    pub context_cleaned: bool,
27    pub agent_chain: AgentChainState,
28    pub rebase: RebaseState,
29    pub commit: CommitState,
30    pub execution_history: Vec<ExecutionStep>,
31}
32
33impl PipelineState {
34    pub fn initial(developer_iters: u32, reviewer_reviews: u32) -> Self {
35        // Determine initial phase based on what work needs to be done
36        let initial_phase = if developer_iters == 0 {
37            // No development iterations → skip Planning and Development
38            if reviewer_reviews == 0 {
39                // No review passes either → go straight to commit
40                PipelinePhase::CommitMessage
41            } else {
42                PipelinePhase::Review
43            }
44        } else {
45            PipelinePhase::Planning
46        };
47
48        Self {
49            phase: initial_phase,
50            previous_phase: None,
51            iteration: 0,
52            total_iterations: developer_iters,
53            reviewer_pass: 0,
54            total_reviewer_passes: reviewer_reviews,
55            review_issues_found: false,
56            context_cleaned: false,
57            agent_chain: AgentChainState::initial(),
58            rebase: RebaseState::NotStarted,
59            commit: CommitState::NotStarted,
60            execution_history: Vec::new(),
61        }
62    }
63
64    pub fn is_complete(&self) -> bool {
65        matches!(
66            self.phase,
67            PipelinePhase::Complete | PipelinePhase::Interrupted
68        )
69    }
70
71    pub fn current_head(&self) -> String {
72        self.rebase
73            .current_head()
74            .unwrap_or_else(|| "HEAD".to_string())
75    }
76}
77
78/// Agent fallback chain state (explicit, not loop indices).
79///
80/// Tracks position in the multi-level fallback chain:
81/// - Agent level (primary → fallback1 → fallback2)
82/// - Model level (within each agent, try different models)
83/// - Retry cycle (exhaust all agents, start over with exponential backoff)
84#[derive(Clone, Serialize, Deserialize, Debug)]
85pub struct AgentChainState {
86    pub agents: Vec<String>,
87    pub current_agent_index: usize,
88    pub models_per_agent: Vec<Vec<String>>,
89    pub current_model_index: usize,
90    pub retry_cycle: u32,
91    pub max_cycles: u32,
92    pub current_role: AgentRole,
93}
94
95impl AgentChainState {
96    pub fn initial() -> Self {
97        Self {
98            agents: Vec::new(),
99            current_agent_index: 0,
100            models_per_agent: Vec::new(),
101            current_model_index: 0,
102            retry_cycle: 0,
103            max_cycles: 3,
104            current_role: AgentRole::Developer,
105        }
106    }
107
108    pub fn with_agents(
109        mut self,
110        agents: Vec<String>,
111        models_per_agent: Vec<Vec<String>>,
112        role: AgentRole,
113    ) -> Self {
114        self.agents = agents;
115        self.models_per_agent = models_per_agent;
116        self.current_role = role;
117        self
118    }
119
120    /// Builder method to set the maximum number of retry cycles.
121    ///
122    /// A retry cycle is when all agents have been exhausted and we start
123    /// over with exponential backoff.
124    pub fn with_max_cycles(mut self, max_cycles: u32) -> Self {
125        self.max_cycles = max_cycles;
126        self
127    }
128
129    pub fn current_agent(&self) -> Option<&String> {
130        self.agents.get(self.current_agent_index)
131    }
132
133    /// Get the currently selected model for the current agent.
134    ///
135    /// Returns `None` if:
136    /// - No models are configured
137    /// - The current agent index is out of bounds
138    /// - The current model index is out of bounds
139    pub fn current_model(&self) -> Option<&String> {
140        self.models_per_agent
141            .get(self.current_agent_index)
142            .and_then(|models| models.get(self.current_model_index))
143    }
144
145    pub fn is_exhausted(&self) -> bool {
146        self.retry_cycle >= self.max_cycles
147            && self.current_agent_index == 0
148            && self.current_model_index == 0
149    }
150
151    pub fn advance_to_next_model(&self) -> Self {
152        let mut new = self.clone();
153        if let Some(models) = new.models_per_agent.get(new.current_agent_index) {
154            if new.current_model_index + 1 < models.len() {
155                new.current_model_index += 1;
156            } else {
157                new.current_model_index = 0;
158            }
159        }
160        new
161    }
162
163    pub fn switch_to_next_agent(&self) -> Self {
164        let mut new = self.clone();
165        if new.current_agent_index + 1 < new.agents.len() {
166            new.current_agent_index += 1;
167            new.current_model_index = 0;
168        } else {
169            new.current_agent_index = 0;
170            new.current_model_index = 0;
171            new.retry_cycle += 1;
172        }
173        new
174    }
175
176    pub fn reset_for_role(&self, role: AgentRole) -> Self {
177        let mut new = self.clone();
178        new.current_role = role;
179        new.current_agent_index = 0;
180        new.current_model_index = 0;
181        new.retry_cycle = 0;
182        new
183    }
184
185    pub fn reset(&self) -> Self {
186        let mut new = self.clone();
187        new.current_agent_index = 0;
188        new.current_model_index = 0;
189        new
190    }
191
192    pub fn start_retry_cycle(&self) -> Self {
193        let mut new = self.clone();
194        new.current_agent_index = 0;
195        new.current_model_index = 0;
196        new.retry_cycle += 1;
197        new
198    }
199}
200
201/// Rebase operation state.
202///
203/// Tracks rebase progress through the state machine:
204/// NotStarted → InProgress → Conflicted → Completed/Skipped
205#[derive(Clone, Serialize, Deserialize, Debug)]
206pub enum RebaseState {
207    NotStarted,
208    InProgress {
209        original_head: String,
210        target_branch: String,
211    },
212    Conflicted {
213        original_head: String,
214        target_branch: String,
215        files: Vec<PathBuf>,
216        resolution_attempts: u32,
217    },
218    Completed {
219        new_head: String,
220    },
221    Skipped,
222}
223
224impl RebaseState {
225    #[doc(hidden)]
226    #[cfg(any(test, feature = "test-utils"))]
227    pub fn is_terminal(&self) -> bool {
228        matches!(self, RebaseState::Completed { .. } | RebaseState::Skipped)
229    }
230
231    pub fn current_head(&self) -> Option<String> {
232        match self {
233            RebaseState::NotStarted | RebaseState::Skipped => None,
234            RebaseState::InProgress { original_head, .. } => Some(original_head.clone()),
235            RebaseState::Conflicted { .. } => None,
236            RebaseState::Completed { new_head } => Some(new_head.clone()),
237        }
238    }
239
240    #[doc(hidden)]
241    #[cfg(any(test, feature = "test-utils"))]
242    pub fn is_in_progress(&self) -> bool {
243        matches!(
244            self,
245            RebaseState::InProgress { .. } | RebaseState::Conflicted { .. }
246        )
247    }
248}
249
250/// Maximum number of retry attempts when XML/format validation fails.
251///
252/// This applies across the pipeline for:
253/// - Commit message generation validation failures
254/// - Plan generation validation failures  
255/// - Development output validation failures
256/// - Review output validation failures
257///
258/// When an agent produces output that fails XML parsing or format validation,
259/// we retry with corrective prompts up to this many times before giving up.
260pub const MAX_VALIDATION_RETRY_ATTEMPTS: u32 = 100;
261
262/// Maximum number of developer validation retry attempts before giving up.
263///
264/// Specifically for developer iterations to reduce the development cycle time.
265/// This allows for faster iteration during development while keeping other
266/// validation retry attempts at their original values.
267pub const MAX_DEV_VALIDATION_RETRY_ATTEMPTS: u32 = 2;
268
269/// Commit generation state.
270///
271/// Tracks commit message generation progress through retries:
272/// NotStarted → Generating → Generated → Committed/Skipped
273#[derive(Clone, Serialize, Deserialize, Debug)]
274pub enum CommitState {
275    NotStarted,
276    Generating { attempt: u32, max_attempts: u32 },
277    Generated { message: String },
278    Committed { hash: String },
279    Skipped,
280}
281
282impl CommitState {
283    #[doc(hidden)]
284    #[cfg(any(test, feature = "test-utils"))]
285    pub fn is_terminal(&self) -> bool {
286        matches!(self, CommitState::Committed { .. } | CommitState::Skipped)
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn test_pipeline_state_initial() {
296        let state = PipelineState::initial(5, 2);
297        assert_eq!(state.phase, PipelinePhase::Planning);
298        assert_eq!(state.total_iterations, 5);
299        assert_eq!(state.total_reviewer_passes, 2);
300        assert!(!state.is_complete());
301    }
302
303    #[test]
304    fn test_agent_chain_initial() {
305        let chain = AgentChainState::initial();
306        assert!(chain.agents.is_empty());
307        assert_eq!(chain.current_agent_index, 0);
308        assert_eq!(chain.retry_cycle, 0);
309    }
310
311    #[test]
312    fn test_agent_chain_with_agents() {
313        let chain = AgentChainState::initial()
314            .with_agents(
315                vec!["claude".to_string(), "codex".to_string()],
316                vec![vec![], vec![]],
317                AgentRole::Developer,
318            )
319            .with_max_cycles(3);
320
321        assert_eq!(chain.agents.len(), 2);
322        assert_eq!(chain.current_agent(), Some(&"claude".to_string()));
323        assert_eq!(chain.max_cycles, 3);
324    }
325
326    #[test]
327    fn test_agent_chain_advance_to_next_model() {
328        let chain = AgentChainState::initial().with_agents(
329            vec!["claude".to_string()],
330            vec![vec!["model1".to_string(), "model2".to_string()]],
331            AgentRole::Developer,
332        );
333
334        let new_chain = chain.advance_to_next_model();
335        assert_eq!(new_chain.current_model_index, 1);
336        assert_eq!(new_chain.current_model(), Some(&"model2".to_string()));
337    }
338
339    #[test]
340    fn test_agent_chain_switch_to_next_agent() {
341        let chain = AgentChainState::initial().with_agents(
342            vec!["claude".to_string(), "codex".to_string()],
343            vec![vec![], vec![]],
344            AgentRole::Developer,
345        );
346
347        let new_chain = chain.switch_to_next_agent();
348        assert_eq!(new_chain.current_agent_index, 1);
349        assert_eq!(new_chain.current_agent(), Some(&"codex".to_string()));
350        assert_eq!(new_chain.retry_cycle, 0);
351    }
352
353    #[test]
354    fn test_agent_chain_exhausted() {
355        let chain = AgentChainState::initial()
356            .with_agents(
357                vec!["claude".to_string()],
358                vec![vec![]],
359                AgentRole::Developer,
360            )
361            .with_max_cycles(3);
362
363        let chain = chain.start_retry_cycle();
364        let chain = chain.start_retry_cycle();
365        let chain = chain.start_retry_cycle();
366
367        assert!(chain.is_exhausted());
368    }
369
370    #[test]
371    fn test_rebase_state_not_started() {
372        let state = RebaseState::NotStarted;
373        assert!(!state.is_terminal());
374        assert!(state.current_head().is_none());
375        assert!(!state.is_in_progress());
376    }
377
378    #[test]
379    fn test_rebase_state_in_progress() {
380        let state = RebaseState::InProgress {
381            original_head: "abc123".to_string(),
382            target_branch: "main".to_string(),
383        };
384        assert!(!state.is_terminal());
385        assert_eq!(state.current_head(), Some("abc123".to_string()));
386        assert!(state.is_in_progress());
387    }
388
389    #[test]
390    fn test_rebase_state_completed() {
391        let state = RebaseState::Completed {
392            new_head: "def456".to_string(),
393        };
394        assert!(state.is_terminal());
395        assert_eq!(state.current_head(), Some("def456".to_string()));
396        assert!(!state.is_in_progress());
397    }
398
399    #[test]
400    fn test_commit_state_not_started() {
401        let state = CommitState::NotStarted;
402        assert!(!state.is_terminal());
403    }
404
405    #[test]
406    fn test_commit_state_generating() {
407        let state = CommitState::Generating {
408            attempt: 1,
409            max_attempts: 3,
410        };
411        assert!(!state.is_terminal());
412    }
413
414    #[test]
415    fn test_commit_state_committed() {
416        let state = CommitState::Committed {
417            hash: "abc123".to_string(),
418        };
419        assert!(state.is_terminal());
420    }
421
422    #[test]
423    fn test_is_complete_during_finalizing() {
424        // Finalizing phase should NOT be complete - event loop must continue
425        // to execute the RestorePromptPermissions effect
426        let state = PipelineState {
427            phase: PipelinePhase::Finalizing,
428            ..PipelineState::initial(5, 2)
429        };
430        assert!(
431            !state.is_complete(),
432            "Finalizing phase should not be complete - event loop must continue"
433        );
434    }
435
436    #[test]
437    fn test_is_complete_after_finalization() {
438        // Complete phase IS complete
439        let state = PipelineState {
440            phase: PipelinePhase::Complete,
441            ..PipelineState::initial(5, 2)
442        };
443        assert!(state.is_complete(), "Complete phase should be complete");
444    }
445}