1use 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#[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#[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 if let Some(monitor) = prompt_monitor {
68 monitor.stop().iter().for_each(|warning| {
69 ctx.logger.warn(warning);
70 });
71 }
72
73 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 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 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 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 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 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 let runtime_dev_completed = 5; let runtime_review_runs = 2; 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); assert_eq!(review_runs, 3); assert_eq!(commits, 8); 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 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 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 }
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 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}