Skip to main content

ralph_workflow/app/
finalization.rs

1//! Pipeline finalization and cleanup.
2//!
3//! This module handles the final phase of the pipeline including cleanup,
4//! final summary, and checkpoint clearing.
5//!
6//! Note: PROMPT.md permission restoration is now handled by the reducer's
7//! `Effect::RestorePromptPermissions` during the `Finalizing` phase, ensuring
8//! it goes through the effect system for proper testability.
9
10use crate::banner::{print_final_summary, PipelineSummary};
11use crate::checkpoint::clear_checkpoint_with_workspace;
12use crate::config::Config;
13use crate::files::protection::monitoring::PromptMonitor;
14use crate::logger::Colors;
15use crate::logger::Logger;
16use crate::pipeline::AgentPhaseGuard;
17use crate::pipeline::Timer;
18use crate::reducer::state::PipelineState;
19use crate::workspace::Workspace;
20
21/// Context for pipeline finalization.
22#[derive(Copy, Clone)]
23pub struct FinalizeContext<'a> {
24    pub logger: &'a Logger,
25    pub colors: Colors,
26    pub config: &'a Config,
27    pub timer: &'a Timer,
28    pub workspace: &'a dyn Workspace,
29}
30
31/// Finalizes the pipeline: cleans up and prints summary.
32///
33/// Commits now happen per-iteration during development and per-cycle during review,
34/// so this function only handles cleanup and final summary.
35///
36/// # Arguments
37///
38/// * `ctx` - Finalization context with logger, config, timer, and workspace
39/// * `final_state` - Final pipeline state from reducer (source of truth for metrics)
40#[must_use]
41pub const fn build_pipeline_summary(
42    total_time: String,
43    config: &Config,
44    final_state: &PipelineState,
45) -> PipelineSummary {
46    let connectivity_interruptions = if final_state.metrics.connectivity_interruptions_total > 0 {
47        Some(final_state.metrics.connectivity_interruptions_total as usize)
48    } else {
49        None
50    };
51    PipelineSummary {
52        total_time,
53        dev_runs_completed: final_state.metrics.dev_iterations_completed as usize,
54        dev_runs_total: final_state.metrics.max_dev_iterations as usize,
55        review_passes_completed: final_state.metrics.review_passes_completed as usize,
56        review_passes_total: final_state.metrics.max_review_passes as usize,
57        review_runs: final_state.metrics.review_runs_total as usize,
58        changes_detected: final_state.metrics.commits_created_total as usize,
59        isolation_mode: config.isolation_mode,
60        verbose: config.verbosity.is_verbose(),
61        review_summary: None,
62        connectivity_interruptions,
63    }
64}
65
66pub fn finalize_pipeline(
67    agent_phase_guard: &mut AgentPhaseGuard<'_>,
68    ctx: FinalizeContext<'_>,
69    final_state: &PipelineState,
70    prompt_monitor: Option<PromptMonitor>,
71) {
72    // Stop the PROMPT.md monitor if it was started
73    if let Some(monitor) = prompt_monitor {
74        monitor.stop().iter().for_each(|warning| {
75            ctx.logger.warn(warning);
76        });
77    }
78
79    // End agent phase and clean up
80    let repo_root = ctx.workspace.root();
81    crate::git_helpers::end_agent_phase_in_repo(repo_root);
82    crate::git_helpers::disable_git_wrapper(agent_phase_guard.git_helpers);
83
84    let uninstall_result = crate::git_helpers::uninstall_hooks_in_repo(repo_root, ctx.logger);
85    let hook_uninstall_ok = match uninstall_result {
86        Ok(_) => true,
87        Err(err) => {
88            if err.kind() == std::io::ErrorKind::NotFound {
89                ctx.logger.warn(&format!(
90                    "Skipping hook uninstall (repo not present on filesystem): {err}"
91                ));
92                true
93            } else {
94                ctx.logger
95                    .warn(&format!("Failed to uninstall Ralph hooks: {err}"));
96                false
97            }
98        }
99    };
100
101    let wrapper_remaining = crate::git_helpers::verify_wrapper_cleaned(repo_root);
102    let wrapper_ok = if wrapper_remaining.is_empty() {
103        true
104    } else {
105        ctx.logger.warn(&format!(
106            "Wrapper artifacts still present after cleanup: {}",
107            wrapper_remaining.join(", ")
108        ));
109        false
110    };
111
112    let hooks_result = crate::git_helpers::verify_hooks_removed(repo_root);
113    let hooks_ok = match hooks_result {
114        Ok(remaining) => {
115            if remaining.is_empty() {
116                true
117            } else {
118                ctx.logger.warn(&format!(
119                    "Ralph hooks still present after cleanup: {}",
120                    remaining.join(", ")
121                ));
122                false
123            }
124        }
125        Err(err) => {
126            if err.kind() == std::io::ErrorKind::NotFound {
127                ctx.logger.warn(&format!(
128                    "Skipping hook cleanup verification (repo not present on filesystem): {err}"
129                ));
130                true
131            } else {
132                ctx.logger
133                    .warn(&format!("Failed to verify hook cleanup: {err}"));
134                false
135            }
136        }
137    };
138
139    let cleanup_ok_initial = hook_uninstall_ok && wrapper_ok && hooks_ok;
140
141    // Note: Individual commits were created per-iteration during development
142    // and per-cycle during review. The final commit phase has been removed.
143
144    // Final summary derived exclusively from reducer state
145    let summary = build_pipeline_summary(ctx.timer.elapsed_formatted(), ctx.config, final_state);
146    print_final_summary(ctx.colors, &summary, ctx.logger);
147
148    if ctx.config.features.checkpoint_enabled {
149        if let Err(err) = clear_checkpoint_with_workspace(ctx.workspace) {
150            ctx.logger
151                .warn(&format!("Failed to clear checkpoint: {err}"));
152        }
153    }
154
155    // Note: PROMPT.md write permissions are now restored via the reducer's
156    // Effect::RestorePromptPermissions during the Finalizing phase.
157    // This ensures the operation goes through the effect system for testability.
158
159    // Clean up generated files before disarming the guard.
160    // This must happen BEFORE disarm() because the guard's Drop is the only
161    // other place that calls cleanup_generated_files_with_workspace, and
162    // disarm() prevents Drop from running.
163    crate::files::cleanup_generated_files_with_workspace(ctx.workspace);
164    let cleanup_ok = if !crate::git_helpers::try_remove_ralph_dir(repo_root) {
165        let remaining = crate::git_helpers::verify_ralph_dir_removed(repo_root);
166        ctx.logger.warn(&format!(
167            "Ralph git dir still present after cleanup: {}",
168            remaining.join(", ")
169        ));
170        false
171    } else {
172        cleanup_ok_initial
173    };
174
175    if cleanup_ok {
176        // Clear global mutexes only when cleanup succeeded and the guard is
177        // actually being disarmed. On failure, keep the fallback paths intact
178        // so AgentPhaseGuard::drop() and the SIGINT cleanup path still have
179        // valid locations for their final best-effort cleanup.
180        crate::git_helpers::clear_agent_phase_global_state();
181        agent_phase_guard.disarm();
182    } else {
183        ctx.logger.warn(
184            "Agent phase cleanup incomplete; leaving AgentPhaseGuard armed for Drop best-effort",
185        );
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use crate::reducer::state::{ContinuationState, PipelineState, RunMetrics};
192
193    #[test]
194    fn test_summary_derives_from_reducer_metrics() {
195        let state = PipelineState {
196            metrics: RunMetrics {
197                dev_iterations_completed: 3,
198                review_runs_total: 4,
199                commits_created_total: 3,
200                ..RunMetrics::new(5, 2, &ContinuationState::new())
201            },
202            ..PipelineState::initial(5, 2)
203        };
204
205        // Summary should use reducer metrics, not runtime counters
206        let dev_runs_completed = state.metrics.dev_iterations_completed as usize;
207        let dev_runs_total = state.metrics.max_dev_iterations as usize;
208        let review_runs = state.metrics.review_runs_total as usize;
209        let changes_detected = state.metrics.commits_created_total as usize;
210
211        assert_eq!(dev_runs_completed, 3);
212        assert_eq!(dev_runs_total, 5);
213        assert_eq!(review_runs, 4);
214        assert_eq!(changes_detected, 3);
215    }
216
217    #[test]
218    fn test_metrics_reflect_actual_progress_not_config() {
219        let state = PipelineState {
220            metrics: RunMetrics {
221                dev_iterations_completed: 2,
222                review_runs_total: 0,
223                ..RunMetrics::new(10, 5, &ContinuationState::new())
224            },
225            ..PipelineState::initial(10, 5)
226        };
227
228        // Simulate partial run: only 2 iterations completed out of 10 configured
229
230        // Summary should show actual progress (2), not config (10)
231        assert_eq!(state.metrics.dev_iterations_completed, 2);
232        assert_eq!(state.metrics.max_dev_iterations, 10);
233    }
234
235    #[test]
236    fn test_summary_no_drift_from_runtime_counters() {
237        let state = PipelineState {
238            metrics: RunMetrics {
239                dev_iterations_completed: 7,
240                review_runs_total: 3,
241                commits_created_total: 8,
242                ..RunMetrics::new(10, 5, &ContinuationState::new())
243            },
244            ..PipelineState::initial(10, 5)
245        };
246
247        // Simulate hypothetical runtime counters (these should NOT be used)
248        let runtime_dev_completed = 5; // WRONG VALUE - should be ignored
249        let runtime_review_runs = 2; // WRONG VALUE - should be ignored
250
251        // Summary must use reducer metrics, not runtime counters
252        let dev_runs = state.metrics.dev_iterations_completed as usize;
253        let review_runs = state.metrics.review_runs_total as usize;
254        let commits = state.metrics.commits_created_total as usize;
255
256        assert_eq!(dev_runs, 7); // From reducer, not runtime
257        assert_eq!(review_runs, 3); // From reducer, not runtime
258        assert_eq!(commits, 8); // From reducer, not runtime
259
260        // Prove we're not using the wrong values
261        assert_ne!(dev_runs, runtime_dev_completed);
262        assert_ne!(review_runs, runtime_review_runs);
263    }
264
265    #[test]
266    fn test_summary_uses_all_reducer_metrics() {
267        let state = PipelineState {
268            metrics: RunMetrics {
269                dev_iterations_started: 5,
270                dev_iterations_completed: 5,
271                dev_attempts_total: 7,
272                analysis_attempts_total: 5,
273                review_passes_started: 3,
274                review_passes_completed: 3,
275                review_runs_total: 3,
276                fix_runs_total: 2,
277                commits_created_total: 6,
278                xsd_retry_attempts_total: 2,
279                same_agent_retry_attempts_total: 1,
280                ..RunMetrics::new(5, 3, &ContinuationState::new())
281            },
282            ..PipelineState::initial(5, 3)
283        };
284
285        // Construct summary as finalize_pipeline does
286        let dev_runs_completed = state.metrics.dev_iterations_completed as usize;
287        let dev_runs_total = state.metrics.max_dev_iterations as usize;
288        let review_passes_completed = state.metrics.review_passes_completed as usize;
289        let review_passes_total = state.metrics.max_review_passes as usize;
290        let review_runs_total = state.metrics.review_runs_total as usize;
291        let changes_detected = state.metrics.commits_created_total as usize;
292
293        // Verify all values come from reducer metrics
294        assert_eq!(dev_runs_completed, 5);
295        assert_eq!(dev_runs_total, 5);
296        assert_eq!(review_passes_completed, 3);
297        assert_eq!(review_passes_total, 3);
298        assert_eq!(review_runs_total, 3);
299        assert_eq!(changes_detected, 6);
300
301        // Verify we're not using any separate runtime counters
302        // (this test proves the summary construction pattern)
303    }
304
305    #[test]
306    fn test_partial_run_shows_actual_not_configured() {
307        let state = PipelineState {
308            metrics: RunMetrics {
309                dev_iterations_completed: 3,
310                review_passes_completed: 1,
311                commits_created_total: 3,
312                ..RunMetrics::new(10, 5, &ContinuationState::new())
313            },
314            ..PipelineState::initial(10, 5)
315        };
316
317        assert_eq!(state.metrics.dev_iterations_completed, 3);
318        assert_eq!(state.metrics.max_dev_iterations, 10);
319        assert_eq!(state.metrics.review_passes_completed, 1);
320        assert_eq!(state.metrics.max_review_passes, 5);
321    }
322
323    #[test]
324    fn test_generated_files_includes_all_artifacts() {
325        use crate::files::agent_files::GENERATED_FILES;
326        // Verify GENERATED_FILES contains all known generated artifacts.
327        // If a new artifact is added to the pipeline, add it here too.
328        // Workspace cleanup must remove the marker because startup/finalization can operate
329        // purely through Workspace, but head-oid.txt and git-wrapper-dir.txt remain git-helper
330        // managed metadata outside the generated-files set.
331        assert!(
332            GENERATED_FILES.contains(&".agent/PLAN.md"),
333            "GENERATED_FILES must include .agent/PLAN.md"
334        );
335        assert!(
336            GENERATED_FILES.contains(&".agent/commit-message.txt"),
337            "GENERATED_FILES must include .agent/commit-message.txt"
338        );
339        assert!(
340            GENERATED_FILES.contains(&".agent/checkpoint.json.tmp"),
341            "GENERATED_FILES must include .agent/checkpoint.json.tmp"
342        );
343        assert!(
344            GENERATED_FILES.contains(&".git/ralph/no_agent_commit"),
345            "GENERATED_FILES must include .git/ralph/no_agent_commit"
346        );
347    }
348}