Skip to main content

ralph_workflow/prompts/
prompt_scope_key.rs

1//! Typed prompt scope key for replay identity.
2//!
3//! RFC-007 Short-term corrective action #1 and #5: Replace ad-hoc `format!()` string keys
4//! with a typed `PromptScopeKey` struct that makes missing identity dimensions impossible
5//! at compile time.
6//!
7//! # Design
8//!
9//! Each prompt key has a phase-specific set of identity dimensions:
10//! - **Planning**: iteration + `retry_mode`
11//! - **Development**: iteration + optional continuation + `retry_mode`
12//! - **Commit**: iteration + attempt + `retry_mode`
13//! - **Review**: pass + `retry_mode`
14//! - **Fix**: pass + `retry_mode`
15//!
16//! `recovery_epoch` is carried for auditing/future isolation but is NOT included
17//! in the `Display` string to preserve checkpoint backward-compatibility.
18//! Level-3/4 recovery increments `recovery_epoch` and clears `PipelineState.prompt_history`
19//! atomically. This is the safety mechanism that prevents stale prompt replay across
20//! epoch boundaries, even if iteration counters are reset or reused.
21//!
22//! # Backward Compatibility
23//!
24//! The `Display` implementation produces strings identical to the `format!()` calls
25//! it replaces. Existing checkpoint `prompt_history` maps remain compatible.
26
27use std::fmt;
28
29/// The pipeline phase that a prompt belongs to.
30///
31/// Used as a discriminant in `PromptScopeKey` to ensure callers construct
32/// keys with the correct phase-specific constructor.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub enum PromptPhase {
35    /// Planning phase (iteration-scoped).
36    Planning,
37    /// Development phase (iteration-scoped, optional continuation).
38    Development,
39    /// Commit message phase (iteration + attempt-scoped).
40    Commit,
41    /// Review phase (pass-scoped).
42    Review,
43    /// Fix phase (pass-scoped).
44    Fix,
45    /// Rebase conflict resolution phase (rebase-phase-name-scoped).
46    ///
47    /// `phase` is the lowercase rebase phase name (e.g., "planning", "development")
48    /// derived from git rebase context, not the main pipeline phase.
49    ConflictResolution {
50        /// The rebase phase name (lowercase).
51        phase: String,
52    },
53}
54
55/// The retry mode for a prompt invocation.
56///
57/// Included in the scope key to distinguish fresh prompts from retry variants.
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub enum RetryMode {
60    /// Normal (first attempt or continuation) — no retry suffix.
61    Normal,
62    /// Same-agent retry — appends `_same_agent_retry_{count}` suffix.
63    SameAgent {
64        /// Retry count (1-based).
65        count: u32,
66    },
67    /// XSD validation retry — appends `_xsd_retry_{count}` suffix.
68    Xsd {
69        /// Retry count (1-based).
70        count: u32,
71    },
72}
73
74/// Typed prompt scope key.
75///
76/// Uniquely identifies a prompt for replay from checkpoint history.
77/// Constructed via phase-specific factory methods to enforce required dimensions.
78///
79/// # Backward Compatibility
80///
81/// `Display` output exactly matches the `format!()` strings previously used in handlers.
82/// The `recovery_epoch` field is NOT part of `Display` — it is a future-proofing hook
83/// and an audit dimension only.
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct PromptScopeKey {
86    /// Pipeline phase this prompt belongs to.
87    phase: PromptPhase,
88    /// Development iteration (0-based). Used by Planning, Development, Commit phases.
89    iteration: u32,
90    /// Review/fix pass number (0-based). Used by Review and Fix phases.
91    pass: Option<u32>,
92    /// Commit attempt number within the iteration. Used by Commit phase.
93    attempt: Option<u32>,
94    /// Continuation attempt within a development iteration. Used by Development phase.
95    continuation: Option<u32>,
96    /// Retry mode for this invocation.
97    retry_mode: RetryMode,
98    /// Recovery epoch counter — number of epoch-resetting recoveries (level-3/4) that have
99    /// occurred. NOT included in `Display` but carried for auditing and future isolation.
100    recovery_epoch: u32,
101}
102
103impl PromptScopeKey {
104    /// Construct a key for the Planning phase.
105    #[must_use]
106    pub const fn for_planning(iteration: u32, retry_mode: RetryMode, recovery_epoch: u32) -> Self {
107        Self {
108            phase: PromptPhase::Planning,
109            iteration,
110            pass: None,
111            attempt: None,
112            continuation: None,
113            retry_mode,
114            recovery_epoch,
115        }
116    }
117
118    /// Construct a key for the Development phase.
119    ///
120    /// Set `continuation` to `Some(attempt)` for continuation mode,
121    /// or `None` for normal and retry modes.
122    #[must_use]
123    pub const fn for_development(
124        iteration: u32,
125        continuation: Option<u32>,
126        retry_mode: RetryMode,
127        recovery_epoch: u32,
128    ) -> Self {
129        Self {
130            phase: PromptPhase::Development,
131            iteration,
132            pass: None,
133            attempt: None,
134            continuation,
135            retry_mode,
136            recovery_epoch,
137        }
138    }
139
140    /// Construct a key for the Commit phase.
141    #[must_use]
142    pub const fn for_commit(
143        iteration: u32,
144        attempt: u32,
145        retry_mode: RetryMode,
146        recovery_epoch: u32,
147    ) -> Self {
148        Self {
149            phase: PromptPhase::Commit,
150            iteration,
151            pass: None,
152            attempt: Some(attempt),
153            continuation: None,
154            retry_mode,
155            recovery_epoch,
156        }
157    }
158
159    /// Construct a key for the Review phase.
160    #[must_use]
161    pub const fn for_review(pass: u32, retry_mode: RetryMode, recovery_epoch: u32) -> Self {
162        Self {
163            phase: PromptPhase::Review,
164            iteration: 0,
165            pass: Some(pass),
166            attempt: None,
167            continuation: None,
168            retry_mode,
169            recovery_epoch,
170        }
171    }
172
173    /// Construct a key for the Fix phase.
174    #[must_use]
175    pub const fn for_fix(pass: u32, retry_mode: RetryMode, recovery_epoch: u32) -> Self {
176        Self {
177            phase: PromptPhase::Fix,
178            iteration: 0,
179            pass: Some(pass),
180            attempt: None,
181            continuation: None,
182            retry_mode,
183            recovery_epoch,
184        }
185    }
186
187    /// Construct a key for a rebase conflict resolution prompt.
188    ///
189    /// The `phase` argument is the rebase phase name (lowercase), e.g. `"planning"`
190    /// or `"development"`, derived from the git rebase context. It is NOT a main
191    /// pipeline phase — it identifies which rebase phase triggered the conflict.
192    ///
193    /// `recovery_epoch` is carried for auditing but the rebase handler owns epoch
194    /// semantics via `PromptCaptured` events. Pass `0` from effectful helpers.
195    ///
196    /// The `Display` output (`"{phase}_conflict_resolution"`) is byte-identical to
197    /// the former `format!("{}_conflict_resolution", phase.to_lowercase())` calls,
198    /// preserving backward-compatibility with existing checkpoint `prompt_history` maps.
199    #[must_use]
200    pub fn for_conflict_resolution(phase: &str, recovery_epoch: u32) -> Self {
201        Self {
202            phase: PromptPhase::ConflictResolution {
203                phase: phase.to_lowercase(),
204            },
205            iteration: 0,
206            pass: None,
207            attempt: None,
208            continuation: None,
209            retry_mode: RetryMode::Normal,
210            recovery_epoch,
211        }
212    }
213}
214
215/// Display implementation producing strings largely backward-compatible with existing checkpoint data.
216///
217/// Output format per phase:
218/// - Planning: `planning_{iter}[_{retry_suffix}]`
219/// - Development: `development_{iter}[_continuation_{n}][_{retry_suffix}]`
220/// - Commit: `commit_message_attempt_iter{iter}_{attempt}[_{retry_suffix}]` (NOTE: includes iteration; this intentionally differs from pre-RFC-007 attempt-only commit keys)
221/// - Review: `review_{pass}[_{retry_suffix}]`
222/// - Fix: `fix_{pass}[_{retry_suffix}]`
223///
224/// Retry suffixes:
225/// - `SameAgent { count }` → `_same_agent_retry_{count}`
226/// - `Xsd { count }` → `_xsd_retry_{count}`
227///
228/// NOTE: `recovery_epoch` is intentionally excluded from Display to preserve
229/// backward-compatibility with existing checkpoint `prompt_history` entries.
230impl fmt::Display for PromptScopeKey {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        let base = match &self.phase {
233            PromptPhase::Planning => format!("planning_{}", self.iteration),
234            PromptPhase::Development => self.continuation.map_or_else(
235                || format!("development_{}", self.iteration),
236                |c| format!("development_{}_continuation_{}", self.iteration, c),
237            ),
238            PromptPhase::Commit => format!(
239                "commit_message_attempt_iter{}_{}",
240                self.iteration,
241                self.attempt.unwrap_or(1)
242            ),
243            PromptPhase::Review => format!("review_{}", self.pass.unwrap_or(1)),
244            PromptPhase::Fix => format!("fix_{}", self.pass.unwrap_or(1)),
245            PromptPhase::ConflictResolution { phase } => {
246                format!("{phase}_conflict_resolution")
247            }
248        };
249        match &self.retry_mode {
250            RetryMode::Normal => write!(f, "{base}"),
251            RetryMode::SameAgent { count } => write!(f, "{base}_same_agent_retry_{count}"),
252            RetryMode::Xsd { count } => write!(f, "{base}_xsd_retry_{count}"),
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    // =========================================================================
262    // Planning phase key tests
263    // =========================================================================
264
265    #[test]
266    fn planning_normal_key_matches_legacy_format() {
267        let key = PromptScopeKey::for_planning(0, RetryMode::Normal, 0);
268        assert_eq!(key.to_string(), "planning_0");
269    }
270
271    #[test]
272    fn planning_normal_key_iteration_2() {
273        let key = PromptScopeKey::for_planning(2, RetryMode::Normal, 0);
274        assert_eq!(key.to_string(), "planning_2");
275    }
276
277    #[test]
278    fn planning_same_agent_retry_key_matches_legacy_format() {
279        let key = PromptScopeKey::for_planning(0, RetryMode::SameAgent { count: 2 }, 0);
280        assert_eq!(key.to_string(), "planning_0_same_agent_retry_2");
281    }
282
283    // =========================================================================
284    // Development phase key tests
285    // =========================================================================
286
287    #[test]
288    fn development_normal_key_matches_legacy_format() {
289        let key = PromptScopeKey::for_development(0, None, RetryMode::Normal, 0);
290        assert_eq!(key.to_string(), "development_0");
291    }
292
293    #[test]
294    fn development_continuation_key_matches_legacy_format() {
295        let key = PromptScopeKey::for_development(0, Some(3), RetryMode::Normal, 0);
296        assert_eq!(key.to_string(), "development_0_continuation_3");
297    }
298
299    #[test]
300    fn development_same_agent_retry_key_matches_legacy_format() {
301        let key = PromptScopeKey::for_development(2, None, RetryMode::SameAgent { count: 1 }, 0);
302        assert_eq!(key.to_string(), "development_2_same_agent_retry_1");
303    }
304
305    // =========================================================================
306    // Commit phase key tests
307    // =========================================================================
308
309    #[test]
310    fn commit_normal_key_matches_legacy_format() {
311        let key = PromptScopeKey::for_commit(0, 1, RetryMode::Normal, 0);
312        assert_eq!(key.to_string(), "commit_message_attempt_iter0_1");
313    }
314
315    #[test]
316    fn commit_same_agent_retry_key_matches_legacy_format() {
317        let key = PromptScopeKey::for_commit(0, 1, RetryMode::SameAgent { count: 1 }, 0);
318        assert_eq!(
319            key.to_string(),
320            "commit_message_attempt_iter0_1_same_agent_retry_1"
321        );
322    }
323
324    #[test]
325    fn commit_xsd_retry_key_matches_legacy_format() {
326        let key = PromptScopeKey::for_commit(0, 1, RetryMode::Xsd { count: 1 }, 0);
327        assert_eq!(
328            key.to_string(),
329            "commit_message_attempt_iter0_1_xsd_retry_1"
330        );
331    }
332
333    // =========================================================================
334    // Review phase key tests
335    // =========================================================================
336
337    #[test]
338    fn review_normal_key_matches_legacy_format() {
339        let key = PromptScopeKey::for_review(0, RetryMode::Normal, 0);
340        assert_eq!(key.to_string(), "review_0");
341    }
342
343    #[test]
344    fn review_xsd_retry_key_matches_legacy_format() {
345        // invalid_output_attempts is the XSD retry count for review
346        let key = PromptScopeKey::for_review(1, RetryMode::Xsd { count: 3 }, 0);
347        assert_eq!(key.to_string(), "review_1_xsd_retry_3");
348    }
349
350    #[test]
351    fn review_same_agent_retry_key_matches_legacy_format() {
352        let key = PromptScopeKey::for_review(1, RetryMode::SameAgent { count: 2 }, 0);
353        assert_eq!(key.to_string(), "review_1_same_agent_retry_2");
354    }
355
356    // =========================================================================
357    // Fix phase key tests
358    // =========================================================================
359
360    #[test]
361    fn fix_normal_key_matches_legacy_format() {
362        let key = PromptScopeKey::for_fix(1, RetryMode::Normal, 0);
363        assert_eq!(key.to_string(), "fix_1");
364    }
365
366    #[test]
367    fn fix_same_agent_retry_key_matches_legacy_format() {
368        let key = PromptScopeKey::for_fix(1, RetryMode::SameAgent { count: 1 }, 0);
369        assert_eq!(key.to_string(), "fix_1_same_agent_retry_1");
370    }
371
372    #[test]
373    fn fix_xsd_retry_key_matches_legacy_format() {
374        let key = PromptScopeKey::for_fix(1, RetryMode::Xsd { count: 2 }, 0);
375        assert_eq!(key.to_string(), "fix_1_xsd_retry_2");
376    }
377
378    // =========================================================================
379    // recovery_epoch isolation tests
380    // =========================================================================
381
382    #[test]
383    fn recovery_epoch_not_in_display_string() {
384        // Two keys with same phase/iteration/retry but different epochs
385        // must produce the same Display string (epoch not in key string)
386        let key_epoch_0 = PromptScopeKey::for_planning(1, RetryMode::Normal, 0);
387        let key_epoch_1 = PromptScopeKey::for_planning(1, RetryMode::Normal, 1);
388        assert_eq!(
389            key_epoch_0.to_string(),
390            key_epoch_1.to_string(),
391            "recovery_epoch must not affect Display string for checkpoint compat"
392        );
393    }
394
395    #[test]
396    fn keys_are_unique_across_phases() {
397        let planning = PromptScopeKey::for_planning(1, RetryMode::Normal, 0).to_string();
398        let development =
399            PromptScopeKey::for_development(1, None, RetryMode::Normal, 0).to_string();
400        let commit = PromptScopeKey::for_commit(1, 1, RetryMode::Normal, 0).to_string();
401        let review = PromptScopeKey::for_review(1, RetryMode::Normal, 0).to_string();
402        let fix = PromptScopeKey::for_fix(1, RetryMode::Normal, 0).to_string();
403
404        let all = [&planning, &development, &commit, &review, &fix];
405        for (i, k1) in all.iter().enumerate() {
406            for (j, k2) in all.iter().enumerate() {
407                if i != j {
408                    assert_ne!(k1, k2, "Keys for different phases must be unique");
409                }
410            }
411        }
412    }
413
414    #[test]
415    fn keys_are_unique_across_retry_modes() {
416        let normal = PromptScopeKey::for_planning(1, RetryMode::Normal, 0).to_string();
417        let same_agent =
418            PromptScopeKey::for_planning(1, RetryMode::SameAgent { count: 1 }, 0).to_string();
419        assert_ne!(normal, same_agent);
420    }
421
422    #[test]
423    fn keys_are_unique_across_iterations() {
424        let iter1 = PromptScopeKey::for_planning(1, RetryMode::Normal, 0).to_string();
425        let iter2 = PromptScopeKey::for_planning(2, RetryMode::Normal, 0).to_string();
426        assert_ne!(iter1, iter2);
427    }
428
429    /// SC-2: Development phase keys are unique across iterations.
430    ///
431    /// Ensures that iteration 2's development prompt key cannot collide with
432    /// iteration 1's key, so a checkpoint from cycle 1 cannot be replayed in cycle 2.
433    #[test]
434    fn development_keys_are_unique_across_iterations() {
435        let iter1 = PromptScopeKey::for_development(1, None, RetryMode::Normal, 0).to_string();
436        let iter2 = PromptScopeKey::for_development(2, None, RetryMode::Normal, 0).to_string();
437        assert_ne!(
438            iter1, iter2,
439            "Development keys must differ across iterations to prevent stale replay. \
440             iter1='{iter1}', iter2='{iter2}'"
441        );
442    }
443
444    /// Regression test for the root cause of the stale prompt replay bug (RFC-007).
445    ///
446    /// The bug: commit attempt numbers reset to 1 on each new commit cycle. Before
447    /// the fix, keys were `commit_message_attempt_1` (attempt-only), so iter2/attempt1
448    /// would collide with iter1/attempt1 and replay the stale first-cycle prompt.
449    ///
450    /// After the fix, keys include the iteration dimension:
451    /// `commit_message_attempt_iter1_1` != `commit_message_attempt_iter2_1`.
452    #[test]
453    fn commit_keys_are_unique_across_iterations_same_attempt() {
454        // Both use attempt=1 (attempt resets to 1 on each new commit cycle — the bug scenario)
455        let iter1_attempt1 = PromptScopeKey::for_commit(1, 1, RetryMode::Normal, 0).to_string();
456        let iter2_attempt1 = PromptScopeKey::for_commit(2, 1, RetryMode::Normal, 0).to_string();
457        assert_ne!(
458            iter1_attempt1, iter2_attempt1,
459            "Commit keys must differ across iterations even when attempt number is the same. \
460             iter1/attempt1 = '{iter1_attempt1}', iter2/attempt1 = '{iter2_attempt1}'"
461        );
462    }
463
464    // =========================================================================
465    // ConflictResolution phase key tests
466    // =========================================================================
467
468    #[test]
469    fn test_conflict_resolution_key_format_matches_legacy_raw_string() {
470        // Verifies byte-identical output to the former:
471        //   format!("{}_conflict_resolution", "planning".to_lowercase())
472        let key = PromptScopeKey::for_conflict_resolution("planning", 0);
473        assert_eq!(key.to_string(), "planning_conflict_resolution");
474    }
475
476    #[test]
477    fn test_conflict_resolution_key_for_different_phases() {
478        assert_eq!(
479            PromptScopeKey::for_conflict_resolution("development", 0).to_string(),
480            "development_conflict_resolution"
481        );
482        assert_eq!(
483            PromptScopeKey::for_conflict_resolution("RebaseOnly", 0).to_string(),
484            "rebaseonly_conflict_resolution"
485        );
486    }
487
488    #[test]
489    fn test_conflict_resolution_key_lowercases_phase() {
490        let upper = PromptScopeKey::for_conflict_resolution("PLANNING", 0).to_string();
491        let lower = PromptScopeKey::for_conflict_resolution("planning", 0).to_string();
492        assert_eq!(upper, lower);
493    }
494
495    #[test]
496    fn test_conflict_resolution_key_recovery_epoch_not_in_display() {
497        let key_epoch0 = PromptScopeKey::for_conflict_resolution("planning", 0);
498        let key_epoch1 = PromptScopeKey::for_conflict_resolution("planning", 1);
499        assert_eq!(
500            key_epoch0.to_string(),
501            key_epoch1.to_string(),
502            "recovery_epoch must not affect Display string for checkpoint compat"
503        );
504    }
505
506    #[test]
507    fn test_conflict_resolution_key_is_unique_from_pipeline_phase_keys() {
508        let conflict_key = PromptScopeKey::for_conflict_resolution("planning", 0).to_string();
509        let planning_key = PromptScopeKey::for_planning(1, RetryMode::Normal, 0).to_string();
510        let development_key =
511            PromptScopeKey::for_development(1, None, RetryMode::Normal, 0).to_string();
512        // Conflict key contains "_conflict_resolution" suffix, which pipeline keys do not.
513        assert_ne!(conflict_key, planning_key);
514        assert_ne!(conflict_key, development_key);
515        assert!(conflict_key.ends_with("_conflict_resolution"));
516    }
517}