Skip to main content

ralph_workflow/app/event_loop/
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 = 1 + max_dev_continuations;
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 mut state = PipelineState::initial_with_continuation(
78        ctx.config.developer_iters,
79        ctx.config.reviewer_reviews,
80        &continuation,
81    );
82    state.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    state.cloud = crate::config::CloudStateConfig::from(ctx.cloud);
89
90    state
91}
92
93/// Overlay checkpoint-derived progress onto a config-derived base state.
94///
95/// This is used for resume: budgets/limits remain config-driven (from `base_state`),
96/// while progress counters and histories are restored from the checkpoint-migrated
97/// `PipelineState`.
98///
99/// NOTE: `base_state.cloud` is intentionally preserved (it is derived from
100/// runtime env and is already redacted/credential-free).
101pub fn overlay_checkpoint_progress_onto_base_state(
102    base_state: &mut PipelineState,
103    migrated: PipelineState,
104    execution_history_limit: usize,
105) {
106    let migrated_execution_history = migrated.execution_history().clone();
107
108    base_state.phase = migrated.phase;
109    base_state.iteration = migrated.iteration;
110    base_state.total_iterations = migrated.total_iterations;
111    base_state.reviewer_pass = migrated.reviewer_pass;
112    base_state.total_reviewer_passes = migrated.total_reviewer_passes;
113    base_state.rebase = migrated.rebase;
114    base_state
115        .replace_execution_history_bounded(migrated_execution_history, execution_history_limit);
116    base_state.prompt_inputs = migrated.prompt_inputs;
117    base_state.prompt_permissions = migrated.prompt_permissions;
118    base_state.prompt_history = migrated.prompt_history;
119    base_state.metrics = migrated.metrics;
120
121    // Restore resume-critical recovery/dev-fix/interrupt fields.
122    // These are checkpoint-derived and must not be dropped, otherwise resumed runs
123    // can behave as if recovery/dev-fix state never happened.
124    base_state.recovery_epoch = migrated.recovery_epoch;
125    base_state.recovery_escalation_level = migrated.recovery_escalation_level;
126    base_state.dev_fix_attempt_count = migrated.dev_fix_attempt_count;
127    base_state.failed_phase_for_recovery = migrated.failed_phase_for_recovery;
128    base_state.interrupted_by_user = migrated.interrupted_by_user;
129
130    // Restore cloud resume continuity from checkpoint-migrated state.
131    // Keep `base_state.cloud` (runtime env-derived, redacted).
132    base_state.pending_push_commit = migrated.pending_push_commit;
133    base_state.git_auth_configured = migrated.git_auth_configured;
134    base_state.pr_created = migrated.pr_created;
135    base_state.pr_url = migrated.pr_url;
136    base_state.pr_number = migrated.pr_number;
137    base_state.push_count = migrated.push_count;
138    base_state.push_retry_count = migrated.push_retry_count;
139    base_state.last_push_error = migrated.last_push_error;
140    base_state.unpushed_commits = migrated.unpushed_commits;
141    base_state.last_pushed_commit = migrated.last_pushed_commit;
142}
143
144/// Maximum iterations for the main event loop to prevent infinite loops.
145///
146/// This is a safety limit - the pipeline should complete well before this limit
147/// under normal circumstances. If reached, it indicates either a bug in the
148/// reducer logic or an extremely complex project.
149///
150/// NOTE: Even `1_000_000` can still be too low for extremely slow-progress runs.
151/// If this cap is hit in practice, prefer making it configurable and/or
152/// investigating why the reducer is not converging.
153pub const MAX_EVENT_LOOP_ITERATIONS: usize = 1_000_000;
154
155#[cfg(test)]
156mod resume_overlay_tests {
157    use super::overlay_checkpoint_progress_onto_base_state;
158    use crate::config::{CloudStateConfig, GitAuthStateMethod, GitRemoteStateConfig};
159    use crate::reducer::event::PipelinePhase;
160    use crate::reducer::PipelineState;
161
162    #[test]
163    fn resume_overlay_restores_cloud_resume_fields_but_preserves_runtime_cloud() {
164        let mut base = PipelineState::initial(3, 2);
165        base.cloud = CloudStateConfig {
166            enabled: true,
167            api_url: None,
168            run_id: Some("run_from_env".to_string()),
169            heartbeat_interval_secs: 30,
170            graceful_degradation: true,
171            git_remote: GitRemoteStateConfig {
172                auth_method: GitAuthStateMethod::Token {
173                    username: "x-access-token".to_string(),
174                },
175                push_branch: "env_branch".to_string(),
176                create_pr: true,
177                pr_title_template: None,
178                pr_body_template: None,
179                pr_base_branch: None,
180                force_push: false,
181                remote_name: "origin".to_string(),
182            },
183        };
184
185        let mut migrated = PipelineState::initial(999, 999);
186        migrated.cloud = CloudStateConfig::disabled();
187        migrated.pending_push_commit = Some("abc123".to_string());
188        migrated.git_auth_configured = true;
189        migrated.pr_created = true;
190        migrated.pr_url = Some("https://example.com/pr/1".to_string());
191        migrated.pr_number = Some(1);
192        migrated.push_count = 7;
193        migrated.push_retry_count = 2;
194        migrated.last_push_error = Some("push failed".to_string());
195        migrated.unpushed_commits = vec!["deadbeef".to_string()];
196        migrated.last_pushed_commit = Some("beadfeed".to_string());
197
198        overlay_checkpoint_progress_onto_base_state(&mut base, migrated, 1000);
199
200        // Runtime (env-derived) redacted config is preserved.
201        assert!(base.cloud.enabled);
202        assert_eq!(base.cloud.run_id.as_deref(), Some("run_from_env"));
203        assert_eq!(base.cloud.git_remote.push_branch.as_str(), "env_branch");
204
205        // Cloud resume state is restored.
206        assert_eq!(base.pending_push_commit.as_deref(), Some("abc123"));
207        assert!(base.git_auth_configured);
208        assert!(base.pr_created);
209        assert_eq!(base.pr_url.as_deref(), Some("https://example.com/pr/1"));
210        assert_eq!(base.pr_number, Some(1));
211        assert_eq!(base.push_count, 7);
212        assert_eq!(base.push_retry_count, 2);
213        assert_eq!(base.last_push_error.as_deref(), Some("push failed"));
214        assert_eq!(base.unpushed_commits, vec!["deadbeef".to_string()]);
215        assert_eq!(base.last_pushed_commit.as_deref(), Some("beadfeed"));
216    }
217
218    #[test]
219    fn resume_overlay_restores_recovery_and_interrupt_fields() {
220        let mut base = PipelineState::initial(3, 2);
221
222        let mut migrated = PipelineState::initial(999, 999);
223        migrated.dev_fix_attempt_count = 42;
224        migrated.recovery_epoch = 7;
225        migrated.recovery_escalation_level = 3;
226        migrated.failed_phase_for_recovery = Some(PipelinePhase::Review);
227        migrated.interrupted_by_user = true;
228
229        overlay_checkpoint_progress_onto_base_state(&mut base, migrated, 1000);
230
231        assert_eq!(base.dev_fix_attempt_count, 42);
232        assert_eq!(base.recovery_epoch, 7);
233        assert_eq!(base.recovery_escalation_level, 3);
234        assert_eq!(base.failed_phase_for_recovery, Some(PipelinePhase::Review));
235        assert!(
236            base.interrupted_by_user,
237            "interrupted_by_user must be restored from the migrated checkpoint state"
238        );
239    }
240}
241
242/// Configuration for event loop.
243#[derive(Copy, Clone, Debug)]
244pub struct EventLoopConfig {
245    /// Maximum number of iterations to prevent infinite loops.
246    pub max_iterations: usize,
247}
248
249/// Result of event loop execution.
250#[derive(Debug, Clone)]
251pub struct EventLoopResult {
252    /// Whether pipeline completed successfully.
253    pub completed: bool,
254    /// Total events processed.
255    pub events_processed: usize,
256    /// Final reducer phase when the loop stopped.
257    pub final_phase: PipelinePhase,
258    /// Final pipeline state (for metrics and summary).
259    pub final_state: PipelineState,
260}