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