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 3, attempts 0, 1, 2 are allowed (3 total), attempt 3+ 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}