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