Skip to main content

ralph_workflow/app/
config.rs

1//! Event loop configuration and initialization.
2//!
3//! This module defines configuration types and initialization logic for the
4//! reducer-based event loop.
5
6use crate::phases::PhaseContext;
7use crate::reducer::event::PipelinePhase;
8use crate::reducer::state::ContinuationState;
9use crate::reducer::PipelineState;
10
11/// Create initial pipeline state with continuation limits from config.
12///
13/// This function creates a `PipelineState` with XSD retry and continuation limits
14/// loaded from the config, ensuring these values are available for the reducer
15/// to make deterministic retry decisions.
16pub fn create_initial_state_with_config(ctx: &PhaseContext<'_>) -> PipelineState {
17    // Config semantics: max_dev_continuations counts continuation attempts *beyond*
18    // the initial attempt. ContinuationState::max_continue_count semantics are
19    // "maximum total attempts including initial".
20
21    // CRITICAL: max_dev_continuations should always be Some() when loaded via config_from_unified().
22    // The serde defaults in UnifiedConfig ensure these fields are never missing.
23    // The unwrap_or() here is a defensive fallback for edge cases:
24    // - Config::default() or Config::test_default()
25    // - Direct Config construction in tests without going through config_from_unified()
26    //
27    // In debug builds, we assert that the value is Some() to catch config loading bugs early.
28    debug_assert!(
29        ctx.config.max_dev_continuations.is_some(),
30        "BUG: max_dev_continuations is None when it should always have a value from config loading. \
31         This indicates config_from_unified() did not properly set the field, or Config was \
32         constructed directly without defaults."
33    );
34    debug_assert!(
35        ctx.config.max_xsd_retries.is_some(),
36        "BUG: max_xsd_retries is None when it should always have a value from config loading."
37    );
38    debug_assert!(
39        ctx.config.max_same_agent_retries.is_some(),
40        "BUG: max_same_agent_retries is None when it should always have a value from config loading."
41    );
42    debug_assert!(
43        ctx.config.max_commit_residual_retries.is_some(),
44        "BUG: max_commit_residual_retries is None when it should always have a value from config loading."
45    );
46
47    // CRITICAL SAFETY MECHANISM: Apply unconditional default of 2 (3 total attempts) when None.
48    // This ensures bounded continuation even if Config was constructed without going through
49    // config_from_unified() (e.g., Config::default(), tests). This is the PRIMARY DEFENSE
50    // against infinite continuation loops when max_dev_continuations is missing.
51    //
52    // VERIFIED FIX: This unwrap_or(2) is what prevents the infinite loop bug reported by user.
53    // With max_dev_continuations = 2:
54    // - max_continue_count = 1 + 2 = 3
55    // - Attempts 0, 1, 2 are allowed (3 total)
56    // - Attempt 3+ is exhausted via OutcomeApplied check: (attempt + 1 >= 3)
57    //
58    // The defensive check in trigger_continuation provides additional safety by preventing
59    // counter increment when next_attempt >= max_continue_count.
60    let max_dev_continuations = ctx.config.max_dev_continuations.unwrap_or(2);
61    let max_continue_count = max_dev_continuations.saturating_add(1);
62
63    // SAFETY ASSERTION: when max_dev_continuations is absent, unwrap_or(2)
64    // must produce the default total-attempts cap of 3.
65    if ctx.config.max_dev_continuations.is_none() {
66        debug_assert_eq!(
67            max_continue_count, 3,
68            "BUG: missing max_dev_continuations must default to 3 total attempts. Got: {max_continue_count}"
69        );
70    }
71
72    let continuation = ContinuationState::with_limits(
73        ctx.config.max_xsd_retries.unwrap_or(10),
74        max_continue_count,
75        ctx.config.max_same_agent_retries.unwrap_or(2),
76    );
77    let state = PipelineState::initial_with_continuation(
78        ctx.config.developer_iters,
79        ctx.config.reviewer_reviews,
80        &continuation,
81    );
82    let max_commit_residual_retries =
83        u8::try_from(ctx.config.max_commit_residual_retries.unwrap_or(10)).unwrap_or(u8::MAX);
84
85    // Inject a checkpoint-safe (redacted) view of runtime cloud config.
86    // This ensures pure orchestration can derive cloud effects when enabled,
87    // without ever storing secrets in reducer state.
88    let cloud = crate::config::CloudStateConfig::disabled();
89
90    PipelineState {
91        max_commit_residual_retries,
92        cloud,
93        ..state
94    }
95}
96
97/// Overlay checkpoint-derived progress onto a config-derived base state.
98///
99/// This is used for resume: budgets/limits remain config-driven (from `base_state`),
100/// while progress counters and histories are restored from the checkpoint-migrated
101/// `PipelineState`.
102///
103/// NOTE: `base_state.cloud` is intentionally preserved (it is derived from
104/// runtime env and is already redacted/credential-free).
105pub fn overlay_checkpoint_progress_onto_base_state(
106    base_state: PipelineState,
107    migrated: PipelineState,
108    execution_history_limit: usize,
109) -> PipelineState {
110    let migrated_execution_history = migrated.execution_history().clone();
111
112    let cloud = base_state.cloud.clone();
113
114    let new_execution_history = base_state
115        .with_execution_history(migrated_execution_history, execution_history_limit)
116        .execution_history;
117
118    PipelineState {
119        phase: migrated.phase,
120        iteration: migrated.iteration,
121        total_iterations: migrated.total_iterations,
122        reviewer_pass: migrated.reviewer_pass,
123        total_reviewer_passes: migrated.total_reviewer_passes,
124        rebase: migrated.rebase,
125        execution_history: new_execution_history,
126        prompt_inputs: migrated.prompt_inputs,
127        prompt_permissions: migrated.prompt_permissions,
128        prompt_history: migrated.prompt_history,
129        metrics: migrated.metrics,
130        recovery_epoch: migrated.recovery_epoch,
131        recovery_escalation_level: migrated.recovery_escalation_level,
132        dev_fix_attempt_count: migrated.dev_fix_attempt_count,
133        failed_phase_for_recovery: migrated.failed_phase_for_recovery,
134        interrupted_by_user: migrated.interrupted_by_user,
135        pending_push_commit: migrated.pending_push_commit,
136        git_auth_configured: migrated.git_auth_configured,
137        pr_created: migrated.pr_created,
138        pr_url: migrated.pr_url,
139        pr_number: migrated.pr_number,
140        push_count: migrated.push_count,
141        push_retry_count: migrated.push_retry_count,
142        last_push_error: migrated.last_push_error,
143        unpushed_commits: migrated.unpushed_commits,
144        last_pushed_commit: migrated.last_pushed_commit,
145        // Preserve cloud from base_state (runtime env-derived, redacted)
146        cloud,
147        // Take all other fields from migrated that aren't explicitly set above
148        ..migrated
149    }
150}
151
152/// Maximum iterations for the main event loop to prevent infinite loops.
153///
154/// This is a safety limit - the pipeline should complete well before this limit
155/// under normal circumstances. If reached, it indicates either a bug in the
156/// reducer logic or an extremely complex project.
157///
158/// NOTE: Even `1_000_000` can still be too low for extremely slow-progress runs.
159/// If this cap is hit in practice, prefer making it configurable and/or
160/// investigating why the reducer is not converging.
161pub const MAX_EVENT_LOOP_ITERATIONS: usize = 1_000_000;
162
163#[cfg(test)]
164mod resume_overlay_tests {
165    use super::overlay_checkpoint_progress_onto_base_state;
166    use crate::config::{CloudStateConfig, GitAuthStateMethod, GitRemoteStateConfig};
167    use crate::reducer::event::PipelinePhase;
168    use crate::reducer::PipelineState;
169
170    #[test]
171    fn resume_overlay_restores_cloud_resume_fields_but_preserves_runtime_cloud() {
172        let base = PipelineState {
173            cloud: CloudStateConfig {
174                enabled: true,
175                api_url: None,
176                run_id: Some("run_from_env".to_string()),
177                heartbeat_interval_secs: 30,
178                graceful_degradation: true,
179                git_remote: GitRemoteStateConfig {
180                    auth_method: GitAuthStateMethod::Token {
181                        username: "x-access-token".to_string(),
182                    },
183                    push_branch: "env_branch".to_string(),
184                    create_pr: true,
185                    pr_title_template: None,
186                    pr_body_template: None,
187                    pr_base_branch: None,
188                    force_push: false,
189                    remote_name: "origin".to_string(),
190                },
191            },
192            ..PipelineState::initial(3, 2)
193        };
194
195        let migrated = PipelineState {
196            cloud: CloudStateConfig::disabled(),
197            pending_push_commit: Some("abc123".to_string()),
198            git_auth_configured: true,
199            pr_created: true,
200            pr_url: Some("https://example.com/pr/1".to_string()),
201            pr_number: Some(1),
202            push_count: 7,
203            push_retry_count: 2,
204            last_push_error: Some("push failed".to_string()),
205            unpushed_commits: vec!["deadbeef".to_string()],
206            last_pushed_commit: Some("beadfeed".to_string()),
207            ..PipelineState::initial(999, 999)
208        };
209
210        let base = overlay_checkpoint_progress_onto_base_state(base, migrated, 1000);
211
212        // Runtime (env-derived) redacted config is preserved.
213        assert!(base.cloud.enabled);
214        assert_eq!(base.cloud.run_id.as_deref(), Some("run_from_env"));
215        assert_eq!(base.cloud.git_remote.push_branch.as_str(), "env_branch");
216
217        // Cloud resume state is restored.
218        assert_eq!(base.pending_push_commit.as_deref(), Some("abc123"));
219        assert!(base.git_auth_configured);
220        assert!(base.pr_created);
221        assert_eq!(base.pr_url.as_deref(), Some("https://example.com/pr/1"));
222        assert_eq!(base.pr_number, Some(1));
223        assert_eq!(base.push_count, 7);
224        assert_eq!(base.push_retry_count, 2);
225        assert_eq!(base.last_push_error.as_deref(), Some("push failed"));
226        assert_eq!(base.unpushed_commits, vec!["deadbeef".to_string()]);
227        assert_eq!(base.last_pushed_commit.as_deref(), Some("beadfeed"));
228    }
229
230    #[test]
231    fn resume_overlay_restores_recovery_and_interrupt_fields() {
232        let base = PipelineState::initial(3, 2);
233
234        let migrated = PipelineState {
235            dev_fix_attempt_count: 42,
236            recovery_epoch: 7,
237            recovery_escalation_level: 3,
238            failed_phase_for_recovery: Some(PipelinePhase::Review),
239            interrupted_by_user: true,
240            ..PipelineState::initial(999, 999)
241        };
242
243        let base = overlay_checkpoint_progress_onto_base_state(base, migrated, 1000);
244
245        assert_eq!(base.dev_fix_attempt_count, 42);
246        assert_eq!(base.recovery_epoch, 7);
247        assert_eq!(base.recovery_escalation_level, 3);
248        assert_eq!(base.failed_phase_for_recovery, Some(PipelinePhase::Review));
249        assert!(
250            base.interrupted_by_user,
251            "interrupted_by_user must be restored from the migrated checkpoint state"
252        );
253    }
254}
255
256/// Configuration for event loop.
257#[derive(Copy, Clone, Debug)]
258pub struct EventLoopConfig {
259    /// Maximum number of iterations to prevent infinite loops.
260    pub max_iterations: usize,
261}
262
263/// Result of event loop execution.
264#[derive(Debug, Clone)]
265pub struct EventLoopResult {
266    /// Whether pipeline completed successfully.
267    pub completed: bool,
268    /// Total events processed.
269    pub events_processed: usize,
270    /// Final reducer phase when the loop stopped.
271    pub final_phase: PipelinePhase,
272    /// Final pipeline state (for metrics and summary).
273    pub final_state: PipelineState,
274}