Skip to main content

ralph_workflow/reducer/state/continuation/
state.rs

1//! `ContinuationState` struct definition.
2//!
3//! Contains the core state structure for tracking continuation and retry attempts
4//! across development and fix iterations.
5
6use super::super::{ArtifactType, DevelopmentStatus, FixStatus, SameAgentRetryReason};
7use crate::agents::AgentDrain;
8use serde::{Deserialize, Serialize};
9
10/// Continuation state for development iterations.
11///
12/// Tracks context from previous attempts within the same iteration to enable
13/// continuation-aware prompting when status is "partial" or "failed".
14///
15/// # When Fields Are Populated
16///
17/// - `previous_status`: Set when `DevelopmentIterationContinuationTriggered` event fires
18/// - `previous_summary`: Set when `DevelopmentIterationContinuationTriggered` event fires
19/// - `previous_files_changed`: Set when `DevelopmentIterationContinuationTriggered` event fires
20/// - `previous_next_steps`: Set when `DevelopmentIterationContinuationTriggered` event fires
21/// - `continuation_attempt`: Incremented on each continuation within same iteration
22///
23/// # Reset Triggers
24///
25/// The continuation state is reset (cleared) when:
26/// - A new iteration starts (`DevelopmentIterationStarted` event)
27/// - Status becomes "completed" (`ContinuationSucceeded` event)
28/// - Phase transitions away from Development
29#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
30pub struct ContinuationState {
31    /// Status from the previous attempt ("partial" or "failed").
32    pub previous_status: Option<DevelopmentStatus>,
33    /// Summary of what was accomplished in the previous attempt.
34    pub previous_summary: Option<String>,
35    /// Files changed in the previous attempt. Box<[String]> saves 8 bytes per instance
36    /// vs Vec<String> (no capacity field) since this collection never grows after construction.
37    pub previous_files_changed: Option<Box<[String]>>,
38    /// Agent's recommended next steps from the previous attempt.
39    pub previous_next_steps: Option<String>,
40    /// Current continuation attempt number (0 = first attempt, 1+ = continuation).
41    pub continuation_attempt: u32,
42    /// Count of invalid XML outputs for the current iteration.
43    #[serde(default)]
44    pub invalid_output_attempts: u32,
45    /// Whether a continuation context write is pending.
46    #[serde(default)]
47    pub context_write_pending: bool,
48    /// Whether a continuation context cleanup is pending.
49    #[serde(default)]
50    pub context_cleanup_pending: bool,
51    /// Count of XSD validation retry attempts for current artifact.
52    ///
53    /// Tracks how many times we've retried with the same agent/session due to
54    /// XML parsing or XSD validation failures. Reset when switching agents,
55    /// artifacts, or on successful validation.
56    #[serde(default)]
57    pub xsd_retry_count: u32,
58    /// Whether an XSD retry is pending (validation failed, need to retry).
59    ///
60    /// Set to true when `XsdValidationFailed` event fires.
61    /// Cleared when retry attempt starts or max retries exceeded.
62    #[serde(default)]
63    pub xsd_retry_pending: bool,
64    /// Whether the next agent invocation should reuse the last session id.
65    ///
66    /// XSD retry is derived via `xsd_retry_pending`, but `xsd_retry_pending` is cleared
67    /// as soon as the retry prompt is prepared to avoid re-deriving the prepare-prompt
68    /// effect. This flag preserves the "reuse session id" signal for the subsequent
69    /// invocation effect.
70    #[serde(default)]
71    pub xsd_retry_session_reuse_pending: bool,
72    /// Last validation error message for XSD retry prompts (commit phase).
73    ///
74    /// This is set when validation fails and cleared when the retry attempt starts.
75    #[serde(default)]
76    pub last_xsd_error: Option<String>,
77    /// Last XSD validation error for review issues XML (used in XSD retry prompt).
78    ///
79    /// This is set when review validation fails and cleared when transitioning away
80    /// from review or when validation succeeds.
81    #[serde(default)]
82    pub last_review_xsd_error: Option<String>,
83    /// Last XSD validation error for fix result XML (used in XSD retry prompt).
84    ///
85    /// This is set when fix validation fails and cleared when transitioning away
86    /// from fix or when validation succeeds.
87    #[serde(default)]
88    pub last_fix_xsd_error: Option<String>,
89    /// Count of same-agent retry attempts for transient invocation failures.
90    ///
91    /// This is intentionally separate from XSD retry, which is only for invalid XML outputs.
92    #[serde(default)]
93    pub same_agent_retry_count: u32,
94    /// Whether a same-agent retry is pending.
95    ///
96    /// Set to true by the reducer when a transient invocation failure occurs (timeout/internal).
97    /// Cleared when the retry attempt starts or when switching agents.
98    #[serde(default)]
99    pub same_agent_retry_pending: bool,
100    /// The reason for the pending same-agent retry, for prompt rendering.
101    #[serde(default)]
102    pub same_agent_retry_reason: Option<SameAgentRetryReason>,
103    /// Whether a continuation is pending (output valid but work incomplete).
104    ///
105    /// Set to true when agent output indicates status is "partial" or "failed".
106    /// Cleared when continuation attempt starts or max continuations exceeded.
107    #[serde(default)]
108    pub continue_pending: bool,
109    /// Current artifact type being processed.
110    ///
111    /// Set at the start of each phase to track which XML artifact is expected.
112    /// Used for appropriate retry prompts and error messages.
113    #[serde(default)]
114    pub current_artifact: Option<ArtifactType>,
115    /// Maximum XSD retry attempts (default 10).
116    ///
117    /// Loaded from unified config. After this many retries, falls back to
118    /// agent chain advancement.
119    #[serde(default = "default_max_xsd_retry_count")]
120    pub max_xsd_retry_count: u32,
121    /// Maximum same-agent retry attempts for invocation failures that should not
122    /// immediately trigger agent fallback (default 2).
123    ///
124    /// This is a failure budget for the current agent. For example, with a value of 2:
125    /// - 1st failure → retry the same agent
126    /// - 2nd failure → fall back to the next agent
127    #[serde(default = "default_max_same_agent_retry_count")]
128    pub max_same_agent_retry_count: u32,
129    /// Maximum continuation attempts (default 3).
130    ///
131    /// Loaded from unified config. After this many continuations, marks
132    /// iteration as complete (even if status is partial/failed).
133    #[serde(default = "default_max_continue_count")]
134    pub max_continue_count: u32,
135
136    // =========================================================================
137    // Fix continuation tracking (parallel to development continuation)
138    // =========================================================================
139    /// Status from the previous fix attempt.
140    #[serde(default)]
141    pub fix_status: Option<FixStatus>,
142    /// Summary from the previous fix attempt.
143    #[serde(default)]
144    pub fix_previous_summary: Option<String>,
145    /// Current fix continuation attempt number (0 = first attempt, 1+ = continuation).
146    #[serde(default)]
147    pub fix_continuation_attempt: u32,
148    /// Whether a fix continuation is pending (output valid but work incomplete).
149    ///
150    /// Set to true when fix output indicates status is "`issues_remain`".
151    /// Cleared when continuation attempt starts or max continuations exceeded.
152    #[serde(default)]
153    pub fix_continue_pending: bool,
154    /// Maximum fix continuation attempts (default 3).
155    ///
156    /// After this many continuations, proceeds to commit even if issues remain.
157    #[serde(default = "default_max_continue_count")]
158    pub max_fix_continue_count: u32,
159
160    // =========================================================================
161    // Loop detection fields (to prevent infinite tight loops)
162    // =========================================================================
163    /// Loop detection: last effect executed (for detecting repeats).
164    #[serde(default)]
165    pub last_effect_kind: Option<String>,
166
167    /// Loop detection: count of consecutive identical effects.
168    #[serde(default)]
169    pub consecutive_same_effect_count: u32,
170
171    /// Maximum consecutive identical effects before triggering recovery.
172    #[serde(default = "default_max_consecutive_same_effect")]
173    pub max_consecutive_same_effect: u32,
174
175    // =========================================================================
176    // Timeout context preservation fields (for session-less agent retry)
177    // =========================================================================
178    /// Whether a timeout context file write is pending.
179    ///
180    /// Set when a timeout with `PartialOutput` occurs but the agent has no session ID.
181    /// The context must be extracted from the logfile and written to a temp file
182    /// before the retry prompt is prepared.
183    #[serde(default)]
184    pub timeout_context_write_pending: bool,
185
186    /// Path to the timeout context file (if written).
187    ///
188    /// After `WriteTimeoutContext` effect completes, this stores the path to the
189    /// context file so the retry prompt can reference it.
190    #[serde(default)]
191    pub timeout_context_file_path: Option<String>,
192}
193
194const fn default_max_xsd_retry_count() -> u32 {
195    10
196}
197
198const fn default_max_same_agent_retry_count() -> u32 {
199    2
200}
201
202const fn default_max_continue_count() -> u32 {
203    3
204}
205
206/// Default threshold for consecutive identical effects before triggering loop recovery.
207///
208/// When the same effect is executed this many times consecutively, the system triggers
209/// loop recovery to break potential infinite retry cycles.
210pub const DEFAULT_LOOP_DETECTION_THRESHOLD: u32 = 100;
211
212/// Serde requires a function for `#[serde(default = "...")]`.
213const fn default_max_consecutive_same_effect() -> u32 {
214    DEFAULT_LOOP_DETECTION_THRESHOLD
215}
216
217impl Default for ContinuationState {
218    fn default() -> Self {
219        Self {
220            previous_status: None,
221            previous_summary: None,
222            previous_files_changed: None,
223            previous_next_steps: None,
224            continuation_attempt: 0,
225            invalid_output_attempts: 0,
226            context_write_pending: false,
227            context_cleanup_pending: false,
228            xsd_retry_count: 0,
229            xsd_retry_pending: false,
230            xsd_retry_session_reuse_pending: false,
231            last_xsd_error: None,
232            last_review_xsd_error: None,
233            last_fix_xsd_error: None,
234            same_agent_retry_count: 0,
235            same_agent_retry_pending: false,
236            same_agent_retry_reason: None,
237            continue_pending: false,
238            current_artifact: None,
239            max_xsd_retry_count: default_max_xsd_retry_count(),
240            max_same_agent_retry_count: default_max_same_agent_retry_count(),
241            max_continue_count: default_max_continue_count(),
242            // Fix continuation fields
243            fix_status: None,
244            fix_previous_summary: None,
245            fix_continuation_attempt: 0,
246            fix_continue_pending: false,
247            max_fix_continue_count: default_max_continue_count(),
248            // Loop detection fields
249            last_effect_kind: None,
250            consecutive_same_effect_count: 0,
251            max_consecutive_same_effect: DEFAULT_LOOP_DETECTION_THRESHOLD,
252            // Timeout context preservation fields
253            timeout_context_write_pending: false,
254            timeout_context_file_path: None,
255        }
256    }
257}
258
259impl ContinuationState {
260    /// Create a new empty continuation state.
261    #[must_use]
262    pub fn new() -> Self {
263        Self::default()
264    }
265
266    /// Create continuation state with custom limits (for config loading).
267    #[must_use]
268    pub fn with_limits(
269        max_xsd_retry_count: u32,
270        max_continue_count: u32,
271        max_same_agent_retry_count: u32,
272    ) -> Self {
273        Self {
274            max_xsd_retry_count,
275            max_same_agent_retry_count,
276            max_continue_count,
277            max_fix_continue_count: max_continue_count,
278            ..Self::default()
279        }
280    }
281
282    /// Builder: set max XSD retry count.
283    ///
284    /// Use 0 to disable XSD retries (immediate agent fallback on validation failure).
285    #[must_use]
286    pub const fn with_max_xsd_retry(mut self, max_xsd_retry_count: u32) -> Self {
287        self.max_xsd_retry_count = max_xsd_retry_count;
288        self
289    }
290
291    /// Builder: set max same-agent retry count for transient invocation failures.
292    ///
293    /// Use 0 to disable same-agent retries (immediate agent fallback on timeout/internal error).
294    #[must_use]
295    pub const fn with_max_same_agent_retry(mut self, max_same_agent_retry_count: u32) -> Self {
296        self.max_same_agent_retry_count = max_same_agent_retry_count;
297        self
298    }
299
300    /// Check if this is a continuation attempt (not the first attempt).
301    #[must_use]
302    pub const fn is_continuation(&self) -> bool {
303        self.continuation_attempt > 0
304    }
305
306    /// Whether the active runtime drain has a pending continuation.
307    #[must_use]
308    pub const fn pending_continuation_for_drain(&self, drain: AgentDrain) -> bool {
309        match drain {
310            AgentDrain::Development => self.continue_pending,
311            AgentDrain::Fix => self.fix_continue_pending,
312            AgentDrain::Planning
313            | AgentDrain::Review
314            | AgentDrain::Commit
315            | AgentDrain::Analysis => false,
316        }
317    }
318
319    /// Whether the active runtime drain has exhausted its continuation budget.
320    #[must_use]
321    pub const fn continuation_exhausted_for_drain(&self, drain: AgentDrain) -> bool {
322        match drain {
323            AgentDrain::Development => self.continuation_attempt >= self.max_continue_count,
324            AgentDrain::Fix => self.fix_continuation_attempt >= self.max_fix_continue_count,
325            AgentDrain::Planning
326            | AgentDrain::Review
327            | AgentDrain::Commit
328            | AgentDrain::Analysis => false,
329        }
330    }
331
332    /// Reset the continuation state for a new iteration or phase transition.
333    ///
334    /// This performs a **hard reset** of ALL continuation and retry state,
335    /// preserving only the configured limits (`max_xsd_retry_count`, `max_continue_count`,
336    /// `max_fix_continue_count`).
337    ///
338    /// # What gets reset
339    ///
340    /// - `continuation_attempt` -> 0
341    /// - `continue_pending` -> false
342    /// - `invalid_output_attempts` -> 0
343    /// - `xsd_retry_count` -> 0
344    /// - `xsd_retry_pending` -> false
345    /// - `fix_continuation_attempt` -> 0
346    /// - `fix_continue_pending` -> false
347    /// - `fix_status` -> None
348    /// - `current_artifact` -> None
349    /// - `previous_status`, `previous_summary`, etc. -> defaults
350    ///
351    /// # Usage
352    ///
353    /// Call this when transitioning to a completely new phase or iteration
354    /// where prior continuation/retry state should not carry over. For partial
355    /// resets (e.g., resetting only fix continuation while preserving development
356    /// continuation state), use field-level updates instead.
357    #[must_use]
358    pub fn reset(self) -> Self {
359        // Preserve configured limits, reset everything else including loop detection counters.
360        // The struct initialization below explicitly preserves max_* fields,
361        // then the spread operator ..Self::default() resets ALL other fields
362        // (including loop detection fields: last_effect_kind -> None,
363        // consecutive_same_effect_count -> 0). This is intentional during
364        // loop recovery to break the tight loop cycle and start fresh.
365        Self {
366            max_xsd_retry_count: self.max_xsd_retry_count,
367            max_same_agent_retry_count: self.max_same_agent_retry_count,
368            max_continue_count: self.max_continue_count,
369            max_fix_continue_count: self.max_fix_continue_count,
370            max_consecutive_same_effect: self.max_consecutive_same_effect,
371            ..Self::default()
372        }
373    }
374}