Skip to main content

ralph_workflow/reducer/state/continuation/
budget.rs

1//! Budget tracking logic for continuation attempts.
2//!
3//! Provides methods for tracking and checking budget exhaustion for:
4//! - XSD retries
5//! - Same-agent retries
6//! - Development continuations
7//! - Fix continuations
8
9use super::super::{ArtifactType, DevelopmentStatus, FixStatus, SameAgentRetryReason};
10use super::state::ContinuationState;
11
12impl ContinuationState {
13    /// Set the current artifact type being processed.
14    #[must_use]
15    pub fn with_artifact(mut self, artifact: ArtifactType) -> Self {
16        // Reset XSD retry state when switching artifacts, preserve everything else
17        self.current_artifact = Some(artifact);
18        self.xsd_retry_count = 0;
19        self.xsd_retry_pending = false;
20        self.xsd_retry_session_reuse_pending = false;
21        self.last_xsd_error = None;
22        self.last_review_xsd_error = None;
23        self.last_fix_xsd_error = None;
24        self
25    }
26
27    /// Mark XSD validation as failed, triggering a retry.
28    ///
29    /// For XSD retry, we want to re-invoke the same agent in the same session when possible,
30    /// to keep retries deterministic and to preserve provider-side context.
31    #[must_use]
32    pub const fn trigger_xsd_retry(mut self) -> Self {
33        self.xsd_retry_pending = true;
34        self.xsd_retry_count += 1;
35        self.xsd_retry_session_reuse_pending = true;
36        self
37    }
38
39    /// Clear XSD retry pending flag after starting retry.
40    #[must_use]
41    pub fn clear_xsd_retry_pending(mut self) -> Self {
42        self.xsd_retry_pending = false;
43        self.last_xsd_error = None;
44        self.last_review_xsd_error = None;
45        self.last_fix_xsd_error = None;
46        self
47    }
48
49    /// Check if XSD retries are exhausted.
50    #[must_use]
51    pub const fn xsd_retries_exhausted(&self) -> bool {
52        self.xsd_retry_count >= self.max_xsd_retry_count
53    }
54
55    /// Mark a same-agent retry as pending for a transient invocation failure.
56    #[must_use]
57    pub const fn trigger_same_agent_retry(mut self, reason: SameAgentRetryReason) -> Self {
58        self.same_agent_retry_pending = true;
59        self.same_agent_retry_count += 1;
60        self.same_agent_retry_reason = Some(reason);
61        self
62    }
63
64    /// Clear same-agent retry pending flag after starting retry.
65    #[must_use]
66    pub const fn clear_same_agent_retry_pending(mut self) -> Self {
67        self.same_agent_retry_pending = false;
68        self.same_agent_retry_reason = None;
69        self
70    }
71
72    /// Check if same-agent retries are exhausted.
73    #[must_use]
74    pub const fn same_agent_retries_exhausted(&self) -> bool {
75        self.same_agent_retry_count >= self.max_same_agent_retry_count
76    }
77
78    /// Mark continuation as pending (output valid but work incomplete).
79    #[must_use]
80    pub const fn trigger_continue(mut self) -> Self {
81        self.continue_pending = true;
82        self
83    }
84
85    /// Clear continue pending flag after starting continuation.
86    #[must_use]
87    pub const fn clear_continue_pending(mut self) -> Self {
88        self.continue_pending = false;
89        self
90    }
91
92    /// Check if continuation attempts are exhausted.
93    ///
94    /// Returns `true` when `continuation_attempt >= max_continue_count`.
95    ///
96    /// # Semantics
97    ///
98    /// The `continuation_attempt` counter tracks how many times work has been attempted:
99    /// - 0: Initial attempt (before any continuation)
100    /// - 1: After first continuation
101    /// - 2: After second continuation
102    /// - etc.
103    ///
104    /// With `max_continue_count = 3`:
105    /// - Attempts 0, 1, 2 are allowed (3 total)
106    /// - Attempt 3+ triggers exhaustion
107    ///
108    /// # Exhaustion Behavior
109    ///
110    /// When continuation budget is exhausted (`ContinuationBudgetExhausted` event):
111    /// - If all agents exhausted AND status is Failed/Partial → transition to `AwaitingDevFix`
112    /// - Otherwise → complete current iteration (via `IterationCompleted`) and advance to next iteration
113    ///
114    /// This ensures bounded execution: the system never restarts the continuation cycle
115    /// with a fresh agent within the same iteration, preventing infinite loops when work
116    /// remains incomplete despite exhausting the continuation budget.
117    ///
118    /// # Naming Note
119    ///
120    /// The field is named `max_continue_count` rather than `max_total_attempts` because
121    /// it historically represented the maximum number of continuations. The actual
122    /// semantics are "maximum total attempts including initial".
123    #[must_use]
124    pub const fn continuations_exhausted(&self) -> bool {
125        self.continuation_attempt >= self.max_continue_count
126    }
127
128    /// Trigger a continuation with context from the previous attempt.
129    ///
130    /// Sets both `context_write_pending` (to write continuation context) and
131    /// `continue_pending` (to trigger the continuation flow in orchestration).
132    #[must_use]
133    pub fn trigger_continuation(
134        mut self,
135        status: DevelopmentStatus,
136        summary: String,
137        files_changed: Option<Vec<String>>,
138        next_steps: Option<String>,
139    ) -> Self {
140        self.previous_status = Some(status);
141        self.previous_summary = Some(summary);
142        self.previous_files_changed = files_changed.map(std::vec::Vec::into_boxed_slice);
143        self.previous_next_steps = next_steps;
144
145        // CRITICAL FIX: Check boundary BEFORE incrementing counter.
146        // With max_continue_count = 3:
147        // - At attempt 0: next_attempt = 1, 1 < 3 → continue (correct)
148        // - At attempt 1: next_attempt = 2, 2 < 3 → continue (correct)
149        // - At attempt 2: next_attempt = 3, 3 >= 3 → exhaust WITHOUT incrementing (correct)
150        //
151        // BEFORE FIX: Line 128 set continuation_attempt = next_attempt even when the defensive
152        // check triggered, allowing the counter to reach the boundary value (3) instead of
153        // staying below it (2). This caused the off-by-one bug where the system allowed
154        // one extra attempt beyond the configured limit.
155        //
156        // AFTER FIX: When the defensive check triggers, we return immediately WITHOUT updating
157        // continuation_attempt. The counter stays at 2, preventing the off-by-one bug.
158        let next_attempt = self.continuation_attempt.saturating_add(1);
159        if next_attempt >= self.max_continue_count {
160            // At boundary: do not schedule another continuation AND do not increment counter.
161            // The exhaustion check in OutcomeApplied (iteration_reducer.rs:169-171)
162            // already emitted ContinuationBudgetExhausted, which will reset this counter.
163            // Clearing these flags ensures orchestration doesn't try to continue.
164            self.continue_pending = false;
165            self.context_write_pending = false;
166            self.context_cleanup_pending = false;
167            return self;
168        }
169
170        self.continuation_attempt = next_attempt;
171        self.invalid_output_attempts = 0;
172        self.context_write_pending = true;
173        self.context_cleanup_pending = false;
174        // Reset XSD retry count for new continuation attempt
175        self.xsd_retry_count = 0;
176        self.xsd_retry_pending = false;
177        self.xsd_retry_session_reuse_pending = false;
178        self.last_xsd_error = None;
179        self.last_review_xsd_error = None;
180        self.last_fix_xsd_error = None;
181        // Reset same-agent retry state for new continuation attempt
182        self.same_agent_retry_count = 0;
183        self.same_agent_retry_pending = false;
184        self.same_agent_retry_reason = None;
185        // Set continue_pending to trigger continuation in orchestration
186        self.continue_pending = true;
187        // Fix continuation fields and loop detection already preserved
188        self
189    }
190
191    // =========================================================================
192    // Fix continuation methods
193    // =========================================================================
194
195    /// Check if fix continuations are exhausted.
196    ///
197    /// Semantics match `continuations_exhausted()`: with default `max_fix_continue_count`
198    /// of 10, attempts 0 through 9 are allowed (10 total), attempt 10+ is exhausted.
199    #[must_use]
200    pub const fn fix_continuations_exhausted(&self) -> bool {
201        self.fix_continuation_attempt >= self.max_fix_continue_count
202    }
203
204    /// Trigger a fix continuation with status context.
205    #[must_use]
206    pub fn trigger_fix_continuation(mut self, status: FixStatus, summary: Option<String>) -> Self {
207        self.fix_status = Some(status);
208        self.fix_previous_summary = summary;
209        self.fix_continuation_attempt += 1;
210        self.fix_continue_pending = true;
211        // Reset XSD retry state for new continuation
212        self.xsd_retry_count = 0;
213        self.xsd_retry_pending = false;
214        self.xsd_retry_session_reuse_pending = false;
215        self.last_xsd_error = None;
216        self.last_review_xsd_error = None;
217        self.last_fix_xsd_error = None;
218        // Reset invalid output attempts for new continuation
219        self.invalid_output_attempts = 0;
220        // Clear other pending flags
221        self.context_write_pending = false;
222        self.context_cleanup_pending = false;
223        self.continue_pending = false;
224        self
225    }
226
227    /// Clear fix continuation pending flag after starting continuation.
228    #[must_use]
229    pub const fn clear_fix_continue_pending(mut self) -> Self {
230        self.fix_continue_pending = false;
231        self
232    }
233
234    /// Reset fix continuation state (e.g., when entering a new review pass).
235    #[must_use]
236    pub fn reset_fix_continuation(mut self) -> Self {
237        self.fix_status = None;
238        self.fix_previous_summary = None;
239        self.fix_continuation_attempt = 0;
240        self.fix_continue_pending = false;
241        self
242    }
243}