Skip to main content

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(&current_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}