1use super::commit_logging::{
14 AttemptOutcome, CommitAttemptLog, CommitLogSession, ExtractionAttempt,
15};
16use super::context::PhaseContext;
17use crate::agents::{AgentRegistry, AgentRole};
18use crate::checkpoint::execution_history::{ExecutionStep, StepOutcome};
19use crate::files::llm_output_extraction::{
20 archive_xml_file_with_workspace, preprocess_raw_content, try_extract_from_file_with_workspace,
21 try_extract_xml_commit_with_trace, xml_paths, CommitExtractionResult,
22};
23use crate::git_helpers::{git_add_all, git_commit, CommitResultFallback};
24use crate::logger::Logger;
25use crate::pipeline::PipelineRuntime;
26use crate::prompts::{
27 get_stored_or_generate_prompt, prompt_generate_commit_message_with_diff_with_context,
28 prompt_simplified_commit_with_context, prompt_xsd_retry_with_context,
29};
30use std::collections::HashMap;
31use std::fmt;
32use std::path::Path;
33
34fn preview_commit_message(msg: &str) -> String {
36 let first_line = msg.lines().next().unwrap_or(msg);
37 if first_line.len() > 60 {
38 format!("{}...", &first_line[..60])
39 } else {
40 first_line.to_string()
41 }
42}
43
44const MAX_SAFE_PROMPT_SIZE: usize = 200_000;
55
56pub(crate) const HARDCODED_FALLBACK_COMMIT: &str = "chore: automated commit";
67
68fn max_prompt_size_for_agent(commit_agent: &str) -> usize {
81 let agent_lower = commit_agent.to_lowercase();
82
83 if agent_lower.contains("glm")
85 || agent_lower.contains("zhipuai")
86 || agent_lower.contains("zai")
87 || agent_lower.contains("qwen")
88 || agent_lower.contains("deepseek")
89 {
90 100_000 } else if agent_lower.contains("claude")
92 || agent_lower.contains("ccs")
93 || agent_lower.contains("anthropic")
94 {
95 300_000 } else {
97 MAX_SAFE_PROMPT_SIZE }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113enum CommitRetryStrategy {
114 Normal,
116 Simplified,
118}
119
120impl CommitRetryStrategy {
121 const fn description(self) -> &'static str {
123 match self {
124 Self::Normal => "normal XML prompt",
125 Self::Simplified => "simplified XML prompt",
126 }
127 }
128
129 const fn next(self) -> Option<Self> {
131 match self {
132 Self::Normal => Some(Self::Simplified),
133 Self::Simplified => None,
134 }
135 }
136
137 const fn stage_number(self) -> usize {
139 match self {
140 Self::Normal => 1,
141 Self::Simplified => 2,
142 }
143 }
144
145 const fn total_stages() -> usize {
147 2 }
149
150 const fn max_session_retries(self) -> usize {
152 match self {
153 Self::Normal => crate::reducer::state::MAX_VALIDATION_RETRY_ATTEMPTS as usize,
154 Self::Simplified => crate::reducer::state::MAX_VALIDATION_RETRY_ATTEMPTS as usize,
155 }
156 }
157}
158
159impl fmt::Display for CommitRetryStrategy {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 write!(f, "{}", self.description())
162 }
163}
164
165pub struct CommitMessageResult {
167 pub message: String,
169 pub success: bool,
171 pub _log_path: String,
173 pub generated_prompts: std::collections::HashMap<String, String>,
176}
177
178fn truncate_diff_if_large(diff: &str, max_size: usize) -> String {
191 if diff.len() <= max_size {
192 return diff.to_string();
193 }
194
195 let mut files: Vec<DiffFile> = Vec::new();
197 let mut current_file = DiffFile::default();
198 let mut in_file = false;
199
200 for line in diff.lines() {
201 if line.starts_with("diff --git ") {
202 if in_file && !current_file.lines.is_empty() {
204 files.push(std::mem::take(&mut current_file));
205 }
206 in_file = true;
207 current_file.lines.push(line.to_string());
208
209 if let Some(path) = line.split(" b/").nth(1) {
211 current_file.path = path.to_string();
212 current_file.priority = prioritize_file_path(path);
213 }
214 } else if in_file {
215 current_file.lines.push(line.to_string());
216 }
217 }
218
219 if in_file && !current_file.lines.is_empty() {
221 files.push(current_file);
222 }
223
224 let total_files = files.len();
225
226 files.sort_by_key(|f| std::cmp::Reverse(f.priority));
228
229 let mut selected_files = Vec::new();
231 let mut current_size = 0;
232
233 for file in files {
234 let file_size: usize = file.lines.iter().map(|l| l.len() + 1).sum(); if current_size + file_size <= max_size {
237 current_size += file_size;
238 selected_files.push(file);
239 } else if current_size > 0 {
240 break;
243 } else {
244 let truncated_lines = truncate_lines_to_fit(&file.lines, max_size);
247 selected_files.push(DiffFile {
248 path: file.path,
249 priority: file.priority,
250 lines: truncated_lines,
251 });
252 break;
253 }
254 }
255
256 let selected_count = selected_files.len();
257 let omitted_count = total_files.saturating_sub(selected_count);
258
259 let mut result = String::new();
261
262 if omitted_count > 0 {
264 use std::fmt::Write;
265 let _ = write!(
266 result,
267 "[Diff truncated: Showing first {selected_count} of {total_files} files. {omitted_count} files omitted due to size constraints.]\n\n"
268 );
269 }
270
271 for file in selected_files {
272 for line in &file.lines {
273 result.push_str(line);
274 result.push('\n');
275 }
276 }
277
278 result
279}
280
281#[derive(Debug, Default, Clone)]
283struct DiffFile {
284 path: String,
286 priority: i32,
288 lines: Vec<String>,
290}
291
292fn prioritize_file_path(path: &str) -> i32 {
302 use std::path::Path;
303 let path_lower = path.to_lowercase();
304
305 let has_ext = |ext: &str| -> bool {
307 Path::new(path)
308 .extension()
309 .and_then(std::ffi::OsStr::to_str)
310 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
311 };
312
313 let has_ext_lower = |ext: &str| -> bool {
315 Path::new(&path_lower)
316 .extension()
317 .and_then(std::ffi::OsStr::to_str)
318 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
319 };
320
321 if path_lower.contains("src/") && has_ext_lower("rs") {
323 100
324 } else if path_lower.contains("src/") {
325 80
326 }
327 else if path_lower.contains("test") {
329 40
330 }
331 else if has_ext("toml")
333 || has_ext("json")
334 || path_lower.ends_with("cargo.toml")
335 || path_lower.ends_with("package.json")
336 || path_lower.ends_with("tsconfig.json")
337 {
338 60
339 }
340 else if path_lower.contains("doc") || has_ext("md") {
342 20
343 }
344 else {
346 50
347 }
348}
349
350fn truncate_lines_to_fit(lines: &[String], max_size: usize) -> Vec<String> {
355 let mut result = Vec::new();
356 let mut current_size = 0;
357
358 for line in lines {
359 let line_size = line.len() + 1; if current_size + line_size <= max_size {
361 current_size += line_size;
362 result.push(line.clone());
363 } else {
364 break;
365 }
366 }
367
368 if let Some(last) = result.last_mut() {
370 last.push_str(" [truncated...]");
371 }
372
373 result
374}
375
376fn check_and_pre_truncate_diff(
380 diff: &str,
381 commit_agent: &str,
382 runtime: &PipelineRuntime,
383) -> (String, bool) {
384 let max_size = max_prompt_size_for_agent(commit_agent);
385 if diff.len() > max_size {
386 runtime.logger.warn(&format!(
387 "Diff size ({} KB) exceeds agent limit ({} KB). Pre-truncating to avoid token errors.",
388 diff.len() / 1024,
389 max_size / 1024
390 ));
391 (truncate_diff_if_large(diff, max_size), true)
392 } else {
393 runtime.logger.info(&format!(
394 "Diff size ({} KB) is within safe limit ({} KB).",
395 diff.len() / 1024,
396 max_size / 1024
397 ));
398 (diff.to_string(), false)
399 }
400}
401
402fn generate_prompt_for_strategy(
410 strategy: CommitRetryStrategy,
411 working_diff: &str,
412 template_context: &crate::prompts::TemplateContext,
413 workspace: &dyn crate::workspace::Workspace,
414 xsd_error: Option<&str>,
415 prompt_history: &HashMap<String, String>,
416 prompt_key: &str,
417) -> (String, bool) {
418 let full_prompt_key = if xsd_error.is_some() {
421 format!("{}_xsd_retry", prompt_key)
422 } else {
423 prompt_key.to_string()
424 };
425
426 let (prompt, was_replayed) =
427 get_stored_or_generate_prompt(&full_prompt_key, prompt_history, || match strategy {
428 CommitRetryStrategy::Normal => {
429 if let Some(error_msg) = xsd_error {
430 prompt_xsd_retry_with_context(
432 template_context,
433 working_diff,
434 error_msg,
435 workspace,
436 )
437 } else {
438 prompt_generate_commit_message_with_diff_with_context(
440 template_context,
441 working_diff,
442 workspace,
443 )
444 }
445 }
446 CommitRetryStrategy::Simplified => {
447 if let Some(error_msg) = xsd_error {
448 prompt_xsd_retry_with_context(
450 template_context,
451 working_diff,
452 error_msg,
453 workspace,
454 )
455 } else {
456 prompt_simplified_commit_with_context(template_context, working_diff)
458 }
459 }
460 });
461
462 (prompt, was_replayed)
463}
464
465fn log_commit_attempt(
467 strategy: CommitRetryStrategy,
468 prompt_size_kb: usize,
469 commit_agent: &str,
470 runtime: &PipelineRuntime,
471) {
472 if strategy == CommitRetryStrategy::Normal {
473 runtime.logger.info(&format!(
474 "Attempt 1/{}: Using {} (prompt size: {} KB, agent: {})",
475 CommitRetryStrategy::total_stages(),
476 strategy,
477 prompt_size_kb,
478 commit_agent
479 ));
480 } else {
481 runtime.logger.warn(&format!(
482 "Attempt {}/{}: Re-prompting with {} (prompt size: {} KB, agent: {})...",
483 strategy as usize + 1,
484 CommitRetryStrategy::total_stages(),
485 strategy,
486 prompt_size_kb,
487 commit_agent
488 ));
489 }
490}
491
492fn handle_commit_extraction_result(
501 extraction_result: anyhow::Result<Option<CommitExtractionResult>>,
502 strategy: CommitRetryStrategy,
503 log_dir: &str,
504 runtime: &PipelineRuntime,
505 last_extraction: &mut Option<CommitExtractionResult>,
506 attempt_log: &mut CommitAttemptLog,
507) -> Option<anyhow::Result<CommitMessageResult>> {
508 let log_file = format!("{log_dir}/final.log");
509
510 match extraction_result {
511 Ok(Some(extraction)) => {
512 runtime.logger.info(&format!(
514 "Successfully extracted commit message with {strategy}"
515 ));
516 let message = extraction.clone().into_message();
517 attempt_log.set_outcome(AttemptOutcome::Success(message.clone()));
518 *last_extraction = Some(extraction);
519 Some(Ok(CommitMessageResult {
521 message,
522 success: true,
523 _log_path: log_file,
524 generated_prompts: std::collections::HashMap::new(),
525 }))
526 }
527 Ok(None) => {
528 runtime.logger.warn(&format!(
529 "No valid commit message extracted with {strategy}, will try next strategy"
530 ));
531 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(
532 "No valid commit message extracted".to_string(),
533 ));
534 None }
536 Err(e) => {
537 runtime.logger.error(&format!(
538 "Failed to extract commit message with {strategy}: {e}"
539 ));
540 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(e.to_string()));
541 None }
543 }
544}
545
546fn build_agents_to_try<'a>(fallbacks: &'a [&'a str], primary_agent: &'a str) -> Vec<&'a str> {
551 let mut agents_to_try: Vec<&'a str> = vec![primary_agent];
552 for fb in fallbacks {
553 if *fb != primary_agent && !agents_to_try.contains(fb) {
554 agents_to_try.push(fb);
555 }
556 }
557 agents_to_try
558}
559
560struct CommitAttemptContext<'a> {
562 working_diff: &'a str,
564 log_dir: &'a str,
566 diff_was_truncated: bool,
568 template_context: &'a crate::prompts::TemplateContext,
570 workspace: &'a dyn crate::workspace::Workspace,
572 prompt_history: &'a HashMap<String, String>,
574 prompt_key: String,
576 generated_prompts: &'a mut std::collections::HashMap<String, String>,
579}
580
581fn run_commit_attempt_with_agent(
588 strategy: CommitRetryStrategy,
589 ctx: &mut CommitAttemptContext<'_>,
590 runtime: &mut PipelineRuntime,
591 registry: &AgentRegistry,
592 agent: &str,
593 last_extraction: &mut Option<CommitExtractionResult>,
594 session: &mut CommitLogSession,
595) -> Option<anyhow::Result<CommitMessageResult>> {
596 let Some(agent_config) = registry.resolve_config(agent) else {
598 runtime
599 .logger
600 .warn(&format!("Agent '{agent}' not found in registry, skipping"));
601 let mut attempt_log = session.new_attempt(agent, strategy.description());
602 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(format!(
603 "Agent '{agent}' not found in registry"
604 )));
605 let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
606 return None;
607 };
608
609 let cmd_str = agent_config.build_cmd(true, true, false);
611 let logfile = format!("{}/{}_latest.log", ctx.log_dir, agent.replace('/', "-"));
612
613 let max_retries = strategy.max_session_retries();
615 let mut xsd_error: Option<String> = None;
616
617 for retry_num in 0..max_retries {
618 if retry_num > 0 {
620 use crate::files::io::check_and_cleanup_xml_before_retry_with_workspace;
621 use std::path::Path;
622 let xml_path =
623 Path::new(crate::files::llm_output_extraction::xml_paths::COMMIT_MESSAGE_XML);
624 let _ = check_and_cleanup_xml_before_retry_with_workspace(
625 ctx.workspace,
626 xml_path,
627 runtime.logger,
628 );
629 }
630
631 let prompt_key = format!("{}_{}", ctx.prompt_key, strategy.stage_number());
635 let (prompt, was_replayed) = generate_prompt_for_strategy(
636 strategy,
637 ctx.working_diff,
638 ctx.template_context,
639 ctx.workspace,
640 xsd_error.as_deref(),
641 ctx.prompt_history,
642 &prompt_key,
643 );
644 let prompt_size_kb = prompt.len() / 1024;
645
646 if was_replayed && retry_num == 0 {
648 runtime.logger.info(&format!(
649 "Using stored prompt from checkpoint for determinism: {}",
650 prompt_key
651 ));
652 } else if !was_replayed {
653 ctx.generated_prompts
655 .insert(prompt_key.clone(), prompt.clone());
656 }
657
658 let mut attempt_log = session.new_attempt(agent, strategy.description());
660 attempt_log.set_prompt_size(prompt.len());
661 attempt_log.set_diff_info(ctx.working_diff.len(), ctx.diff_was_truncated);
662
663 if retry_num > 0 {
665 runtime.logger.info(&format!(
666 " In-session retry {}/{} for XSD validation",
667 retry_num,
668 max_retries - 1
669 ));
670 if let Some(ref error) = xsd_error {
671 runtime.logger.info(&format!(" XSD error: {}", error));
672 }
673 } else {
674 log_commit_attempt(strategy, prompt_size_kb, agent, runtime);
675 }
676
677 let exit_code = match crate::pipeline::run_with_prompt(
679 &crate::pipeline::PromptCommand {
680 label: &format!("generate commit message ({})", strategy.description()),
681 display_name: agent,
682 cmd_str: &cmd_str,
683 prompt: &prompt,
684 logfile: &logfile,
685 parser_type: agent_config.json_parser,
686 env_vars: &agent_config.env_vars,
687 },
688 runtime,
689 ) {
690 Ok(result) => result.exit_code,
691 Err(e) => {
692 runtime.logger.error(&format!("Failed to run agent: {e}"));
693 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(format!(
694 "Agent execution failed: {e}"
695 )));
696 let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
697 return None;
698 }
699 };
700
701 if exit_code != 0 {
702 runtime
703 .logger
704 .warn("Commit agent failed, checking logs for partial output...");
705 }
706
707 let extraction_result = extract_commit_message_from_logs_with_trace(
708 ctx.log_dir,
709 ctx.working_diff,
710 agent,
711 runtime.logger,
712 &mut attempt_log,
713 ctx.workspace,
714 );
715
716 match &extraction_result {
718 Ok(Some(_)) => {
719 let result = handle_commit_extraction_result(
721 extraction_result,
722 strategy,
723 ctx.log_dir,
724 runtime,
725 last_extraction,
726 &mut attempt_log,
727 );
728
729 if let Err(e) = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace) {
730 runtime
731 .logger
732 .warn(&format!("Failed to write attempt log: {e}"));
733 }
734
735 return result;
736 }
737 _ => {
738 }
740 };
741
742 let xsd_error_msg = attempt_log
744 .extraction_attempts
745 .iter()
746 .find(|attempt| attempt.detail.contains("XSD validation failed"))
747 .map(|attempt| attempt.detail.clone());
748
749 if let Some(ref error_msg) = xsd_error_msg {
750 runtime
751 .logger
752 .warn(&format!(" XSD validation failed: {}", error_msg));
753
754 if retry_num < max_retries - 1 {
755 let error = error_msg
757 .strip_prefix("XSD validation failed: ")
758 .unwrap_or(error_msg);
759
760 xsd_error = Some(error.to_string());
762
763 attempt_log.set_outcome(AttemptOutcome::XsdValidationFailed(error.to_string()));
765 let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
766
767 continue;
769 } else {
770 runtime
772 .logger
773 .warn(" No more in-session retries remaining");
774 }
775 }
776
777 let result = handle_commit_extraction_result(
779 extraction_result,
780 strategy,
781 ctx.log_dir,
782 runtime,
783 last_extraction,
784 &mut attempt_log,
785 );
786
787 if let Err(e) = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace) {
789 runtime
790 .logger
791 .warn(&format!("Failed to write attempt log: {e}"));
792 }
793
794 if result.is_some() {
796 return result;
797 }
798
799 if retry_num >= max_retries - 1 {
801 break;
802 }
803
804 break;
806 }
807
808 None
809}
810
811fn return_hardcoded_fallback(
813 log_file: &str,
814 runtime: &PipelineRuntime,
815 generated_prompts: std::collections::HashMap<String, String>,
816) -> CommitMessageResult {
817 runtime.logger.warn("");
818 runtime.logger.warn("All recovery methods failed:");
819 runtime.logger.warn(" - All 9 prompt variants exhausted");
820 runtime
821 .logger
822 .warn(" - All agents in fallback chain exhausted");
823 runtime.logger.warn(" - All truncation stages failed");
824 runtime.logger.warn(" - Emergency prompts failed");
825 runtime.logger.warn("");
826 runtime
827 .logger
828 .warn("Using hardcoded fallback commit message as last resort.");
829 runtime.logger.warn(&format!(
830 "Fallback message: \"{HARDCODED_FALLBACK_COMMIT}\""
831 ));
832 runtime.logger.warn("");
833
834 CommitMessageResult {
835 message: HARDCODED_FALLBACK_COMMIT.to_string(),
836 success: true,
837 _log_path: log_file.to_string(),
838 generated_prompts,
839 }
840}
841
842pub fn generate_commit_message(
885 diff: &str,
886 registry: &AgentRegistry,
887 runtime: &mut PipelineRuntime,
888 commit_agent: &str,
889 template_context: &crate::prompts::TemplateContext,
890 workspace: &dyn crate::workspace::Workspace,
891 prompt_history: &HashMap<String, String>,
892) -> anyhow::Result<CommitMessageResult> {
893 let log_dir = ".agent/logs/commit_generation";
894 let log_file = format!("{log_dir}/final.log");
895
896 workspace.create_dir_all(Path::new(log_dir))?;
897
898 if diff.trim().is_empty() {
900 runtime
901 .logger
902 .warn("Empty diff provided to generate_commit_message, using fallback");
903 return Ok(CommitMessageResult {
904 message: HARDCODED_FALLBACK_COMMIT.to_string(),
905 success: true,
906 _log_path: log_file,
907 generated_prompts: std::collections::HashMap::new(),
908 });
909 }
910
911 runtime.logger.info("Generating commit message...");
912
913 let mut session = create_commit_log_session(log_dir, runtime, workspace);
915 let (working_diff, diff_was_pre_truncated) =
916 check_and_pre_truncate_diff(diff, commit_agent, runtime);
917
918 let fallbacks = registry.available_fallbacks(AgentRole::Commit);
919 let agents_to_try = build_agents_to_try(&fallbacks, commit_agent);
920
921 let mut last_extraction: Option<CommitExtractionResult> = None;
922 let mut total_attempts = 0;
923
924 let prompt_key = format!(
927 "commit_{}",
928 std::time::SystemTime::now()
929 .duration_since(std::time::UNIX_EPOCH)
930 .unwrap_or_default()
931 .as_secs()
932 );
933
934 let mut generated_prompts = std::collections::HashMap::new();
936
937 let mut attempt_ctx = CommitAttemptContext {
938 working_diff: &working_diff,
939 log_dir,
940 diff_was_truncated: diff_was_pre_truncated,
941 template_context,
942 workspace,
943 prompt_history,
944 prompt_key,
945 generated_prompts: &mut generated_prompts,
946 };
947
948 if let Some(result) = try_agents_with_strategies(
950 &agents_to_try,
951 &mut attempt_ctx,
952 runtime,
953 registry,
954 &mut last_extraction,
955 &mut session,
956 &mut total_attempts,
957 ) {
958 log_completion(runtime, &session, total_attempts, &result, workspace);
959 return result.map(|mut r| {
961 r.generated_prompts = generated_prompts;
962 r
963 });
964 }
965
966 let fallback_ctx = CommitFallbackContext {
968 log_file: &log_file,
969 };
970 handle_commit_fallbacks(
971 &fallback_ctx,
972 runtime,
973 &session,
974 total_attempts,
975 last_extraction.as_ref(),
976 generated_prompts,
977 workspace,
978 )
979}
980
981fn create_commit_log_session(
983 log_dir: &str,
984 runtime: &mut PipelineRuntime,
985 workspace: &dyn crate::workspace::Workspace,
986) -> CommitLogSession {
987 match CommitLogSession::new(log_dir, workspace) {
988 Ok(s) => {
989 runtime.logger.info(&format!(
990 "Commit logs will be written to: {}",
991 s.run_dir().display()
992 ));
993 s
994 }
995 Err(e) => {
996 runtime
997 .logger
998 .warn(&format!("Failed to create log session: {e}"));
999 CommitLogSession::new(log_dir, workspace).unwrap_or_else(|_| {
1000 CommitLogSession::new("/tmp/ralph-commit-logs", workspace)
1001 .expect("fallback session")
1002 })
1003 }
1004 }
1005}
1006
1007fn try_agents_with_strategies(
1017 agents: &[&str],
1018 ctx: &mut CommitAttemptContext<'_>,
1019 runtime: &mut PipelineRuntime,
1020 registry: &AgentRegistry,
1021 last_extraction: &mut Option<CommitExtractionResult>,
1022 session: &mut CommitLogSession,
1023 total_attempts: &mut usize,
1024) -> Option<anyhow::Result<CommitMessageResult>> {
1025 let mut strategy = CommitRetryStrategy::Normal;
1026 loop {
1027 runtime.logger.info(&format!(
1028 "Trying strategy {}/{}: {}",
1029 strategy.stage_number(),
1030 CommitRetryStrategy::total_stages(),
1031 strategy.description()
1032 ));
1033
1034 for (agent_idx, agent) in agents.iter().enumerate() {
1035 runtime.logger.info(&format!(
1036 " - Agent {}/{}: {agent}",
1037 agent_idx + 1,
1038 agents.len()
1039 ));
1040
1041 *total_attempts += 1;
1042 if let Some(result) = run_commit_attempt_with_agent(
1043 strategy,
1044 ctx,
1045 runtime,
1046 registry,
1047 agent,
1048 last_extraction,
1049 session,
1050 ) {
1051 return Some(result);
1052 }
1053 }
1054
1055 runtime.logger.warn(&format!(
1056 "All agents failed for strategy: {}",
1057 strategy.description()
1058 ));
1059
1060 match strategy.next() {
1061 Some(next) => strategy = next,
1062 None => break,
1063 }
1064 }
1065 None
1066}
1067
1068fn log_completion(
1070 runtime: &mut PipelineRuntime,
1071 session: &CommitLogSession,
1072 total_attempts: usize,
1073 result: &anyhow::Result<CommitMessageResult>,
1074 workspace: &dyn crate::workspace::Workspace,
1075) {
1076 if let Ok(ref commit_result) = result {
1077 let _ = session.write_summary(
1078 total_attempts,
1079 &format!(
1080 "SUCCESS: {}",
1081 preview_commit_message(&commit_result.message)
1082 ),
1083 workspace,
1084 );
1085 }
1086 runtime.logger.info(&format!(
1087 "Commit generation complete after {total_attempts} attempts. Logs: {}",
1088 session.run_dir().display()
1089 ));
1090}
1091
1092struct CommitFallbackContext<'a> {
1094 log_file: &'a str,
1095}
1096
1097fn handle_commit_fallbacks(
1103 ctx: &CommitFallbackContext<'_>,
1104 runtime: &mut PipelineRuntime,
1105 session: &CommitLogSession,
1106 total_attempts: usize,
1107 last_extraction: Option<&CommitExtractionResult>,
1108 generated_prompts: std::collections::HashMap<String, String>,
1109 workspace: &dyn crate::workspace::Workspace,
1110) -> anyhow::Result<CommitMessageResult> {
1111 if let Some(extraction) = last_extraction {
1114 let message = extraction.clone().into_message();
1115 let _ = session.write_summary(
1116 total_attempts,
1117 &format!("LAST_EXTRACTION: {}", preview_commit_message(&message)),
1118 workspace,
1119 );
1120 runtime.logger.info(&format!(
1121 "Commit generation complete after {total_attempts} attempts. Logs: {}",
1122 session.run_dir().display()
1123 ));
1124 return Ok(CommitMessageResult {
1125 message,
1126 success: true,
1127 _log_path: ctx.log_file.to_string(),
1128 generated_prompts,
1129 });
1130 }
1131
1132 let _ = session.write_summary(
1134 total_attempts,
1135 &format!("HARDCODED_FALLBACK: {HARDCODED_FALLBACK_COMMIT}"),
1136 workspace,
1137 );
1138 runtime.logger.info(&format!(
1139 "Commit generation complete after {total_attempts} attempts (hardcoded fallback). Logs: {}",
1140 session.run_dir().display()
1141 ));
1142 Ok(return_hardcoded_fallback(
1143 ctx.log_file,
1144 runtime,
1145 generated_prompts,
1146 ))
1147}
1148
1149pub fn commit_with_generated_message(
1166 diff: &str,
1167 commit_agent: &str,
1168 git_user_name: Option<&str>,
1169 git_user_email: Option<&str>,
1170 ctx: &mut PhaseContext<'_>,
1171) -> CommitResultFallback {
1172 let staged = match git_add_all() {
1174 Ok(s) => s,
1175 Err(e) => {
1176 return CommitResultFallback::Failed(format!("Failed to stage changes: {e}"));
1177 }
1178 };
1179
1180 if !staged {
1181 return CommitResultFallback::NoChanges;
1182 }
1183
1184 let start_time = std::time::Instant::now();
1186
1187 let mut runtime = PipelineRuntime {
1189 timer: ctx.timer,
1190 logger: ctx.logger,
1191 colors: ctx.colors,
1192 config: ctx.config,
1193 executor: ctx.executor,
1194 executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
1195 workspace: ctx.workspace,
1196 };
1197
1198 let result = match generate_commit_message(
1200 diff,
1201 ctx.registry,
1202 &mut runtime,
1203 commit_agent,
1204 ctx.template_context,
1205 ctx.workspace,
1206 &ctx.prompt_history,
1207 ) {
1208 Ok(r) => r,
1209 Err(e) => {
1210 ctx.execution_history.add_step(
1212 ExecutionStep::new(
1213 "commit",
1214 0,
1215 "commit_generation",
1216 StepOutcome::failure(format!("Failed to generate commit message: {e}"), false),
1217 )
1218 .with_agent(commit_agent)
1219 .with_duration(start_time.elapsed().as_secs()),
1220 );
1221 return CommitResultFallback::Failed(format!("Failed to generate commit message: {e}"));
1222 }
1223 };
1224
1225 for (key, prompt) in result.generated_prompts {
1227 ctx.capture_prompt(&key, &prompt);
1228 }
1229
1230 if !result.success || result.message.trim().is_empty() {
1232 ctx.logger
1234 .warn("Commit generation returned empty message, using hardcoded fallback...");
1235 let fallback_message = HARDCODED_FALLBACK_COMMIT.to_string();
1236 let commit_result = match git_commit(
1237 &fallback_message,
1238 git_user_name,
1239 git_user_email,
1240 Some(ctx.executor),
1241 ) {
1242 Ok(Some(oid)) => CommitResultFallback::Success(oid),
1243 Ok(None) => CommitResultFallback::NoChanges,
1244 Err(e) => CommitResultFallback::Failed(format!("Failed to create commit: {e}")),
1245 };
1246 let outcome = match &commit_result {
1248 CommitResultFallback::Success(oid) => StepOutcome::success(
1249 Some(format!("Commit created: {oid}")),
1250 vec![".".to_string()],
1251 ),
1252 CommitResultFallback::NoChanges => {
1253 StepOutcome::skipped("No changes to commit".to_string())
1254 }
1255 CommitResultFallback::Failed(e) => StepOutcome::failure(e.clone(), false),
1256 };
1257 ctx.execution_history.add_step(
1258 ExecutionStep::new("commit", 0, "commit_generation", outcome)
1259 .with_agent(commit_agent)
1260 .with_duration(start_time.elapsed().as_secs()),
1261 );
1262 commit_result
1263 } else {
1264 let commit_result = match git_commit(
1266 &result.message,
1267 git_user_name,
1268 git_user_email,
1269 Some(ctx.executor),
1270 ) {
1271 Ok(Some(oid)) => CommitResultFallback::Success(oid),
1272 Ok(None) => CommitResultFallback::NoChanges,
1273 Err(e) => CommitResultFallback::Failed(format!("Failed to create commit: {e}")),
1274 };
1275 let outcome = match &commit_result {
1277 CommitResultFallback::Success(oid) => StepOutcome::success(
1278 Some(format!("Commit created: {oid}")),
1279 vec![".".to_string()],
1280 ),
1281 CommitResultFallback::NoChanges => {
1282 StepOutcome::skipped("No changes to commit".to_string())
1283 }
1284 CommitResultFallback::Failed(e) => StepOutcome::failure(e.clone(), false),
1285 };
1286 let oid_for_history = match &commit_result {
1287 CommitResultFallback::Success(oid) => Some(oid.to_string()),
1288 _ => None,
1289 };
1290 let mut step = ExecutionStep::new("commit", 0, "commit_generation", outcome)
1291 .with_agent(commit_agent)
1292 .with_duration(start_time.elapsed().as_secs());
1293 if let Some(oid) = &oid_for_history {
1294 step = step.with_git_commit_oid(oid);
1295 }
1296 ctx.execution_history.add_step(step);
1297 commit_result
1298 }
1299}
1300
1301use crate::phases::commit_logging::{ParsingTraceLog, ParsingTraceStep};
1303
1304fn write_parsing_trace_with_logging(
1306 parsing_trace: &ParsingTraceLog,
1307 log_dir: &str,
1308 logger: &Logger,
1309 workspace: &dyn crate::workspace::Workspace,
1310) {
1311 if let Err(e) = parsing_trace.write_to_workspace(Path::new(log_dir), workspace) {
1312 logger.warn(&format!("Failed to write parsing trace log: {e}"));
1313 }
1314}
1315
1316fn try_xml_extraction_traced(
1319 content: &str,
1320 step_number: &mut usize,
1321 parsing_trace: &mut ParsingTraceLog,
1322 logger: &Logger,
1323 attempt_log: &mut CommitAttemptLog,
1324 log_dir: &str,
1325 workspace: &dyn crate::workspace::Workspace,
1326) -> Option<CommitExtractionResult> {
1327 let xml_file_path = Path::new(xml_paths::COMMIT_MESSAGE_XML);
1329 let (xml_result, xml_detail) =
1330 if let Some(file_xml) = try_extract_from_file_with_workspace(workspace, xml_file_path) {
1331 let (validated, detail) = try_extract_xml_commit_with_trace(&file_xml);
1333 let detail = format!("file-based: {}", detail);
1334 (validated, detail)
1335 } else {
1336 try_extract_xml_commit_with_trace(content)
1338 };
1339 logger.info(&format!(" ✓ XML extraction: {xml_detail}"));
1340
1341 parsing_trace.add_step(
1342 ParsingTraceStep::new(*step_number, "XML Extraction")
1343 .with_input(&content[..content.len().min(1000)])
1344 .with_result(xml_result.as_deref().unwrap_or("[No XML found]"))
1345 .with_success(xml_result.is_some())
1346 .with_details(&xml_detail),
1347 );
1348 *step_number += 1;
1349
1350 if let Some(message) = xml_result {
1351 archive_xml_file_with_workspace(workspace, xml_file_path);
1354
1355 attempt_log.add_extraction_attempt(ExtractionAttempt::success("XML", xml_detail));
1356 parsing_trace.set_final_message(&message);
1357 write_parsing_trace_with_logging(parsing_trace, log_dir, logger, workspace);
1358 return Some(CommitExtractionResult::new(message));
1359 }
1360
1361 attempt_log.add_extraction_attempt(ExtractionAttempt::failure("XML", xml_detail));
1363 logger.info(" ✗ XML extraction failed");
1364 None
1365}
1366
1367fn extract_commit_message_from_logs_with_trace(
1375 log_dir: &str,
1376 _diff: &str,
1377 _agent_cmd: &str,
1378 logger: &Logger,
1379 attempt_log: &mut CommitAttemptLog,
1380 workspace: &dyn crate::workspace::Workspace,
1381) -> anyhow::Result<Option<CommitExtractionResult>> {
1382 let mut parsing_trace = ParsingTraceLog::new(
1384 attempt_log.attempt_number,
1385 &attempt_log.agent,
1386 &attempt_log.strategy,
1387 );
1388
1389 let Some(content) = read_log_content_with_trace(log_dir, logger, attempt_log, workspace)?
1391 else {
1392 return Ok(None);
1393 };
1394
1395 parsing_trace.set_raw_output(&content);
1397
1398 let mut step_number = 1;
1399
1400 if let Some(result) = try_xml_extraction_traced(
1404 &content,
1405 &mut step_number,
1406 &mut parsing_trace,
1407 logger,
1408 attempt_log,
1409 log_dir,
1410 workspace,
1411 ) {
1412 return Ok(Some(result));
1413 }
1414
1415 parsing_trace.add_step(
1417 ParsingTraceStep::new(step_number, "XML Extraction Failed")
1418 .with_input(&content[..content.len().min(1000)])
1419 .with_success(false)
1420 .with_details("No valid XML found or XSD validation failed"),
1421 );
1422
1423 write_parsing_trace_with_logging(&parsing_trace, log_dir, logger, workspace);
1424
1425 Ok(None)
1429}
1430
1431fn read_log_content_with_trace(
1433 log_dir: &str,
1434 logger: &Logger,
1435 attempt_log: &mut CommitAttemptLog,
1436 workspace: &dyn crate::workspace::Workspace,
1437) -> anyhow::Result<Option<String>> {
1438 let log_dir_path = Path::new(log_dir);
1439 let log_path = find_most_recent_log_in_workspace(log_dir_path, workspace)?;
1440 let Some(log_file) = log_path else {
1441 logger.warn("No log files found in commit generation directory");
1442 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1443 "File",
1444 "No log files found".to_string(),
1445 ));
1446 return Ok(None);
1447 };
1448
1449 logger.info(&format!(
1450 "Reading commit message from log: {}",
1451 log_file.display()
1452 ));
1453
1454 let content = workspace.read(&log_file)?;
1455 attempt_log.set_raw_output(&content);
1456
1457 if content.trim().is_empty() {
1458 logger.warn("Log file is empty");
1459 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1460 "File",
1461 "Log file is empty".to_string(),
1462 ));
1463 return Ok(None);
1464 }
1465
1466 Ok(Some(preprocess_raw_content(&content)))
1468}
1469
1470fn find_most_recent_log_in_workspace(
1486 log_dir: &Path,
1487 workspace: &dyn crate::workspace::Workspace,
1488) -> anyhow::Result<Option<std::path::PathBuf>> {
1489 if !workspace.exists(log_dir) {
1495 return Ok(None);
1496 }
1497
1498 let entries = match workspace.read_dir(log_dir) {
1499 Ok(e) => e,
1500 Err(_) => return Ok(None),
1501 };
1502
1503 let mut most_recent: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
1504
1505 for entry in entries {
1506 if !entry.is_file() {
1507 continue;
1508 }
1509
1510 if let Some(filename) = entry.file_name().and_then(|s| s.to_str()) {
1512 if !filename.ends_with(".log") {
1513 continue;
1514 }
1515 } else {
1516 continue;
1517 }
1518
1519 if let Some(modified) = entry.modified() {
1520 match &most_recent {
1521 None => {
1522 most_recent = Some((entry.path().to_path_buf(), modified));
1523 }
1524 Some((_, prev_modified)) if modified > *prev_modified => {
1525 most_recent = Some((entry.path().to_path_buf(), modified));
1526 }
1527 _ => {}
1528 }
1529 } else {
1530 if most_recent.is_none() {
1532 most_recent = Some((
1533 entry.path().to_path_buf(),
1534 std::time::SystemTime::UNIX_EPOCH,
1535 ));
1536 }
1537 }
1538 }
1539
1540 Ok(most_recent.map(|(path, _)| path))
1541}
1542
1543#[cfg(test)]
1544mod tests {
1545 use super::*;
1546 use crate::workspace::MemoryWorkspace;
1547 use std::path::Path;
1548 use std::time::{Duration, SystemTime};
1549
1550 fn read_log_content_with_workspace(
1555 log_dir: &Path,
1556 workspace: &dyn crate::workspace::Workspace,
1557 ) -> anyhow::Result<Option<String>> {
1558 let log_path = find_most_recent_log_in_workspace(log_dir, workspace)?;
1559 let Some(log_file) = log_path else {
1560 return Ok(None);
1561 };
1562
1563 let content = workspace.read(&log_file)?;
1564
1565 if content.trim().is_empty() {
1566 return Ok(None);
1567 }
1568
1569 Ok(Some(content))
1570 }
1571
1572 #[test]
1573 fn test_find_most_recent_log_uses_workspace() {
1574 let time1 = SystemTime::UNIX_EPOCH + Duration::from_secs(1000);
1576 let time2 = SystemTime::UNIX_EPOCH + Duration::from_secs(2000);
1577
1578 let workspace = MemoryWorkspace::new_test()
1579 .with_file_at_time(
1580 ".agent/logs/commit_generation/old_agent_0.log",
1581 "old content",
1582 time1,
1583 )
1584 .with_file_at_time(
1585 ".agent/logs/commit_generation/new_agent_0.log",
1586 "new content with XML",
1587 time2,
1588 );
1589
1590 let log_dir = Path::new(".agent/logs/commit_generation");
1592 let result = find_most_recent_log_in_workspace(log_dir, &workspace);
1593 assert!(result.is_ok());
1594 let log_path = result.unwrap();
1595 assert!(log_path.is_some());
1596 let path = log_path.unwrap();
1598 assert!(path.to_string_lossy().contains("new_agent"));
1599 }
1600
1601 #[test]
1602 fn test_find_most_recent_log_empty_workspace() {
1603 let workspace = MemoryWorkspace::new_test();
1604
1605 let log_dir = Path::new(".agent/logs/commit_generation");
1606 let result = find_most_recent_log_in_workspace(log_dir, &workspace);
1607 assert!(result.is_ok());
1608 assert!(result.unwrap().is_none());
1609 }
1610
1611 #[test]
1612 fn test_read_log_content_uses_workspace() {
1613 let workspace = MemoryWorkspace::new_test().with_file(
1615 ".agent/logs/commit_generation/test_agent_0.log",
1616 "test log content for extraction",
1617 );
1618
1619 let log_dir = Path::new(".agent/logs/commit_generation");
1620
1621 let result = read_log_content_with_workspace(log_dir, &workspace);
1623 assert!(result.is_ok());
1624 let content = result.unwrap();
1625 assert!(content.is_some());
1626 assert_eq!(content.unwrap(), "test log content for extraction");
1627 }
1628
1629 #[test]
1630 fn test_read_log_content_empty_log() {
1631 let workspace = MemoryWorkspace::new_test()
1632 .with_file(".agent/logs/commit_generation/empty_agent_0.log", "");
1633
1634 let log_dir = Path::new(".agent/logs/commit_generation");
1635 let result = read_log_content_with_workspace(log_dir, &workspace);
1636 assert!(result.is_ok());
1637 assert!(result.unwrap().is_none());
1639 }
1640
1641 #[test]
1642 fn test_truncate_diff_if_large() {
1643 let large_diff = "a".repeat(100_000);
1644 let truncated = truncate_diff_if_large(&large_diff, 10_000);
1645
1646 assert!(truncated.len() < large_diff.len());
1648 }
1649
1650 #[test]
1651 fn test_truncate_preserves_small_diffs() {
1652 let small_diff = "a".repeat(100);
1653 let truncated = truncate_diff_if_large(&small_diff, 10_000);
1654
1655 assert_eq!(truncated, small_diff);
1657 }
1658
1659 #[test]
1660 fn test_truncate_exactly_at_limit() {
1661 let diff = "a".repeat(10_000);
1662 let truncated = truncate_diff_if_large(&diff, 10_000);
1663
1664 assert_eq!(truncated, diff);
1666 }
1667
1668 #[test]
1669 fn test_truncate_preserves_file_boundaries() {
1670 let diff = "diff --git a/file1.rs b/file1.rs\n\
1671 +line1\n\
1672 +line2\n\
1673 diff --git a/file2.rs b/file2.rs\n\
1674 +line3\n\
1675 +line4\n";
1676 let large_diff = format!("{}{}", diff, "x".repeat(100_000));
1677 let truncated = truncate_diff_if_large(&large_diff, 50);
1678
1679 assert!(truncated.contains("diff --git"));
1681 assert!(truncated.contains("Diff truncated"));
1683 }
1684
1685 #[test]
1686 fn test_prioritize_file_path() {
1687 assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("tests/test.rs"));
1689 assert!(prioritize_file_path("src/lib.rs") > prioritize_file_path("README.md"));
1690
1691 assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("test/test.rs"));
1693
1694 assert!(prioritize_file_path("Cargo.toml") > prioritize_file_path("docs/guide.md"));
1696
1697 assert!(prioritize_file_path("README.md") < prioritize_file_path("src/main.rs"));
1699 }
1700
1701 #[test]
1702 fn test_truncate_keeps_high_priority_files() {
1703 let diff = "diff --git a/README.md b/README.md\n\
1704 +doc change\n\
1705 diff --git a/src/main.rs b/src/main.rs\n\
1706 +important change\n\
1707 diff --git a/tests/test.rs b/tests/test.rs\n\
1708 +test change\n";
1709
1710 let truncated = truncate_diff_if_large(diff, 80);
1712
1713 assert!(truncated.contains("src/main.rs"));
1715 }
1716
1717 #[test]
1718 fn test_truncate_lines_to_fit() {
1719 let lines = vec![
1720 "line1".to_string(),
1721 "line2".to_string(),
1722 "line3".to_string(),
1723 "line4".to_string(),
1724 ];
1725
1726 let truncated = truncate_lines_to_fit(&lines, 18);
1728
1729 assert_eq!(truncated.len(), 3);
1730 assert!(truncated[2].ends_with("[truncated...]"));
1732 }
1733
1734 #[test]
1735 fn test_hardcoded_fallback_commit() {
1736 use crate::files::llm_output_extraction::is_conventional_commit_subject;
1738 assert!(
1739 is_conventional_commit_subject(HARDCODED_FALLBACK_COMMIT),
1740 "Hardcoded fallback must be a valid conventional commit"
1741 );
1742 assert!(!HARDCODED_FALLBACK_COMMIT.is_empty());
1743 assert!(HARDCODED_FALLBACK_COMMIT.len() >= 5);
1744 }
1745}