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::common::truncate_text;
20use crate::files::llm_output_extraction::{
21 archive_xml_file_with_workspace, preprocess_raw_content, try_extract_from_file_with_workspace,
22 try_extract_xml_commit_with_trace, xml_paths, CommitExtractionResult,
23};
24use crate::git_helpers::{git_add_all, git_commit, CommitResultFallback};
25use crate::logger::Logger;
26use crate::pipeline::PipelineRuntime;
27use crate::prompts::{
28 get_stored_or_generate_prompt, prompt_generate_commit_message_with_diff_with_context,
29 prompt_simplified_commit_with_context, prompt_xsd_retry_with_context,
30};
31use std::collections::HashMap;
32use std::fmt;
33use std::path::Path;
34
35fn preview_commit_message(msg: &str) -> String {
39 let first_line = msg.lines().next().unwrap_or(msg);
40 truncate_text(first_line, 63)
42}
43
44const MAX_SAFE_PROMPT_SIZE: usize = 200_000;
55
56const GLM_MAX_PROMPT_SIZE: usize = 100_000;
58
59const CLAUDE_MAX_PROMPT_SIZE: usize = 300_000;
61
62pub(crate) const HARDCODED_FALLBACK_COMMIT: &str = "chore: automated commit";
73
74fn max_prompt_size_for_agent(commit_agent: &str) -> usize {
87 let agent_lower = commit_agent.to_lowercase();
88
89 if agent_lower.contains("glm")
91 || agent_lower.contains("zhipuai")
92 || agent_lower.contains("zai")
93 || agent_lower.contains("qwen")
94 || agent_lower.contains("deepseek")
95 {
96 GLM_MAX_PROMPT_SIZE
97 } else if agent_lower.contains("claude")
98 || agent_lower.contains("ccs")
99 || agent_lower.contains("anthropic")
100 {
101 CLAUDE_MAX_PROMPT_SIZE
102 } else {
103 MAX_SAFE_PROMPT_SIZE
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119enum CommitRetryStrategy {
120 Normal,
122 Simplified,
124}
125
126impl CommitRetryStrategy {
127 const fn description(self) -> &'static str {
129 match self {
130 Self::Normal => "normal XML prompt",
131 Self::Simplified => "simplified XML prompt",
132 }
133 }
134
135 const fn next(self) -> Option<Self> {
137 match self {
138 Self::Normal => Some(Self::Simplified),
139 Self::Simplified => None,
140 }
141 }
142
143 const fn stage_number(self) -> usize {
145 match self {
146 Self::Normal => 1,
147 Self::Simplified => 2,
148 }
149 }
150
151 const fn total_stages() -> usize {
153 2 }
155
156 const fn max_session_retries(self) -> usize {
158 match self {
159 Self::Normal => crate::reducer::state::MAX_VALIDATION_RETRY_ATTEMPTS as usize,
160 Self::Simplified => crate::reducer::state::MAX_VALIDATION_RETRY_ATTEMPTS as usize,
161 }
162 }
163}
164
165impl fmt::Display for CommitRetryStrategy {
166 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167 write!(f, "{}", self.description())
168 }
169}
170
171pub struct CommitMessageResult {
173 pub message: String,
175 pub success: bool,
177 pub _log_path: String,
179 pub generated_prompts: std::collections::HashMap<String, String>,
182}
183
184fn truncate_diff_if_large(diff: &str, max_size: usize) -> String {
197 if diff.len() <= max_size {
198 return diff.to_string();
199 }
200
201 let mut files: Vec<DiffFile> = Vec::new();
203 let mut current_file = DiffFile::default();
204 let mut in_file = false;
205
206 for line in diff.lines() {
207 if line.starts_with("diff --git ") {
208 if in_file && !current_file.lines.is_empty() {
210 files.push(std::mem::take(&mut current_file));
211 }
212 in_file = true;
213 current_file.lines.push(line.to_string());
214
215 if let Some(path) = line.split(" b/").nth(1) {
217 current_file.path = path.to_string();
218 current_file.priority = prioritize_file_path(path);
219 }
220 } else if in_file {
221 current_file.lines.push(line.to_string());
222 }
223 }
224
225 if in_file && !current_file.lines.is_empty() {
227 files.push(current_file);
228 }
229
230 let total_files = files.len();
231
232 files.sort_by_key(|f| std::cmp::Reverse(f.priority));
234
235 let mut selected_files = Vec::new();
237 let mut current_size = 0;
238
239 for file in files {
240 let file_size: usize = file.lines.iter().map(|l| l.len() + 1).sum(); if current_size + file_size <= max_size {
243 current_size += file_size;
244 selected_files.push(file);
245 } else if current_size > 0 {
246 break;
249 } else {
250 let truncated_lines = truncate_lines_to_fit(&file.lines, max_size);
253 selected_files.push(DiffFile {
254 path: file.path,
255 priority: file.priority,
256 lines: truncated_lines,
257 });
258 break;
259 }
260 }
261
262 let selected_count = selected_files.len();
263 let omitted_count = total_files.saturating_sub(selected_count);
264
265 let mut result = String::new();
267
268 if omitted_count > 0 {
270 use std::fmt::Write;
271 let _ = write!(
272 result,
273 "[Diff truncated: Showing first {selected_count} of {total_files} files. {omitted_count} files omitted due to size constraints.]\n\n"
274 );
275 }
276
277 for file in selected_files {
278 for line in &file.lines {
279 result.push_str(line);
280 result.push('\n');
281 }
282 }
283
284 result
285}
286
287#[derive(Debug, Default, Clone)]
289struct DiffFile {
290 path: String,
292 priority: i32,
294 lines: Vec<String>,
296}
297
298mod file_priority {
302 pub const SRC_RUST: i32 = 100;
304 pub const SRC_OTHER: i32 = 80;
306 pub const CONFIG: i32 = 60;
308 pub const DEFAULT: i32 = 50;
310 pub const TESTS: i32 = 40;
312 pub const DOCS: i32 = 20;
314}
315
316fn prioritize_file_path(path: &str) -> i32 {
326 use std::path::Path;
327 let path_lower = path.to_lowercase();
328
329 let has_ext = |ext: &str| -> bool {
331 Path::new(path)
332 .extension()
333 .and_then(std::ffi::OsStr::to_str)
334 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
335 };
336
337 let has_ext_lower = |ext: &str| -> bool {
339 Path::new(&path_lower)
340 .extension()
341 .and_then(std::ffi::OsStr::to_str)
342 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
343 };
344
345 if path_lower.contains("src/") && has_ext_lower("rs") {
347 file_priority::SRC_RUST
348 } else if path_lower.contains("src/") {
349 file_priority::SRC_OTHER
350 }
351 else if path_lower.contains("test") {
353 file_priority::TESTS
354 }
355 else if has_ext("toml")
357 || has_ext("json")
358 || path_lower.ends_with("cargo.toml")
359 || path_lower.ends_with("package.json")
360 || path_lower.ends_with("tsconfig.json")
361 {
362 file_priority::CONFIG
363 }
364 else if path_lower.contains("doc") || has_ext("md") {
366 file_priority::DOCS
367 }
368 else {
370 file_priority::DEFAULT
371 }
372}
373
374fn truncate_lines_to_fit(lines: &[String], max_size: usize) -> Vec<String> {
379 let mut result = Vec::new();
380 let mut current_size = 0;
381
382 for line in lines {
383 let line_size = line.len() + 1; if current_size + line_size <= max_size {
385 current_size += line_size;
386 result.push(line.clone());
387 } else {
388 break;
389 }
390 }
391
392 if let Some(last) = result.last_mut() {
394 last.push_str(" [truncated...]");
395 }
396
397 result
398}
399
400fn check_and_pre_truncate_diff(
404 diff: &str,
405 commit_agent: &str,
406 runtime: &PipelineRuntime,
407) -> (String, bool) {
408 let max_size = max_prompt_size_for_agent(commit_agent);
409 if diff.len() > max_size {
410 runtime.logger.warn(&format!(
411 "Diff size ({} KB) exceeds agent limit ({} KB). Pre-truncating to avoid token errors.",
412 diff.len() / 1024,
413 max_size / 1024
414 ));
415 (truncate_diff_if_large(diff, max_size), true)
416 } else {
417 runtime.logger.info(&format!(
418 "Diff size ({} KB) is within safe limit ({} KB).",
419 diff.len() / 1024,
420 max_size / 1024
421 ));
422 (diff.to_string(), false)
423 }
424}
425
426fn generate_prompt_for_strategy(
434 strategy: CommitRetryStrategy,
435 working_diff: &str,
436 template_context: &crate::prompts::TemplateContext,
437 workspace: &dyn crate::workspace::Workspace,
438 xsd_error: Option<&str>,
439 prompt_history: &HashMap<String, String>,
440 prompt_key: &str,
441) -> (String, bool) {
442 let full_prompt_key = if xsd_error.is_some() {
445 format!("{}_xsd_retry", prompt_key)
446 } else {
447 prompt_key.to_string()
448 };
449
450 let (prompt, was_replayed) =
451 get_stored_or_generate_prompt(&full_prompt_key, prompt_history, || match strategy {
452 CommitRetryStrategy::Normal => {
453 if let Some(error_msg) = xsd_error {
454 prompt_xsd_retry_with_context(
456 template_context,
457 working_diff,
458 error_msg,
459 workspace,
460 )
461 } else {
462 prompt_generate_commit_message_with_diff_with_context(
464 template_context,
465 working_diff,
466 workspace,
467 )
468 }
469 }
470 CommitRetryStrategy::Simplified => {
471 if let Some(error_msg) = xsd_error {
472 prompt_xsd_retry_with_context(
474 template_context,
475 working_diff,
476 error_msg,
477 workspace,
478 )
479 } else {
480 prompt_simplified_commit_with_context(template_context, working_diff)
482 }
483 }
484 });
485
486 (prompt, was_replayed)
487}
488
489fn log_commit_attempt(
491 strategy: CommitRetryStrategy,
492 prompt_size_kb: usize,
493 commit_agent: &str,
494 runtime: &PipelineRuntime,
495) {
496 if strategy == CommitRetryStrategy::Normal {
497 runtime.logger.info(&format!(
498 "Attempt 1/{}: Using {} (prompt size: {} KB, agent: {})",
499 CommitRetryStrategy::total_stages(),
500 strategy,
501 prompt_size_kb,
502 commit_agent
503 ));
504 } else {
505 runtime.logger.warn(&format!(
506 "Attempt {}/{}: Re-prompting with {} (prompt size: {} KB, agent: {})...",
507 strategy as usize + 1,
508 CommitRetryStrategy::total_stages(),
509 strategy,
510 prompt_size_kb,
511 commit_agent
512 ));
513 }
514}
515
516fn handle_commit_extraction_result(
525 extraction_result: anyhow::Result<Option<CommitExtractionResult>>,
526 strategy: CommitRetryStrategy,
527 log_dir: &str,
528 runtime: &PipelineRuntime,
529 last_extraction: &mut Option<CommitExtractionResult>,
530 attempt_log: &mut CommitAttemptLog,
531) -> Option<anyhow::Result<CommitMessageResult>> {
532 let log_file = format!("{log_dir}/final.log");
533
534 match extraction_result {
535 Ok(Some(extraction)) => {
536 runtime.logger.info(&format!(
538 "Successfully extracted commit message with {strategy}"
539 ));
540 let message = extraction.clone().into_message();
541 attempt_log.set_outcome(AttemptOutcome::Success(message.clone()));
542 *last_extraction = Some(extraction);
543 Some(Ok(CommitMessageResult {
545 message,
546 success: true,
547 _log_path: log_file,
548 generated_prompts: std::collections::HashMap::new(),
549 }))
550 }
551 Ok(None) => {
552 runtime.logger.warn(&format!(
553 "No valid commit message extracted with {strategy}, will try next strategy"
554 ));
555 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(
556 "No valid commit message extracted".to_string(),
557 ));
558 None }
560 Err(e) => {
561 runtime.logger.error(&format!(
562 "Failed to extract commit message with {strategy}: {e}"
563 ));
564 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(e.to_string()));
565 None }
567 }
568}
569
570fn build_agents_to_try<'a>(fallbacks: &'a [&'a str], primary_agent: &'a str) -> Vec<&'a str> {
575 let mut agents_to_try: Vec<&'a str> = vec![primary_agent];
576 for fb in fallbacks {
577 if *fb != primary_agent && !agents_to_try.contains(fb) {
578 agents_to_try.push(fb);
579 }
580 }
581 agents_to_try
582}
583
584struct CommitAttemptContext<'a> {
586 working_diff: &'a str,
588 log_dir: &'a str,
590 diff_was_truncated: bool,
592 template_context: &'a crate::prompts::TemplateContext,
594 workspace: &'a dyn crate::workspace::Workspace,
596 prompt_history: &'a HashMap<String, String>,
598 prompt_key: String,
600 generated_prompts: &'a mut std::collections::HashMap<String, String>,
603}
604
605fn run_commit_attempt_with_agent(
612 strategy: CommitRetryStrategy,
613 ctx: &mut CommitAttemptContext<'_>,
614 runtime: &mut PipelineRuntime,
615 registry: &AgentRegistry,
616 agent: &str,
617 last_extraction: &mut Option<CommitExtractionResult>,
618 session: &mut CommitLogSession,
619) -> Option<anyhow::Result<CommitMessageResult>> {
620 let Some(agent_config) = registry.resolve_config(agent) else {
622 runtime
623 .logger
624 .warn(&format!("Agent '{agent}' not found in registry, skipping"));
625 let mut attempt_log = session.new_attempt(agent, strategy.description());
626 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(format!(
627 "Agent '{agent}' not found in registry"
628 )));
629 let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
630 return None;
631 };
632
633 let cmd_str = agent_config.build_cmd(true, true, false);
635 let logfile = format!("{}/{}_latest.log", ctx.log_dir, agent.replace('/', "-"));
636
637 let max_retries = strategy.max_session_retries();
639 let mut xsd_error: Option<String> = None;
640
641 for retry_num in 0..max_retries {
642 if retry_num > 0 {
644 use crate::files::io::check_and_cleanup_xml_before_retry_with_workspace;
645 use std::path::Path;
646 let xml_path =
647 Path::new(crate::files::llm_output_extraction::xml_paths::COMMIT_MESSAGE_XML);
648 let _ = check_and_cleanup_xml_before_retry_with_workspace(
649 ctx.workspace,
650 xml_path,
651 runtime.logger,
652 );
653 }
654
655 let prompt_key = format!("{}_{}", ctx.prompt_key, strategy.stage_number());
659 let (prompt, was_replayed) = generate_prompt_for_strategy(
660 strategy,
661 ctx.working_diff,
662 ctx.template_context,
663 ctx.workspace,
664 xsd_error.as_deref(),
665 ctx.prompt_history,
666 &prompt_key,
667 );
668 let prompt_size_kb = prompt.len() / 1024;
669
670 if was_replayed && retry_num == 0 {
672 runtime.logger.info(&format!(
673 "Using stored prompt from checkpoint for determinism: {}",
674 prompt_key
675 ));
676 } else if !was_replayed {
677 ctx.generated_prompts
679 .insert(prompt_key.clone(), prompt.clone());
680 }
681
682 let mut attempt_log = session.new_attempt(agent, strategy.description());
684 attempt_log.set_prompt_size(prompt.len());
685 attempt_log.set_diff_info(ctx.working_diff.len(), ctx.diff_was_truncated);
686
687 if retry_num > 0 {
689 runtime.logger.info(&format!(
690 " In-session retry {}/{} for XSD validation",
691 retry_num,
692 max_retries - 1
693 ));
694 if let Some(ref error) = xsd_error {
695 runtime.logger.info(&format!(" XSD error: {}", error));
696 }
697 } else {
698 log_commit_attempt(strategy, prompt_size_kb, agent, runtime);
699 }
700
701 let exit_code = match crate::pipeline::run_with_prompt(
703 &crate::pipeline::PromptCommand {
704 label: &format!("generate commit message ({})", strategy.description()),
705 display_name: agent,
706 cmd_str: &cmd_str,
707 prompt: &prompt,
708 logfile: &logfile,
709 parser_type: agent_config.json_parser,
710 env_vars: &agent_config.env_vars,
711 },
712 runtime,
713 ) {
714 Ok(result) => result.exit_code,
715 Err(e) => {
716 runtime.logger.error(&format!("Failed to run agent: {e}"));
717 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(format!(
718 "Agent execution failed: {e}"
719 )));
720 let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
721 return None;
722 }
723 };
724
725 if exit_code != 0 {
726 runtime
727 .logger
728 .warn("Commit agent failed, checking logs for partial output...");
729 }
730
731 let extraction_result = extract_commit_message_from_logs_with_trace(
732 ctx.log_dir,
733 ctx.working_diff,
734 agent,
735 runtime.logger,
736 &mut attempt_log,
737 ctx.workspace,
738 );
739
740 match &extraction_result {
742 Ok(Some(_)) => {
743 let result = handle_commit_extraction_result(
745 extraction_result,
746 strategy,
747 ctx.log_dir,
748 runtime,
749 last_extraction,
750 &mut attempt_log,
751 );
752
753 if let Err(e) = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace) {
754 runtime
755 .logger
756 .warn(&format!("Failed to write attempt log: {e}"));
757 }
758
759 return result;
760 }
761 _ => {
762 }
764 };
765
766 let xsd_error_msg = attempt_log
768 .extraction_attempts
769 .iter()
770 .find(|attempt| attempt.detail.contains("XSD validation failed"))
771 .map(|attempt| attempt.detail.clone());
772
773 if let Some(ref error_msg) = xsd_error_msg {
774 runtime
775 .logger
776 .warn(&format!(" XSD validation failed: {}", error_msg));
777
778 if retry_num < max_retries - 1 {
779 let error = error_msg
781 .strip_prefix("XSD validation failed: ")
782 .unwrap_or(error_msg);
783
784 xsd_error = Some(error.to_string());
786
787 attempt_log.set_outcome(AttemptOutcome::XsdValidationFailed(error.to_string()));
789 let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
790
791 continue;
793 } else {
794 runtime
796 .logger
797 .warn(" No more in-session retries remaining");
798 }
799 }
800
801 let result = handle_commit_extraction_result(
803 extraction_result,
804 strategy,
805 ctx.log_dir,
806 runtime,
807 last_extraction,
808 &mut attempt_log,
809 );
810
811 if let Err(e) = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace) {
813 runtime
814 .logger
815 .warn(&format!("Failed to write attempt log: {e}"));
816 }
817
818 if result.is_some() {
820 return result;
821 }
822
823 if retry_num >= max_retries - 1 {
825 break;
826 }
827
828 break;
830 }
831
832 None
833}
834
835fn return_hardcoded_fallback(
837 log_file: &str,
838 runtime: &PipelineRuntime,
839 generated_prompts: std::collections::HashMap<String, String>,
840) -> CommitMessageResult {
841 runtime.logger.warn("");
842 runtime.logger.warn("All recovery methods failed:");
843 runtime.logger.warn(" - All 9 prompt variants exhausted");
844 runtime
845 .logger
846 .warn(" - All agents in fallback chain exhausted");
847 runtime.logger.warn(" - All truncation stages failed");
848 runtime.logger.warn(" - Emergency prompts failed");
849 runtime.logger.warn("");
850 runtime
851 .logger
852 .warn("Using hardcoded fallback commit message as last resort.");
853 runtime.logger.warn(&format!(
854 "Fallback message: \"{HARDCODED_FALLBACK_COMMIT}\""
855 ));
856 runtime.logger.warn("");
857
858 CommitMessageResult {
859 message: HARDCODED_FALLBACK_COMMIT.to_string(),
860 success: true,
861 _log_path: log_file.to_string(),
862 generated_prompts,
863 }
864}
865
866pub fn generate_commit_message(
909 diff: &str,
910 registry: &AgentRegistry,
911 runtime: &mut PipelineRuntime,
912 commit_agent: &str,
913 template_context: &crate::prompts::TemplateContext,
914 workspace: &dyn crate::workspace::Workspace,
915 prompt_history: &HashMap<String, String>,
916) -> anyhow::Result<CommitMessageResult> {
917 let log_dir = ".agent/logs/commit_generation";
918 let log_file = format!("{log_dir}/final.log");
919
920 workspace.create_dir_all(Path::new(log_dir))?;
921
922 if diff.trim().is_empty() {
924 runtime
925 .logger
926 .warn("Empty diff provided to generate_commit_message, using fallback");
927 return Ok(CommitMessageResult {
928 message: HARDCODED_FALLBACK_COMMIT.to_string(),
929 success: true,
930 _log_path: log_file,
931 generated_prompts: std::collections::HashMap::new(),
932 });
933 }
934
935 runtime.logger.info("Generating commit message...");
936
937 let mut session = create_commit_log_session(log_dir, runtime, workspace);
939 let (working_diff, diff_was_pre_truncated) =
940 check_and_pre_truncate_diff(diff, commit_agent, runtime);
941
942 let fallbacks = registry.available_fallbacks(AgentRole::Commit);
943 let agents_to_try = build_agents_to_try(&fallbacks, commit_agent);
944
945 let mut last_extraction: Option<CommitExtractionResult> = None;
946 let mut total_attempts = 0;
947
948 let prompt_key = format!(
951 "commit_{}",
952 std::time::SystemTime::now()
953 .duration_since(std::time::UNIX_EPOCH)
954 .unwrap_or_default()
955 .as_secs()
956 );
957
958 let mut generated_prompts = std::collections::HashMap::new();
960
961 let mut attempt_ctx = CommitAttemptContext {
962 working_diff: &working_diff,
963 log_dir,
964 diff_was_truncated: diff_was_pre_truncated,
965 template_context,
966 workspace,
967 prompt_history,
968 prompt_key,
969 generated_prompts: &mut generated_prompts,
970 };
971
972 if let Some(result) = try_agents_with_strategies(
974 &agents_to_try,
975 &mut attempt_ctx,
976 runtime,
977 registry,
978 &mut last_extraction,
979 &mut session,
980 &mut total_attempts,
981 ) {
982 log_completion(runtime, &session, total_attempts, &result, workspace);
983 return result.map(|mut r| {
985 r.generated_prompts = generated_prompts;
986 r
987 });
988 }
989
990 let fallback_ctx = CommitFallbackContext {
992 log_file: &log_file,
993 };
994 handle_commit_fallbacks(
995 &fallback_ctx,
996 runtime,
997 &session,
998 total_attempts,
999 last_extraction.as_ref(),
1000 generated_prompts,
1001 workspace,
1002 )
1003}
1004
1005fn create_commit_log_session(
1007 log_dir: &str,
1008 runtime: &mut PipelineRuntime,
1009 workspace: &dyn crate::workspace::Workspace,
1010) -> CommitLogSession {
1011 match CommitLogSession::new(log_dir, workspace) {
1012 Ok(s) => {
1013 runtime.logger.info(&format!(
1014 "Commit logs will be written to: {}",
1015 s.run_dir().display()
1016 ));
1017 s
1018 }
1019 Err(e) => {
1020 runtime
1021 .logger
1022 .warn(&format!("Failed to create log session: {e}"));
1023 CommitLogSession::new(log_dir, workspace).unwrap_or_else(|_| {
1025 match CommitLogSession::new("/tmp/ralph-commit-logs", workspace) {
1026 Ok(s) => {
1027 runtime.logger.warn(&format!(
1028 "Using fallback log directory: {}",
1029 s.run_dir().display()
1030 ));
1031 s
1032 }
1033 Err(e2) => {
1034 runtime.logger.warn(&format!(
1035 "All log directories failed (original: {e}, fallback: {e2}). Using no-op session."
1036 ));
1037 CommitLogSession::noop()
1038 }
1039 }
1040 })
1041 }
1042 }
1043}
1044
1045fn try_agents_with_strategies(
1055 agents: &[&str],
1056 ctx: &mut CommitAttemptContext<'_>,
1057 runtime: &mut PipelineRuntime,
1058 registry: &AgentRegistry,
1059 last_extraction: &mut Option<CommitExtractionResult>,
1060 session: &mut CommitLogSession,
1061 total_attempts: &mut usize,
1062) -> Option<anyhow::Result<CommitMessageResult>> {
1063 let mut strategy = CommitRetryStrategy::Normal;
1064 loop {
1065 runtime.logger.info(&format!(
1066 "Trying strategy {}/{}: {}",
1067 strategy.stage_number(),
1068 CommitRetryStrategy::total_stages(),
1069 strategy.description()
1070 ));
1071
1072 for (agent_idx, agent) in agents.iter().enumerate() {
1073 runtime.logger.info(&format!(
1074 " - Agent {}/{}: {agent}",
1075 agent_idx + 1,
1076 agents.len()
1077 ));
1078
1079 *total_attempts += 1;
1080 if let Some(result) = run_commit_attempt_with_agent(
1081 strategy,
1082 ctx,
1083 runtime,
1084 registry,
1085 agent,
1086 last_extraction,
1087 session,
1088 ) {
1089 return Some(result);
1090 }
1091 }
1092
1093 runtime.logger.warn(&format!(
1094 "All agents failed for strategy: {}",
1095 strategy.description()
1096 ));
1097
1098 match strategy.next() {
1099 Some(next) => strategy = next,
1100 None => break,
1101 }
1102 }
1103 None
1104}
1105
1106fn log_completion(
1108 runtime: &mut PipelineRuntime,
1109 session: &CommitLogSession,
1110 total_attempts: usize,
1111 result: &anyhow::Result<CommitMessageResult>,
1112 workspace: &dyn crate::workspace::Workspace,
1113) {
1114 if let Ok(ref commit_result) = result {
1115 let _ = session.write_summary(
1116 total_attempts,
1117 &format!(
1118 "SUCCESS: {}",
1119 preview_commit_message(&commit_result.message)
1120 ),
1121 workspace,
1122 );
1123 }
1124 runtime.logger.info(&format!(
1125 "Commit generation complete after {total_attempts} attempts. Logs: {}",
1126 session.run_dir().display()
1127 ));
1128}
1129
1130struct CommitFallbackContext<'a> {
1132 log_file: &'a str,
1133}
1134
1135fn handle_commit_fallbacks(
1141 ctx: &CommitFallbackContext<'_>,
1142 runtime: &mut PipelineRuntime,
1143 session: &CommitLogSession,
1144 total_attempts: usize,
1145 last_extraction: Option<&CommitExtractionResult>,
1146 generated_prompts: std::collections::HashMap<String, String>,
1147 workspace: &dyn crate::workspace::Workspace,
1148) -> anyhow::Result<CommitMessageResult> {
1149 if let Some(extraction) = last_extraction {
1152 let message = extraction.clone().into_message();
1153 let _ = session.write_summary(
1154 total_attempts,
1155 &format!("LAST_EXTRACTION: {}", preview_commit_message(&message)),
1156 workspace,
1157 );
1158 runtime.logger.info(&format!(
1159 "Commit generation complete after {total_attempts} attempts. Logs: {}",
1160 session.run_dir().display()
1161 ));
1162 return Ok(CommitMessageResult {
1163 message,
1164 success: true,
1165 _log_path: ctx.log_file.to_string(),
1166 generated_prompts,
1167 });
1168 }
1169
1170 let _ = session.write_summary(
1172 total_attempts,
1173 &format!("HARDCODED_FALLBACK: {HARDCODED_FALLBACK_COMMIT}"),
1174 workspace,
1175 );
1176 runtime.logger.info(&format!(
1177 "Commit generation complete after {total_attempts} attempts (hardcoded fallback). Logs: {}",
1178 session.run_dir().display()
1179 ));
1180 Ok(return_hardcoded_fallback(
1181 ctx.log_file,
1182 runtime,
1183 generated_prompts,
1184 ))
1185}
1186
1187pub fn commit_with_generated_message(
1204 diff: &str,
1205 commit_agent: &str,
1206 git_user_name: Option<&str>,
1207 git_user_email: Option<&str>,
1208 ctx: &mut PhaseContext<'_>,
1209) -> CommitResultFallback {
1210 let staged = match git_add_all() {
1212 Ok(s) => s,
1213 Err(e) => {
1214 return CommitResultFallback::Failed(format!("Failed to stage changes: {e}"));
1215 }
1216 };
1217
1218 if !staged {
1219 return CommitResultFallback::NoChanges;
1220 }
1221
1222 let start_time = std::time::Instant::now();
1224
1225 let mut runtime = PipelineRuntime {
1227 timer: ctx.timer,
1228 logger: ctx.logger,
1229 colors: ctx.colors,
1230 config: ctx.config,
1231 executor: ctx.executor,
1232 executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
1233 workspace: ctx.workspace,
1234 };
1235
1236 let result = match generate_commit_message(
1238 diff,
1239 ctx.registry,
1240 &mut runtime,
1241 commit_agent,
1242 ctx.template_context,
1243 ctx.workspace,
1244 &ctx.prompt_history,
1245 ) {
1246 Ok(r) => r,
1247 Err(e) => {
1248 ctx.execution_history.add_step(
1250 ExecutionStep::new(
1251 "commit",
1252 0,
1253 "commit_generation",
1254 StepOutcome::failure(format!("Failed to generate commit message: {e}"), false),
1255 )
1256 .with_agent(commit_agent)
1257 .with_duration(start_time.elapsed().as_secs()),
1258 );
1259 return CommitResultFallback::Failed(format!("Failed to generate commit message: {e}"));
1260 }
1261 };
1262
1263 for (key, prompt) in result.generated_prompts {
1265 ctx.capture_prompt(&key, &prompt);
1266 }
1267
1268 if !result.success || result.message.trim().is_empty() {
1270 ctx.logger
1272 .warn("Commit generation returned empty message, using hardcoded fallback...");
1273 let fallback_message = HARDCODED_FALLBACK_COMMIT.to_string();
1274 let commit_result = match git_commit(
1275 &fallback_message,
1276 git_user_name,
1277 git_user_email,
1278 Some(ctx.executor),
1279 ) {
1280 Ok(Some(oid)) => CommitResultFallback::Success(oid),
1281 Ok(None) => CommitResultFallback::NoChanges,
1282 Err(e) => CommitResultFallback::Failed(format!("Failed to create commit: {e}")),
1283 };
1284 let outcome = match &commit_result {
1286 CommitResultFallback::Success(oid) => StepOutcome::success(
1287 Some(format!("Commit created: {oid}")),
1288 vec![".".to_string()],
1289 ),
1290 CommitResultFallback::NoChanges => {
1291 StepOutcome::skipped("No changes to commit".to_string())
1292 }
1293 CommitResultFallback::Failed(e) => StepOutcome::failure(e.clone(), false),
1294 };
1295 ctx.execution_history.add_step(
1296 ExecutionStep::new("commit", 0, "commit_generation", outcome)
1297 .with_agent(commit_agent)
1298 .with_duration(start_time.elapsed().as_secs()),
1299 );
1300 commit_result
1301 } else {
1302 let commit_result = match git_commit(
1304 &result.message,
1305 git_user_name,
1306 git_user_email,
1307 Some(ctx.executor),
1308 ) {
1309 Ok(Some(oid)) => CommitResultFallback::Success(oid),
1310 Ok(None) => CommitResultFallback::NoChanges,
1311 Err(e) => CommitResultFallback::Failed(format!("Failed to create commit: {e}")),
1312 };
1313 let outcome = match &commit_result {
1315 CommitResultFallback::Success(oid) => StepOutcome::success(
1316 Some(format!("Commit created: {oid}")),
1317 vec![".".to_string()],
1318 ),
1319 CommitResultFallback::NoChanges => {
1320 StepOutcome::skipped("No changes to commit".to_string())
1321 }
1322 CommitResultFallback::Failed(e) => StepOutcome::failure(e.clone(), false),
1323 };
1324 let oid_for_history = match &commit_result {
1325 CommitResultFallback::Success(oid) => Some(oid.to_string()),
1326 _ => None,
1327 };
1328 let mut step = ExecutionStep::new("commit", 0, "commit_generation", outcome)
1329 .with_agent(commit_agent)
1330 .with_duration(start_time.elapsed().as_secs());
1331 if let Some(oid) = &oid_for_history {
1332 step = step.with_git_commit_oid(oid);
1333 }
1334 ctx.execution_history.add_step(step);
1335 commit_result
1336 }
1337}
1338
1339use crate::phases::commit_logging::{ParsingTraceLog, ParsingTraceStep};
1341
1342fn write_parsing_trace_with_logging(
1344 parsing_trace: &ParsingTraceLog,
1345 log_dir: &str,
1346 logger: &Logger,
1347 workspace: &dyn crate::workspace::Workspace,
1348) {
1349 if let Err(e) = parsing_trace.write_to_workspace(Path::new(log_dir), workspace) {
1350 logger.warn(&format!("Failed to write parsing trace log: {e}"));
1351 }
1352}
1353
1354fn try_xml_extraction_traced(
1357 content: &str,
1358 step_number: &mut usize,
1359 parsing_trace: &mut ParsingTraceLog,
1360 logger: &Logger,
1361 attempt_log: &mut CommitAttemptLog,
1362 log_dir: &str,
1363 workspace: &dyn crate::workspace::Workspace,
1364) -> Option<CommitExtractionResult> {
1365 let xml_file_path = Path::new(xml_paths::COMMIT_MESSAGE_XML);
1367 let (xml_result, xml_detail) =
1368 if let Some(file_xml) = try_extract_from_file_with_workspace(workspace, xml_file_path) {
1369 let (validated, detail) = try_extract_xml_commit_with_trace(&file_xml);
1371 let detail = format!("file-based: {}", detail);
1372 (validated, detail)
1373 } else {
1374 try_extract_xml_commit_with_trace(content)
1376 };
1377 logger.info(&format!(" ✓ XML extraction: {xml_detail}"));
1378
1379 parsing_trace.add_step(
1380 ParsingTraceStep::new(*step_number, "XML Extraction")
1381 .with_input(&content[..content.len().min(1000)])
1382 .with_result(xml_result.as_deref().unwrap_or("[No XML found]"))
1383 .with_success(xml_result.is_some())
1384 .with_details(&xml_detail),
1385 );
1386 *step_number += 1;
1387
1388 if let Some(message) = xml_result {
1389 archive_xml_file_with_workspace(workspace, xml_file_path);
1392
1393 attempt_log.add_extraction_attempt(ExtractionAttempt::success("XML", xml_detail));
1394 parsing_trace.set_final_message(&message);
1395 write_parsing_trace_with_logging(parsing_trace, log_dir, logger, workspace);
1396 return Some(CommitExtractionResult::new(message));
1397 }
1398
1399 attempt_log.add_extraction_attempt(ExtractionAttempt::failure("XML", xml_detail));
1401 logger.info(" ✗ XML extraction failed");
1402 None
1403}
1404
1405fn extract_commit_message_from_logs_with_trace(
1413 log_dir: &str,
1414 _diff: &str,
1415 _agent_cmd: &str,
1416 logger: &Logger,
1417 attempt_log: &mut CommitAttemptLog,
1418 workspace: &dyn crate::workspace::Workspace,
1419) -> anyhow::Result<Option<CommitExtractionResult>> {
1420 let mut parsing_trace = ParsingTraceLog::new(
1422 attempt_log.attempt_number,
1423 &attempt_log.agent,
1424 &attempt_log.strategy,
1425 );
1426
1427 let Some(content) = read_log_content_with_trace(log_dir, logger, attempt_log, workspace)?
1429 else {
1430 return Ok(None);
1431 };
1432
1433 parsing_trace.set_raw_output(&content);
1435
1436 let mut step_number = 1;
1437
1438 if let Some(result) = try_xml_extraction_traced(
1442 &content,
1443 &mut step_number,
1444 &mut parsing_trace,
1445 logger,
1446 attempt_log,
1447 log_dir,
1448 workspace,
1449 ) {
1450 return Ok(Some(result));
1451 }
1452
1453 parsing_trace.add_step(
1455 ParsingTraceStep::new(step_number, "XML Extraction Failed")
1456 .with_input(&content[..content.len().min(1000)])
1457 .with_success(false)
1458 .with_details("No valid XML found or XSD validation failed"),
1459 );
1460
1461 write_parsing_trace_with_logging(&parsing_trace, log_dir, logger, workspace);
1462
1463 Ok(None)
1467}
1468
1469fn read_log_content_with_trace(
1471 log_dir: &str,
1472 logger: &Logger,
1473 attempt_log: &mut CommitAttemptLog,
1474 workspace: &dyn crate::workspace::Workspace,
1475) -> anyhow::Result<Option<String>> {
1476 let log_dir_path = Path::new(log_dir);
1477 let log_path = find_most_recent_log_in_workspace(log_dir_path, workspace)?;
1478 let Some(log_file) = log_path else {
1479 logger.warn("No log files found in commit generation directory");
1480 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1481 "File",
1482 "No log files found".to_string(),
1483 ));
1484 return Ok(None);
1485 };
1486
1487 logger.info(&format!(
1488 "Reading commit message from log: {}",
1489 log_file.display()
1490 ));
1491
1492 let content = workspace.read(&log_file)?;
1493 attempt_log.set_raw_output(&content);
1494
1495 if content.trim().is_empty() {
1496 logger.warn("Log file is empty");
1497 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1498 "File",
1499 "Log file is empty".to_string(),
1500 ));
1501 return Ok(None);
1502 }
1503
1504 Ok(Some(preprocess_raw_content(&content)))
1506}
1507
1508fn find_most_recent_log_in_workspace(
1524 log_dir: &Path,
1525 workspace: &dyn crate::workspace::Workspace,
1526) -> anyhow::Result<Option<std::path::PathBuf>> {
1527 if !workspace.exists(log_dir) {
1533 return Ok(None);
1534 }
1535
1536 let entries = match workspace.read_dir(log_dir) {
1537 Ok(e) => e,
1538 Err(_) => return Ok(None),
1539 };
1540
1541 let mut most_recent: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
1542
1543 for entry in entries {
1544 if !entry.is_file() {
1545 continue;
1546 }
1547
1548 if let Some(filename) = entry.file_name().and_then(|s| s.to_str()) {
1550 if !filename.ends_with(".log") {
1551 continue;
1552 }
1553 } else {
1554 continue;
1555 }
1556
1557 if let Some(modified) = entry.modified() {
1558 match &most_recent {
1559 None => {
1560 most_recent = Some((entry.path().to_path_buf(), modified));
1561 }
1562 Some((_, prev_modified)) if modified > *prev_modified => {
1563 most_recent = Some((entry.path().to_path_buf(), modified));
1564 }
1565 _ => {}
1566 }
1567 } else {
1568 if most_recent.is_none() {
1570 most_recent = Some((
1571 entry.path().to_path_buf(),
1572 std::time::SystemTime::UNIX_EPOCH,
1573 ));
1574 }
1575 }
1576 }
1577
1578 Ok(most_recent.map(|(path, _)| path))
1579}
1580
1581#[cfg(test)]
1582mod tests {
1583 use super::*;
1584 use crate::workspace::MemoryWorkspace;
1585 use std::path::Path;
1586 use std::time::{Duration, SystemTime};
1587
1588 fn read_log_content_with_workspace(
1593 log_dir: &Path,
1594 workspace: &dyn crate::workspace::Workspace,
1595 ) -> anyhow::Result<Option<String>> {
1596 let log_path = find_most_recent_log_in_workspace(log_dir, workspace)?;
1597 let Some(log_file) = log_path else {
1598 return Ok(None);
1599 };
1600
1601 let content = workspace.read(&log_file)?;
1602
1603 if content.trim().is_empty() {
1604 return Ok(None);
1605 }
1606
1607 Ok(Some(content))
1608 }
1609
1610 #[test]
1611 fn test_find_most_recent_log_uses_workspace() {
1612 let time1 = SystemTime::UNIX_EPOCH + Duration::from_secs(1000);
1614 let time2 = SystemTime::UNIX_EPOCH + Duration::from_secs(2000);
1615
1616 let workspace = MemoryWorkspace::new_test()
1617 .with_file_at_time(
1618 ".agent/logs/commit_generation/old_agent_0.log",
1619 "old content",
1620 time1,
1621 )
1622 .with_file_at_time(
1623 ".agent/logs/commit_generation/new_agent_0.log",
1624 "new content with XML",
1625 time2,
1626 );
1627
1628 let log_dir = Path::new(".agent/logs/commit_generation");
1630 let result = find_most_recent_log_in_workspace(log_dir, &workspace);
1631 assert!(result.is_ok());
1632 let log_path = result.unwrap();
1633 assert!(log_path.is_some());
1634 let path = log_path.unwrap();
1636 assert!(path.to_string_lossy().contains("new_agent"));
1637 }
1638
1639 #[test]
1640 fn test_find_most_recent_log_empty_workspace() {
1641 let workspace = MemoryWorkspace::new_test();
1642
1643 let log_dir = Path::new(".agent/logs/commit_generation");
1644 let result = find_most_recent_log_in_workspace(log_dir, &workspace);
1645 assert!(result.is_ok());
1646 assert!(result.unwrap().is_none());
1647 }
1648
1649 #[test]
1650 fn test_read_log_content_uses_workspace() {
1651 let workspace = MemoryWorkspace::new_test().with_file(
1653 ".agent/logs/commit_generation/test_agent_0.log",
1654 "test log content for extraction",
1655 );
1656
1657 let log_dir = Path::new(".agent/logs/commit_generation");
1658
1659 let result = read_log_content_with_workspace(log_dir, &workspace);
1661 assert!(result.is_ok());
1662 let content = result.unwrap();
1663 assert!(content.is_some());
1664 assert_eq!(content.unwrap(), "test log content for extraction");
1665 }
1666
1667 #[test]
1668 fn test_read_log_content_empty_log() {
1669 let workspace = MemoryWorkspace::new_test()
1670 .with_file(".agent/logs/commit_generation/empty_agent_0.log", "");
1671
1672 let log_dir = Path::new(".agent/logs/commit_generation");
1673 let result = read_log_content_with_workspace(log_dir, &workspace);
1674 assert!(result.is_ok());
1675 assert!(result.unwrap().is_none());
1677 }
1678
1679 #[test]
1680 fn test_truncate_diff_if_large() {
1681 let large_diff = "a".repeat(100_000);
1682 let truncated = truncate_diff_if_large(&large_diff, 10_000);
1683
1684 assert!(truncated.len() < large_diff.len());
1686 }
1687
1688 #[test]
1689 fn test_truncate_preserves_small_diffs() {
1690 let small_diff = "a".repeat(100);
1691 let truncated = truncate_diff_if_large(&small_diff, 10_000);
1692
1693 assert_eq!(truncated, small_diff);
1695 }
1696
1697 #[test]
1698 fn test_truncate_exactly_at_limit() {
1699 let diff = "a".repeat(10_000);
1700 let truncated = truncate_diff_if_large(&diff, 10_000);
1701
1702 assert_eq!(truncated, diff);
1704 }
1705
1706 #[test]
1707 fn test_truncate_preserves_file_boundaries() {
1708 let diff = "diff --git a/file1.rs b/file1.rs\n\
1709 +line1\n\
1710 +line2\n\
1711 diff --git a/file2.rs b/file2.rs\n\
1712 +line3\n\
1713 +line4\n";
1714 let large_diff = format!("{}{}", diff, "x".repeat(100_000));
1715 let truncated = truncate_diff_if_large(&large_diff, 50);
1716
1717 assert!(truncated.contains("diff --git"));
1719 assert!(truncated.contains("Diff truncated"));
1721 }
1722
1723 #[test]
1724 fn test_prioritize_file_path() {
1725 assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("tests/test.rs"));
1727 assert!(prioritize_file_path("src/lib.rs") > prioritize_file_path("README.md"));
1728
1729 assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("test/test.rs"));
1731
1732 assert!(prioritize_file_path("Cargo.toml") > prioritize_file_path("docs/guide.md"));
1734
1735 assert!(prioritize_file_path("README.md") < prioritize_file_path("src/main.rs"));
1737 }
1738
1739 #[test]
1740 fn test_truncate_keeps_high_priority_files() {
1741 let diff = "diff --git a/README.md b/README.md\n\
1742 +doc change\n\
1743 diff --git a/src/main.rs b/src/main.rs\n\
1744 +important change\n\
1745 diff --git a/tests/test.rs b/tests/test.rs\n\
1746 +test change\n";
1747
1748 let truncated = truncate_diff_if_large(diff, 80);
1750
1751 assert!(truncated.contains("src/main.rs"));
1753 }
1754
1755 #[test]
1756 fn test_truncate_lines_to_fit() {
1757 let lines = vec![
1758 "line1".to_string(),
1759 "line2".to_string(),
1760 "line3".to_string(),
1761 "line4".to_string(),
1762 ];
1763
1764 let truncated = truncate_lines_to_fit(&lines, 18);
1766
1767 assert_eq!(truncated.len(), 3);
1768 assert!(truncated[2].ends_with("[truncated...]"));
1770 }
1771
1772 #[test]
1773 fn test_hardcoded_fallback_commit() {
1774 use crate::files::llm_output_extraction::is_conventional_commit_subject;
1776 assert!(
1777 is_conventional_commit_subject(HARDCODED_FALLBACK_COMMIT),
1778 "Hardcoded fallback must be a valid conventional commit"
1779 );
1780 assert!(!HARDCODED_FALLBACK_COMMIT.is_empty());
1781 assert!(HARDCODED_FALLBACK_COMMIT.len() >= 5);
1782 }
1783}