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    #[doc(hidden)]
121    pub fn with_max_cycles(mut self, max_cycles: u32) -> Self {
122        self.max_cycles = max_cycles;
123        self
124    }
125
126    pub fn current_agent(&self) -> Option<&String> {
127        self.agents.get(self.current_agent_index)
128    }
129
130    #[doc(hidden)]
131    pub fn current_model(&self) -> Option<&String> {
132        self.models_per_agent
133            .get(self.current_agent_index)
134            .and_then(|models| models.get(self.current_model_index))
135    }
136
137    pub fn is_exhausted(&self) -> bool {
138        self.retry_cycle >= self.max_cycles
139            && self.current_agent_index == 0
140            && self.current_model_index == 0
141    }
142
143    pub fn advance_to_next_model(&self) -> Self {
144        let mut new = self.clone();
145        if let Some(models) = new.models_per_agent.get(new.current_agent_index) {
146            if new.current_model_index + 1 < models.len() {
147                new.current_model_index += 1;
148            } else {
149                new.current_model_index = 0;
150            }
151        }
152        new
153    }
154
155    pub fn switch_to_next_agent(&self) -> Self {
156        let mut new = self.clone();
157        if new.current_agent_index + 1 < new.agents.len() {
158            new.current_agent_index += 1;
159            new.current_model_index = 0;
160        } else {
161            new.current_agent_index = 0;
162            new.current_model_index = 0;
163            new.retry_cycle += 1;
164        }
165        new
166    }
167
168    pub fn reset_for_role(&self, role: AgentRole) -> Self {
169        let mut new = self.clone();
170        new.current_role = role;
171        new.current_agent_index = 0;
172        new.current_model_index = 0;
173        new.retry_cycle = 0;
174        new
175    }
176
177    pub fn reset(&self) -> Self {
178        let mut new = self.clone();
179        new.current_agent_index = 0;
180        new.current_model_index = 0;
181        new
182    }
183
184    pub fn start_retry_cycle(&self) -> Self {
185        let mut new = self.clone();
186        new.current_agent_index = 0;
187        new.current_model_index = 0;
188        new.retry_cycle += 1;
189        new
190    }
191}
192
193/// Rebase operation state.
194///
195/// Tracks rebase progress through the state machine:
196/// NotStarted → InProgress → Conflicted → Completed/Skipped
197#[derive(Clone, Serialize, Deserialize, Debug)]
198pub enum RebaseState {
199    NotStarted,
200    InProgress {
201        original_head: String,
202        target_branch: String,
203    },
204    Conflicted {
205        original_head: String,
206        target_branch: String,
207        files: Vec<PathBuf>,
208        resolution_attempts: u32,
209    },
210    Completed {
211        new_head: String,
212    },
213    Skipped,
214}
215
216impl RebaseState {
217    #[doc(hidden)]
218    #[cfg(any(test, feature = "test-utils"))]
219    pub fn is_terminal(&self) -> bool {
220        matches!(self, RebaseState::Completed { .. } | RebaseState::Skipped)
221    }
222
223    pub fn current_head(&self) -> Option<String> {
224        match self {
225            RebaseState::NotStarted | RebaseState::Skipped => None,
226            RebaseState::InProgress { original_head, .. } => Some(original_head.clone()),
227            RebaseState::Conflicted { .. } => None,
228            RebaseState::Completed { new_head } => Some(new_head.clone()),
229        }
230    }
231
232    #[doc(hidden)]
233    #[cfg(any(test, feature = "test-utils"))]
234    pub fn is_in_progress(&self) -> bool {
235        matches!(
236            self,
237            RebaseState::InProgress { .. } | RebaseState::Conflicted { .. }
238        )
239    }
240}
241
242/// Maximum number of retry attempts when XML/format validation fails.
243///
244/// This applies across the pipeline for:
245/// - Commit message generation validation failures
246/// - Plan generation validation failures  
247/// - Development output validation failures
248/// - Review output validation failures
249///
250/// When an agent produces output that fails XML parsing or format validation,
251/// we retry with corrective prompts up to this many times before giving up.
252pub const MAX_VALIDATION_RETRY_ATTEMPTS: u32 = 100;
253
254/// Maximum number of developer validation retry attempts before giving up.
255///
256/// Specifically for developer iterations to reduce the development cycle time.
257/// This allows for faster iteration during development while keeping other
258/// validation retry attempts at their original values.
259pub const MAX_DEV_VALIDATION_RETRY_ATTEMPTS: u32 = 2;
260
261/// Commit generation state.
262///
263/// Tracks commit message generation progress through retries:
264/// NotStarted → Generating → Generated → Committed/Skipped
265#[derive(Clone, Serialize, Deserialize, Debug)]
266pub enum CommitState {
267    NotStarted,
268    Generating { attempt: u32, max_attempts: u32 },
269    Generated { message: String },
270    Committed { hash: String },
271    Skipped,
272}
273
274impl CommitState {
275    #[doc(hidden)]
276    #[cfg(any(test, feature = "test-utils"))]
277    pub fn is_terminal(&self) -> bool {
278        matches!(self, CommitState::Committed { .. } | CommitState::Skipped)
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_pipeline_state_initial() {
288        let state = PipelineState::initial(5, 2);
289        assert_eq!(state.phase, PipelinePhase::Planning);
290        assert_eq!(state.total_iterations, 5);
291        assert_eq!(state.total_reviewer_passes, 2);
292        assert!(!state.is_complete());
293    }
294
295    #[test]
296    fn test_agent_chain_initial() {
297        let chain = AgentChainState::initial();
298        assert!(chain.agents.is_empty());
299        assert_eq!(chain.current_agent_index, 0);
300        assert_eq!(chain.retry_cycle, 0);
301    }
302
303    #[test]
304    fn test_agent_chain_with_agents() {
305        let chain = AgentChainState::initial()
306            .with_agents(
307                vec!["claude".to_string(), "codex".to_string()],
308                vec![vec![], vec![]],
309                AgentRole::Developer,
310            )
311            .with_max_cycles(3);
312
313        assert_eq!(chain.agents.len(), 2);
314        assert_eq!(chain.current_agent(), Some(&"claude".to_string()));
315        assert_eq!(chain.max_cycles, 3);
316    }
317
318    #[test]
319    fn test_agent_chain_advance_to_next_model() {
320        let chain = AgentChainState::initial().with_agents(
321            vec!["claude".to_string()],
322            vec![vec!["model1".to_string(), "model2".to_string()]],
323            AgentRole::Developer,
324        );
325
326        let new_chain = chain.advance_to_next_model();
327        assert_eq!(new_chain.current_model_index, 1);
328        assert_eq!(new_chain.current_model(), Some(&"model2".to_string()));
329    }
330
331    #[test]
332    fn test_agent_chain_switch_to_next_agent() {
333        let chain = AgentChainState::initial().with_agents(
334            vec!["claude".to_string(), "codex".to_string()],
335            vec![vec![], vec![]],
336            AgentRole::Developer,
337        );
338
339        let new_chain = chain.switch_to_next_agent();
340        assert_eq!(new_chain.current_agent_index, 1);
341        assert_eq!(new_chain.current_agent(), Some(&"codex".to_string()));
342        assert_eq!(new_chain.retry_cycle, 0);
343    }
344
345    #[test]
346    fn test_agent_chain_exhausted() {
347        let chain = AgentChainState::initial()
348            .with_agents(
349                vec!["claude".to_string()],
350                vec![vec![]],
351                AgentRole::Developer,
352            )
353            .with_max_cycles(3);
354
355        let chain = chain.start_retry_cycle();
356        let chain = chain.start_retry_cycle();
357        let chain = chain.start_retry_cycle();
358
359        assert!(chain.is_exhausted());
360    }
361
362    #[test]
363    fn test_rebase_state_not_started() {
364        let state = RebaseState::NotStarted;
365        assert!(!state.is_terminal());
366        assert!(state.current_head().is_none());
367        assert!(!state.is_in_progress());
368    }
369
370    #[test]
371    fn test_rebase_state_in_progress() {
372        let state = RebaseState::InProgress {
373            original_head: "abc123".to_string(),
374            target_branch: "main".to_string(),
375        };
376        assert!(!state.is_terminal());
377        assert_eq!(state.current_head(), Some("abc123".to_string()));
378        assert!(state.is_in_progress());
379    }
380
381    #[test]
382    fn test_rebase_state_completed() {
383        let state = RebaseState::Completed {
384            new_head: "def456".to_string(),
385        };
386        assert!(state.is_terminal());
387        assert_eq!(state.current_head(), Some("def456".to_string()));
388        assert!(!state.is_in_progress());
389    }
390
391    #[test]
392    fn test_commit_state_not_started() {
393        let state = CommitState::NotStarted;
394        assert!(!state.is_terminal());
395    }
396
397    #[test]
398    fn test_commit_state_generating() {
399        let state = CommitState::Generating {
400            attempt: 1,
401            max_attempts: 3,
402        };
403        assert!(!state.is_terminal());
404    }
405
406    #[test]
407    fn test_commit_state_committed() {
408        let state = CommitState::Committed {
409            hash: "abc123".to_string(),
410        };
411        assert!(state.is_terminal());
412    }
413
414    #[test]
415    fn test_is_complete_during_finalizing() {
416        // Finalizing phase should NOT be complete - event loop must continue
417        // to execute the RestorePromptPermissions effect
418        let state = PipelineState {
419            phase: PipelinePhase::Finalizing,
420            ..PipelineState::initial(5, 2)
421        };
422        assert!(
423            !state.is_complete(),
424            "Finalizing phase should not be complete - event loop must continue"
425        );
426    }
427
428    #[test]
429    fn test_is_complete_after_finalization() {
430        // Complete phase IS complete
431        let state = PipelineState {
432            phase: PipelinePhase::Complete,
433            ..PipelineState::initial(5, 2)
434        };
435        assert!(state.is_complete(), "Complete phase should be complete");
436    }
437}