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 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 if let Some(monitor) = prompt_monitor {
74 monitor.stop().iter().for_each(|warning| {
75 ctx.logger.warn(warning);
76 });
77 }
78
79 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 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 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 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 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 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 let runtime_dev_completed = 5; let runtime_review_runs = 2; 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); assert_eq!(review_runs, 3); assert_eq!(commits, 8); 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 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 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 }
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 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}