ralph_workflow/reducer/state/continuation.rs
1// Continuation state for development and fix iterations.
2//
3// Contains ContinuationState and its methods.
4
5/// Continuation state for development iterations.
6///
7/// Tracks context from previous attempts within the same iteration to enable
8/// continuation-aware prompting when status is "partial" or "failed".
9///
10/// # When Fields Are Populated
11///
12/// - `previous_status`: Set when DevelopmentIterationContinuationTriggered event fires
13/// - `previous_summary`: Set when DevelopmentIterationContinuationTriggered event fires
14/// - `previous_files_changed`: Set when DevelopmentIterationContinuationTriggered event fires
15/// - `previous_next_steps`: Set when DevelopmentIterationContinuationTriggered event fires
16/// - `continuation_attempt`: Incremented on each continuation within same iteration
17///
18/// # Reset Triggers
19///
20/// The continuation state is reset (cleared) when:
21/// - A new iteration starts (DevelopmentIterationStarted event)
22/// - Status becomes "completed" (ContinuationSucceeded event)
23/// - Phase transitions away from Development
24#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
25pub struct ContinuationState {
26 /// Status from the previous attempt ("partial" or "failed").
27 pub previous_status: Option<DevelopmentStatus>,
28 /// Summary of what was accomplished in the previous attempt.
29 pub previous_summary: Option<String>,
30 /// Files changed in the previous attempt.
31 pub previous_files_changed: Option<Vec<String>>,
32 /// Agent's recommended next steps from the previous attempt.
33 pub previous_next_steps: Option<String>,
34 /// Current continuation attempt number (0 = first attempt, 1+ = continuation).
35 pub continuation_attempt: u32,
36 /// Count of invalid XML outputs for the current iteration.
37 #[serde(default)]
38 pub invalid_output_attempts: u32,
39 /// Whether a continuation context write is pending.
40 #[serde(default)]
41 pub context_write_pending: bool,
42 /// Whether a continuation context cleanup is pending.
43 #[serde(default)]
44 pub context_cleanup_pending: bool,
45 /// Count of XSD validation retry attempts for current artifact.
46 ///
47 /// Tracks how many times we've retried with the same agent/session due to
48 /// XML parsing or XSD validation failures. Reset when switching agents,
49 /// artifacts, or on successful validation.
50 #[serde(default)]
51 pub xsd_retry_count: u32,
52 /// Whether an XSD retry is pending (validation failed, need to retry).
53 ///
54 /// Set to true when XsdValidationFailed event fires.
55 /// Cleared when retry attempt starts or max retries exceeded.
56 #[serde(default)]
57 pub xsd_retry_pending: bool,
58 /// Whether the next agent invocation should reuse the last session id.
59 ///
60 /// XSD retry is derived via `xsd_retry_pending`, but `xsd_retry_pending` is cleared
61 /// as soon as the retry prompt is prepared to avoid re-deriving the prepare-prompt
62 /// effect. This flag preserves the "reuse session id" signal for the subsequent
63 /// invocation effect.
64 #[serde(default)]
65 pub xsd_retry_session_reuse_pending: bool,
66 /// Last validation error message for XSD retry prompts (commit phase).
67 ///
68 /// This is set when validation fails and cleared when the retry attempt starts.
69 #[serde(default)]
70 pub last_xsd_error: Option<String>,
71 /// Last XSD validation error for review issues XML (used in XSD retry prompt).
72 ///
73 /// This is set when review validation fails and cleared when transitioning away
74 /// from review or when validation succeeds.
75 #[serde(default)]
76 pub last_review_xsd_error: Option<String>,
77 /// Last XSD validation error for fix result XML (used in XSD retry prompt).
78 ///
79 /// This is set when fix validation fails and cleared when transitioning away
80 /// from fix or when validation succeeds.
81 #[serde(default)]
82 pub last_fix_xsd_error: Option<String>,
83 /// Count of same-agent retry attempts for transient invocation failures.
84 ///
85 /// This is intentionally separate from XSD retry, which is only for invalid XML outputs.
86 #[serde(default)]
87 pub same_agent_retry_count: u32,
88 /// Whether a same-agent retry is pending.
89 ///
90 /// Set to true by the reducer when a transient invocation failure occurs (timeout/internal).
91 /// Cleared when the retry attempt starts or when switching agents.
92 #[serde(default)]
93 pub same_agent_retry_pending: bool,
94 /// The reason for the pending same-agent retry, for prompt rendering.
95 #[serde(default)]
96 pub same_agent_retry_reason: Option<SameAgentRetryReason>,
97 /// Whether a continuation is pending (output valid but work incomplete).
98 ///
99 /// Set to true when agent output indicates status is "partial" or "failed".
100 /// Cleared when continuation attempt starts or max continuations exceeded.
101 #[serde(default)]
102 pub continue_pending: bool,
103 /// Current artifact type being processed.
104 ///
105 /// Set at the start of each phase to track which XML artifact is expected.
106 /// Used for appropriate retry prompts and error messages.
107 #[serde(default)]
108 pub current_artifact: Option<ArtifactType>,
109 /// Maximum XSD retry attempts (default 10).
110 ///
111 /// Loaded from unified config. After this many retries, falls back to
112 /// agent chain advancement.
113 #[serde(default = "default_max_xsd_retry_count")]
114 pub max_xsd_retry_count: u32,
115 /// Maximum same-agent retry attempts for invocation failures that should not
116 /// immediately trigger agent fallback (default 2).
117 ///
118 /// This is a failure budget for the current agent. For example, with a value of 2:
119 /// - 1st failure → retry the same agent
120 /// - 2nd failure → fall back to the next agent
121 #[serde(default = "default_max_same_agent_retry_count")]
122 pub max_same_agent_retry_count: u32,
123 /// Maximum continuation attempts (default 3).
124 ///
125 /// Loaded from unified config. After this many continuations, marks
126 /// iteration as complete (even if status is partial/failed).
127 #[serde(default = "default_max_continue_count")]
128 pub max_continue_count: u32,
129
130 // =========================================================================
131 // Fix continuation tracking (parallel to development continuation)
132 // =========================================================================
133 /// Status from the previous fix attempt.
134 #[serde(default)]
135 pub fix_status: Option<FixStatus>,
136 /// Summary from the previous fix attempt.
137 #[serde(default)]
138 pub fix_previous_summary: Option<String>,
139 /// Current fix continuation attempt number (0 = first attempt, 1+ = continuation).
140 #[serde(default)]
141 pub fix_continuation_attempt: u32,
142 /// Whether a fix continuation is pending (output valid but work incomplete).
143 ///
144 /// Set to true when fix output indicates status is "issues_remain".
145 /// Cleared when continuation attempt starts or max continuations exceeded.
146 #[serde(default)]
147 pub fix_continue_pending: bool,
148 /// Maximum fix continuation attempts (default 3).
149 ///
150 /// After this many continuations, proceeds to commit even if issues remain.
151 #[serde(default = "default_max_continue_count")]
152 pub max_fix_continue_count: u32,
153
154 // =========================================================================
155 // Loop detection fields (to prevent infinite tight loops)
156 // =========================================================================
157 /// Loop detection: last effect executed (for detecting repeats).
158 #[serde(default)]
159 pub last_effect_kind: Option<String>,
160
161 /// Loop detection: count of consecutive identical effects.
162 #[serde(default)]
163 pub consecutive_same_effect_count: u32,
164
165 /// Maximum consecutive identical effects before triggering recovery.
166 #[serde(default = "default_max_consecutive_same_effect")]
167 pub max_consecutive_same_effect: u32,
168}
169
170const fn default_max_xsd_retry_count() -> u32 {
171 10
172}
173
174const fn default_max_same_agent_retry_count() -> u32 {
175 2
176}
177
178const fn default_max_continue_count() -> u32 {
179 3
180}
181
182const fn default_max_consecutive_same_effect() -> u32 {
183 20
184}
185
186impl Default for ContinuationState {
187 fn default() -> Self {
188 Self {
189 previous_status: None,
190 previous_summary: None,
191 previous_files_changed: None,
192 previous_next_steps: None,
193 continuation_attempt: 0,
194 invalid_output_attempts: 0,
195 context_write_pending: false,
196 context_cleanup_pending: false,
197 xsd_retry_count: 0,
198 xsd_retry_pending: false,
199 xsd_retry_session_reuse_pending: false,
200 last_xsd_error: None,
201 last_review_xsd_error: None,
202 last_fix_xsd_error: None,
203 same_agent_retry_count: 0,
204 same_agent_retry_pending: false,
205 same_agent_retry_reason: None,
206 continue_pending: false,
207 current_artifact: None,
208 max_xsd_retry_count: default_max_xsd_retry_count(),
209 max_same_agent_retry_count: default_max_same_agent_retry_count(),
210 max_continue_count: default_max_continue_count(),
211 // Fix continuation fields
212 fix_status: None,
213 fix_previous_summary: None,
214 fix_continuation_attempt: 0,
215 fix_continue_pending: false,
216 max_fix_continue_count: default_max_continue_count(),
217 // Loop detection fields
218 last_effect_kind: None,
219 consecutive_same_effect_count: 0,
220 max_consecutive_same_effect: default_max_consecutive_same_effect(),
221 }
222 }
223}
224
225impl ContinuationState {
226 /// Create a new empty continuation state.
227 pub fn new() -> Self {
228 Self::default()
229 }
230
231 /// Create continuation state with custom limits (for config loading).
232 pub fn with_limits(
233 max_xsd_retry_count: u32,
234 max_continue_count: u32,
235 max_same_agent_retry_count: u32,
236 ) -> Self {
237 Self {
238 max_xsd_retry_count,
239 max_same_agent_retry_count,
240 max_continue_count,
241 max_fix_continue_count: max_continue_count,
242 ..Self::default()
243 }
244 }
245
246 /// Builder: set max XSD retry count.
247 ///
248 /// Use 0 to disable XSD retries (immediate agent fallback on validation failure).
249 pub fn with_max_xsd_retry(mut self, max_xsd_retry_count: u32) -> Self {
250 self.max_xsd_retry_count = max_xsd_retry_count;
251 self
252 }
253
254 /// Builder: set max same-agent retry count for transient invocation failures.
255 ///
256 /// Use 0 to disable same-agent retries (immediate agent fallback on timeout/internal error).
257 pub fn with_max_same_agent_retry(mut self, max_same_agent_retry_count: u32) -> Self {
258 self.max_same_agent_retry_count = max_same_agent_retry_count;
259 self
260 }
261
262 /// Check if this is a continuation attempt (not the first attempt).
263 pub fn is_continuation(&self) -> bool {
264 self.continuation_attempt > 0
265 }
266
267 /// Reset the continuation state for a new iteration or phase transition.
268 ///
269 /// This performs a **hard reset** of ALL continuation and retry state,
270 /// preserving only the configured limits (max_xsd_retry_count, max_continue_count,
271 /// max_fix_continue_count).
272 ///
273 /// # What gets reset
274 ///
275 /// - `continuation_attempt` -> 0
276 /// - `continue_pending` -> false
277 /// - `invalid_output_attempts` -> 0
278 /// - `xsd_retry_count` -> 0
279 /// - `xsd_retry_pending` -> false
280 /// - `fix_continuation_attempt` -> 0
281 /// - `fix_continue_pending` -> false
282 /// - `fix_status` -> None
283 /// - `current_artifact` -> None
284 /// - `previous_status`, `previous_summary`, etc. -> defaults
285 ///
286 /// # Usage
287 ///
288 /// Call this when transitioning to a completely new phase or iteration
289 /// where prior continuation/retry state should not carry over. For partial
290 /// resets (e.g., resetting only fix continuation while preserving development
291 /// continuation state), use field-level updates instead.
292 pub fn reset(&self) -> Self {
293 // Preserve configured limits, reset everything else including loop detection counters.
294 // The struct initialization below explicitly preserves max_* fields,
295 // then the spread operator ..Self::default() resets ALL other fields
296 // (including loop detection fields: last_effect_kind -> None,
297 // consecutive_same_effect_count -> 0). This is intentional during
298 // loop recovery to break the tight loop cycle and start fresh.
299 Self {
300 max_xsd_retry_count: self.max_xsd_retry_count,
301 max_same_agent_retry_count: self.max_same_agent_retry_count,
302 max_continue_count: self.max_continue_count,
303 max_fix_continue_count: self.max_fix_continue_count,
304 max_consecutive_same_effect: self.max_consecutive_same_effect,
305 ..Self::default()
306 }
307 }
308
309 /// Set the current artifact type being processed.
310 pub fn with_artifact(&self, artifact: ArtifactType) -> Self {
311 // Reset XSD retry state when switching artifacts, preserve everything else
312 Self {
313 current_artifact: Some(artifact),
314 xsd_retry_count: 0,
315 xsd_retry_pending: false,
316 xsd_retry_session_reuse_pending: false,
317 last_xsd_error: None,
318 last_review_xsd_error: None,
319 last_fix_xsd_error: None,
320 ..self.clone()
321 }
322 }
323
324 /// Mark XSD validation as failed, triggering a retry.
325 ///
326 /// For XSD retry, we want to re-invoke the same agent in the same session when possible,
327 /// to keep retries deterministic and to preserve provider-side context.
328 pub fn trigger_xsd_retry(&self) -> Self {
329 Self {
330 xsd_retry_pending: true,
331 xsd_retry_count: self.xsd_retry_count + 1,
332 xsd_retry_session_reuse_pending: true,
333 ..self.clone()
334 }
335 }
336
337 /// Clear XSD retry pending flag after starting retry.
338 pub fn clear_xsd_retry_pending(&self) -> Self {
339 Self {
340 xsd_retry_pending: false,
341 last_xsd_error: None,
342 last_review_xsd_error: None,
343 last_fix_xsd_error: None,
344 ..self.clone()
345 }
346 }
347
348 /// Check if XSD retries are exhausted.
349 pub fn xsd_retries_exhausted(&self) -> bool {
350 self.xsd_retry_count >= self.max_xsd_retry_count
351 }
352
353 /// Mark a same-agent retry as pending for a transient invocation failure.
354 pub fn trigger_same_agent_retry(&self, reason: SameAgentRetryReason) -> Self {
355 Self {
356 same_agent_retry_pending: true,
357 same_agent_retry_count: self.same_agent_retry_count + 1,
358 same_agent_retry_reason: Some(reason),
359 ..self.clone()
360 }
361 }
362
363 /// Clear same-agent retry pending flag after starting retry.
364 pub fn clear_same_agent_retry_pending(&self) -> Self {
365 Self {
366 same_agent_retry_pending: false,
367 same_agent_retry_reason: None,
368 ..self.clone()
369 }
370 }
371
372 /// Check if same-agent retries are exhausted.
373 pub fn same_agent_retries_exhausted(&self) -> bool {
374 self.same_agent_retry_count >= self.max_same_agent_retry_count
375 }
376
377 /// Mark continuation as pending (output valid but work incomplete).
378 pub fn trigger_continue(&self) -> Self {
379 Self {
380 continue_pending: true,
381 ..self.clone()
382 }
383 }
384
385 /// Clear continue pending flag after starting continuation.
386 pub fn clear_continue_pending(&self) -> Self {
387 Self {
388 continue_pending: false,
389 ..self.clone()
390 }
391 }
392
393 /// Check if continuation attempts are exhausted.
394 ///
395 /// Returns `true` when `continuation_attempt >= max_continue_count`.
396 ///
397 /// # Semantics
398 ///
399 /// The `continuation_attempt` counter tracks how many times work has been attempted:
400 /// - 0: Initial attempt (before any continuation)
401 /// - 1: After first continuation
402 /// - 2: After second continuation
403 /// - etc.
404 ///
405 /// With `max_continue_count = 3`:
406 /// - Attempts 0, 1, 2 are allowed (3 total)
407 /// - Attempt 3+ triggers exhaustion
408 ///
409 /// # Naming Note
410 ///
411 /// The field is named `max_continue_count` rather than `max_total_attempts` because
412 /// it historically represented the maximum number of continuations. The actual
413 /// semantics are "maximum total attempts including initial".
414 pub fn continuations_exhausted(&self) -> bool {
415 self.continuation_attempt >= self.max_continue_count
416 }
417
418 /// Trigger a continuation with context from the previous attempt.
419 ///
420 /// Sets both `context_write_pending` (to write continuation context) and
421 /// `continue_pending` (to trigger the continuation flow in orchestration).
422 pub fn trigger_continuation(
423 &self,
424 status: DevelopmentStatus,
425 summary: String,
426 files_changed: Option<Vec<String>>,
427 next_steps: Option<String>,
428 ) -> Self {
429 Self {
430 previous_status: Some(status),
431 previous_summary: Some(summary),
432 previous_files_changed: files_changed,
433 previous_next_steps: next_steps,
434 continuation_attempt: self.continuation_attempt + 1,
435 invalid_output_attempts: 0,
436 context_write_pending: true,
437 context_cleanup_pending: false,
438 // Reset XSD retry count for new continuation attempt
439 xsd_retry_count: 0,
440 xsd_retry_pending: false,
441 xsd_retry_session_reuse_pending: false,
442 last_xsd_error: None,
443 last_review_xsd_error: None,
444 last_fix_xsd_error: None,
445 // Reset same-agent retry state for new continuation attempt
446 same_agent_retry_count: 0,
447 same_agent_retry_pending: false,
448 same_agent_retry_reason: None,
449 // Set continue_pending to trigger continuation in orchestration
450 continue_pending: true,
451 // Preserve artifact type and limits
452 current_artifact: self.current_artifact.clone(),
453 max_xsd_retry_count: self.max_xsd_retry_count,
454 max_same_agent_retry_count: self.max_same_agent_retry_count,
455 max_continue_count: self.max_continue_count,
456 // Preserve fix continuation fields
457 fix_status: self.fix_status.clone(),
458 fix_previous_summary: self.fix_previous_summary.clone(),
459 fix_continuation_attempt: self.fix_continuation_attempt,
460 fix_continue_pending: self.fix_continue_pending,
461 max_fix_continue_count: self.max_fix_continue_count,
462 // Preserve loop detection fields
463 last_effect_kind: self.last_effect_kind.clone(),
464 consecutive_same_effect_count: self.consecutive_same_effect_count,
465 max_consecutive_same_effect: self.max_consecutive_same_effect,
466 }
467 }
468
469 // =========================================================================
470 // Fix continuation methods
471 // =========================================================================
472
473 /// Check if fix continuations are exhausted.
474 ///
475 /// Semantics match `continuations_exhausted()`: with default `max_fix_continue_count`
476 /// of 3, attempts 0, 1, 2 are allowed (3 total), attempt 3+ is exhausted.
477 pub fn fix_continuations_exhausted(&self) -> bool {
478 self.fix_continuation_attempt >= self.max_fix_continue_count
479 }
480
481 /// Trigger a fix continuation with status context.
482 pub fn trigger_fix_continuation(&self, status: FixStatus, summary: Option<String>) -> Self {
483 Self {
484 fix_status: Some(status),
485 fix_previous_summary: summary,
486 fix_continuation_attempt: self.fix_continuation_attempt + 1,
487 fix_continue_pending: true,
488 // Reset XSD retry state for new continuation
489 xsd_retry_count: 0,
490 xsd_retry_pending: false,
491 xsd_retry_session_reuse_pending: false,
492 last_xsd_error: None,
493 last_review_xsd_error: None,
494 last_fix_xsd_error: None,
495 // Reset invalid output attempts for new continuation
496 invalid_output_attempts: 0,
497 // Clear other pending flags
498 context_write_pending: false,
499 context_cleanup_pending: false,
500 continue_pending: false,
501 // Preserve all other fields via spread operator
502 ..self.clone()
503 }
504 }
505
506 /// Clear fix continuation pending flag after starting continuation.
507 pub fn clear_fix_continue_pending(&self) -> Self {
508 Self {
509 fix_continue_pending: false,
510 ..self.clone()
511 }
512 }
513
514 /// Reset fix continuation state (e.g., when entering a new review pass).
515 pub fn reset_fix_continuation(&self) -> Self {
516 Self {
517 fix_status: None,
518 fix_previous_summary: None,
519 fix_continuation_attempt: 0,
520 fix_continue_pending: false,
521 ..self.clone()
522 }
523 }
524
525 // =========================================================================
526 // Loop detection methods
527 // =========================================================================
528
529 /// Update loop detection counters based on the current effect fingerprint.
530 ///
531 /// This method updates `last_effect_kind` and `consecutive_same_effect_count`
532 /// based on whether the current effect fingerprint matches the previous one.
533 ///
534 /// # Returns
535 ///
536 /// A new `ContinuationState` with updated loop detection counters.
537 ///
538 /// # Behavior
539 ///
540 /// - If `current_fingerprint` equals `last_effect_kind`: increment `consecutive_same_effect_count`
541 /// - Otherwise: reset `consecutive_same_effect_count` to 1 and update `last_effect_kind`
542 pub fn update_loop_detection_counters(&self, current_fingerprint: String) -> Self {
543 if self.last_effect_kind.as_deref() == Some(¤t_fingerprint) {
544 // Same effect as last time - increment counter
545 Self {
546 consecutive_same_effect_count: self.consecutive_same_effect_count + 1,
547 ..self.clone()
548 }
549 } else {
550 // Different effect - reset counter and update fingerprint
551 Self {
552 last_effect_kind: Some(current_fingerprint),
553 consecutive_same_effect_count: 1,
554 ..self.clone()
555 }
556 }
557 }
558
559 /// Check if loop detection threshold has been exceeded.
560 ///
561 /// Returns `true` if `consecutive_same_effect_count` >= `max_consecutive_same_effect`.
562 pub fn is_loop_detected(&self) -> bool {
563 self.consecutive_same_effect_count >= self.max_consecutive_same_effect
564 }
565}