Skip to main content

ralph_workflow/phases/
commit.rs

1//! Commit message generation phase.
2//!
3//! This module handles automated commit message generation using the standard
4//! agent pipeline with fallback support. It replaces the custom implementation
5//! in repo.rs that lacked proper logging and fallback handling.
6//!
7//! The phase:
8//! 1. Takes a git diff as input
9//! 2. Runs the commit agent with the diff via the standard pipeline
10//! 3. Extracts the commit message from agent output
11//! 4. Returns the generated message for use by the caller
12
13use 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
35/// Preview a commit message for display (first line, truncated if needed).
36///
37/// Uses character-based truncation to avoid panics on UTF-8 multi-byte characters.
38fn preview_commit_message(msg: &str) -> String {
39    let first_line = msg.lines().next().unwrap_or(msg);
40    // truncate_text handles the ellipsis, so we use 63 to get ~60 chars + "..."
41    truncate_text(first_line, 63)
42}
43
44/// Maximum safe prompt size in bytes before pre-truncation.
45///
46/// This is a conservative limit to prevent agents from failing with "prompt too long"
47/// errors. Different agents have different token limits:
48/// - GLM: ~100KB effective limit
49/// - Claude CCS: ~300KB effective limit
50/// - Others: vary by model
51///
52/// We use 200KB as a safe middle ground that works for most agents while still
53/// allowing substantial diffs to be processed without truncation.
54const MAX_SAFE_PROMPT_SIZE: usize = 200_000;
55
56/// Maximum prompt size for GLM-like agents (GLM, Zhipu, Qwen, DeepSeek).
57const GLM_MAX_PROMPT_SIZE: usize = 100_000;
58
59/// Maximum prompt size for Claude-based agents.
60const CLAUDE_MAX_PROMPT_SIZE: usize = 300_000;
61
62/// Absolute last resort fallback commit message.
63///
64/// This is used ONLY when all other methods fail:
65/// - All 8 prompt variants exhausted
66/// - All agents in fallback chain exhausted
67/// - All truncation stages failed
68/// - Emergency no-diff prompt failed
69/// - Deterministic fallback from diff failed
70///
71/// This ensures the commit process NEVER fails completely.
72pub(crate) const HARDCODED_FALLBACK_COMMIT: &str = "chore: automated commit";
73
74/// Get the maximum safe prompt size for a specific agent.
75///
76/// Different agents have different token limits. This function returns a
77/// conservative max size for the given agent to prevent "prompt too long" errors.
78///
79/// # Arguments
80///
81/// * `commit_agent` - The commit agent command string
82///
83/// # Returns
84///
85/// Maximum safe prompt size in bytes
86fn max_prompt_size_for_agent(commit_agent: &str) -> usize {
87    let agent_lower = commit_agent.to_lowercase();
88
89    // GLM and similar agents have smaller effective limits
90    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/// Retry strategy for commit message generation.
108///
109/// Tracks which stage of re-prompting we're in, allowing for progressive
110/// degradation from detailed prompts to minimal ones before falling back
111/// to the next agent in the chain.
112///
113/// With XSD validation, we now have two strategies. Each strategy supports
114/// up to 5 in-session retries with validation feedback.
115///
116/// The XSD retry mechanism is used internally for in-session retries when
117/// validation fails, not as a separate stage.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119enum CommitRetryStrategy {
120    /// First attempt with normal XML prompt
121    Normal,
122    /// Simplified XML prompt - more direct instructions
123    Simplified,
124}
125
126impl CommitRetryStrategy {
127    /// Get the description of this retry stage for logging
128    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    /// Get the next retry strategy, or None if this is the last stage
136    const fn next(self) -> Option<Self> {
137        match self {
138            Self::Normal => Some(Self::Simplified),
139            Self::Simplified => None,
140        }
141    }
142
143    /// Get the 1-based stage number for this strategy
144    const fn stage_number(self) -> usize {
145        match self {
146            Self::Normal => 1,
147            Self::Simplified => 2,
148        }
149    }
150
151    /// Get the total number of retry stages
152    const fn total_stages() -> usize {
153        2 // Normal + Simplified
154    }
155
156    /// Get the maximum number of in-session retries for this strategy
157    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
171/// Result of commit message generation.
172pub struct CommitMessageResult {
173    /// The generated commit message (may be empty on failure)
174    pub message: String,
175    /// Whether the generation was successful
176    pub success: bool,
177    /// Path to the agent log file for debugging (currently unused but kept for API compatibility)
178    pub _log_path: String,
179    /// Prompts that were generated during this commit generation (key -> prompt)
180    /// This is used for capturing prompts in checkpoints for deterministic resume
181    pub generated_prompts: std::collections::HashMap<String, String>,
182}
183
184/// Truncate diff if it's too large for agents with small context windows.
185///
186/// This is a defensive measure when agents report "prompt too long" errors.
187/// Returns a truncated diff with a summary of omitted content.
188///
189/// # Semantic Awareness
190///
191/// The improved truncation:
192/// 1. Preserves file structure - truncates at file boundaries (after `diff --git` blocks)
193/// 2. Prioritizes important files - keeps files from `src/` over `tests/`, `.md` files, etc.
194/// 3. Preserves last N files - shows what changed at the end
195/// 4. Adds a summary header - includes "First M files shown, N files truncated"
196fn truncate_diff_if_large(diff: &str, max_size: usize) -> String {
197    if diff.len() <= max_size {
198        return diff.to_string();
199    }
200
201    // Parse the diff into individual file blocks
202    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            // Save previous file if any
209            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            // Extract and prioritize the file path
216            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    // Don't forget the last file
226    if in_file && !current_file.lines.is_empty() {
227        files.push(current_file);
228    }
229
230    let total_files = files.len();
231
232    // Sort files by priority (highest first) to keep important files
233    files.sort_by_key(|f| std::cmp::Reverse(f.priority));
234
235    // Greedily select files that fit within max_size
236    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(); // +1 for newline
241
242        if current_size + file_size <= max_size {
243            current_size += file_size;
244            selected_files.push(file);
245        } else if current_size > 0 {
246            // We have at least one file and this one would exceed the limit
247            // Stop adding more files
248            break;
249        } else {
250            // Even the first (highest priority) file is too large
251            // Take at least the first part of it
252            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    // Build the truncated diff
266    let mut result = String::new();
267
268    // Add summary header at the top
269    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/// Represents a single file's diff chunk.
288#[derive(Debug, Default, Clone)]
289struct DiffFile {
290    /// File path (extracted from diff header)
291    path: String,
292    /// Priority for selection (higher = more important)
293    priority: i32,
294    /// Lines in this file's diff
295    lines: Vec<String>,
296}
297
298/// File prioritization weights for diff truncation.
299///
300/// Higher priority files are kept first when truncating large diffs.
301mod file_priority {
302    /// Rust source in src/ (highest priority).
303    pub const SRC_RUST: i32 = 100;
304    /// Other src/ files.
305    pub const SRC_OTHER: i32 = 80;
306    /// Config files (Cargo.toml, package.json).
307    pub const CONFIG: i32 = 60;
308    /// Default priority for unknown files.
309    pub const DEFAULT: i32 = 50;
310    /// Test files.
311    pub const TESTS: i32 = 40;
312    /// Documentation (lowest priority).
313    pub const DOCS: i32 = 20;
314}
315
316/// Assign a priority score to a file path for truncation selection.
317///
318/// Higher priority files are kept first when truncating:
319/// - src/*.rs: +100 (source code is most important)
320/// - src/*: +80 (other src files)
321/// - tests/*: +40 (tests are important but secondary)
322/// - Cargo.toml, package.json, etc.: +60 (config files)
323/// - docs/*, *.md: +20 (docs are least important)
324/// - Other: +50 (default)
325fn prioritize_file_path(path: &str) -> i32 {
326    use std::path::Path;
327    let path_lower = path.to_lowercase();
328
329    // Helper function for case-insensitive extension check
330    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    // Helper function for case-insensitive file extension check on path_lower
338    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    // Source code files (highest priority)
346    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    // Test files
352    else if path_lower.contains("test") {
353        file_priority::TESTS
354    }
355    // Config files - use case-insensitive extension check
356    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    // Documentation files (lowest priority)
365    else if path_lower.contains("doc") || has_ext("md") {
366        file_priority::DOCS
367    }
368    // Default priority
369    else {
370        file_priority::DEFAULT
371    }
372}
373
374/// Truncate a slice of lines to fit within a maximum size.
375///
376/// This is a fallback for when even a single file is too large.
377/// Returns as many complete lines as will fit.
378fn 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; // +1 for newline
384        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    // Add truncation marker to the last line
393    if let Some(last) = result.last_mut() {
394        last.push_str(" [truncated...]");
395    }
396
397    result
398}
399
400/// Check and pre-truncate diff if it exceeds agent's token limits.
401///
402/// Returns the (possibly truncated) diff and whether truncation occurred.
403fn 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
426/// Generate the appropriate prompt for the current retry strategy.
427///
428/// For XSD retry, the xsd_error parameter is used to provide feedback to the agent.
429/// Note: XSD retry is handled internally within the session, not as a separate stage.
430///
431/// For hardened resume, this function uses stored prompts from checkpoint when available
432/// to ensure deterministic behavior on resume.
433fn 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    // Use stored_or_generate pattern for hardened resume
443    // The key identifies which prompt variant this is (strategy + retry state)
444    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                    // In-session XSD retry with error feedback
455                    prompt_xsd_retry_with_context(
456                        template_context,
457                        working_diff,
458                        error_msg,
459                        workspace,
460                    )
461                } else {
462                    // First attempt with normal XML prompt
463                    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                    // In-session XSD retry with error feedback
473                    prompt_xsd_retry_with_context(
474                        template_context,
475                        working_diff,
476                        error_msg,
477                        workspace,
478                    )
479                } else {
480                    // Simplified XML prompt
481                    prompt_simplified_commit_with_context(template_context, working_diff)
482                }
483            }
484        });
485
486    (prompt, was_replayed)
487}
488
489/// Log the current attempt with prompt size information.
490fn 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
516/// Handle the extraction result from a commit attempt.
517///
518/// Returns `Some(result)` if we should return early (success),
519/// or `None` if we should continue to the next strategy.
520///
521/// With XSD validation handling everything, we only check:
522/// - Success: Valid commit message extracted
523/// - Failure: No valid message (try next strategy)
524fn 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            // XSD validation already passed - we have a valid commit message
537            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            // Note: generated_prompts is collected in the context and returned later
544            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 // Continue to next strategy
559        }
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 // Continue to next strategy
566        }
567    }
568}
569
570/// Build the list of agents to try for commit generation.
571///
572/// This helper function constructs the ordered list of agents to try,
573/// starting with the primary agent and followed by configured fallbacks.
574fn 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
584/// Context for a commit attempt, bundling related state to avoid too many arguments.
585struct CommitAttemptContext<'a> {
586    /// The diff being processed
587    working_diff: &'a str,
588    /// Log directory path
589    log_dir: &'a str,
590    /// Whether the diff was pre-truncated
591    diff_was_truncated: bool,
592    /// Template context for user template overrides
593    template_context: &'a crate::prompts::TemplateContext,
594    /// Workspace for file operations (trait object for DI)
595    workspace: &'a dyn crate::workspace::Workspace,
596    /// Prompt history for checkpoint/resume determinism
597    prompt_history: &'a HashMap<String, String>,
598    /// Unique key for this commit generation attempt
599    prompt_key: String,
600    /// Output map to capture prompts that were newly generated (not replayed)
601    /// This is used for checkpoint/resume determinism
602    generated_prompts: &'a mut std::collections::HashMap<String, String>,
603}
604
605/// Run a single commit attempt with the given strategy and agent.
606///
607/// This function runs a single agent (not using fallback) to allow for
608/// per-agent prompt variant cycling with in-session XSD validation retry.
609/// Returns Some(result) if we should return early (success or hard error),
610/// or None if we should continue to the next strategy.
611fn 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    // Get the agent config
621    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    // Build the command for this agent
634    let cmd_str = agent_config.build_cmd(true, true, false);
635    let logfile = format!("{}/{}_latest.log", ctx.log_dir, agent.replace('/', "-"));
636
637    // In-session retry loop with XSD validation feedback
638    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        // Before each retry, check if the XML file is writable and clean up if locked
643        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        // For initial attempt, xsd_error is None
656        // For retries, we use the XSD error to guide the agent
657        // Build prompt key for this attempt (strategy-specific)
658        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        // Log if using stored prompt for determinism
671        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            // Capture the newly generated prompt for checkpoint/resume
678            ctx.generated_prompts
679                .insert(prompt_key.clone(), prompt.clone());
680        }
681
682        // Create attempt log
683        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        // Log retry attempt if not first attempt
688        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        // Run the agent directly (without fallback)
702        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        // Check if we got a valid commit message or need to retry for XSD errors
741        match &extraction_result {
742            Ok(Some(_)) => {
743                // XSD validation passed - we have a valid commit message
744                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                // Extraction failed - continue to check for XSD errors for retry
763            }
764        };
765
766        // Check extraction attempts for XSD validation errors
767        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                // Extract just the error message (after "XSD validation failed: ")
780                let error = error_msg
781                    .strip_prefix("XSD validation failed: ")
782                    .unwrap_or(error_msg);
783
784                // Store error for next retry attempt
785                xsd_error = Some(error.to_string());
786
787                // Write attempt log but don't return yet
788                attempt_log.set_outcome(AttemptOutcome::XsdValidationFailed(error.to_string()));
789                let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
790
791                // Continue to next retry iteration
792                continue;
793            } else {
794                // No more retries - fall through to handle as extraction failure
795                runtime
796                    .logger
797                    .warn("  No more in-session retries remaining");
798            }
799        }
800
801        // Handle extraction result (failure cases)
802        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        // Write the attempt log
812        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 we got a result (success or hard error), return it
819        if result.is_some() {
820            return result;
821        }
822
823        // Otherwise, if this was a retry and we exhausted retries, break out
824        if retry_num >= max_retries - 1 {
825            break;
826        }
827
828        // For non-XSD errors, we don't retry in-session - move to next strategy
829        break;
830    }
831
832    None
833}
834
835/// Return the hardcoded fallback commit message as last resort.
836fn 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
866/// Generate a commit message using the standard agent pipeline with fallback.
867///
868/// This function uses the standard agent pipeline with fallback, which provides:
869/// - Proper stdout/stderr logging
870/// - Configurable fallback chains
871/// - Retry logic with exponential backoff
872/// - Agent error classification
873///
874/// Multi-stage retry logic:
875/// 1. Try initial prompt
876/// 2. On fallback/empty result, try strict JSON prompt
877/// 3. On failure, try V2 strict prompt (with negative examples)
878/// 4. On failure, try ultra-minimal prompt
879/// 5. On failure, try emergency prompt
880/// 6. Only use hardcoded fallback after all prompt variants exhausted
881///
882/// # Agent Cycling Behavior
883///
884/// This function implements proper strategy-first cycling by trying each strategy
885/// with all agents before moving to the next strategy:
886/// - Strategy 1 (initial): Agent 1 → Agent 2 → Agent 3
887/// - Strategy 2 (strict JSON): Agent 1 → Agent 2 → Agent 3
888/// - Strategy 3 (strict JSON V2): Agent 1 → Agent 2 → Agent 3
889/// - etc.
890///
891/// This approach is more efficient because if a particular strategy works well
892/// with any agent, we succeed quickly rather than exhausting all strategies
893/// on the first agent before trying others.
894///
895/// # Arguments
896///
897/// * `diff` - The git diff to generate a commit message for
898/// * `registry` - The agent registry for resolving agents and fallbacks
899/// * `runtime` - The pipeline runtime for execution services
900/// * `commit_agent` - The primary agent to use for commit generation
901/// * `template_context` - Template context for user template overrides
902/// * `workspace` - Workspace filesystem for file operations
903/// * `prompt_history` - Prompt history for checkpoint/resume determinism
904///
905/// # Returns
906///
907/// Returns `Ok(CommitMessageResult)` with the generated message and metadata.
908pub 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    // Check if diff is empty before proceeding
923    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    // Create a logging session for this commit generation run
938    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    // Generate a unique prompt key for this commit generation attempt
949    // Use timestamp-based key to ensure uniqueness across different commit generations
950    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    // Map to capture newly generated prompts for checkpoint/resume
959    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    // Try each agent with all prompt variants
973    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        // Include generated prompts in the result
984        return result.map(|mut r| {
985            r.generated_prompts = generated_prompts;
986            r
987        });
988    }
989
990    // Handle fallback cases
991    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
1005/// Create a commit log session, with fallback.
1006fn 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            // Try the original directory again, then fallback to /tmp, then noop
1024            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
1045/// Try all agents with their strategy variants.
1046///
1047/// This function implements strategy-first cycling:
1048/// - Outer loop: Iterate through strategies
1049/// - Inner loop: Try all agents with the current strategy
1050/// - Only advance to next strategy if ALL agents failed with current strategy
1051///
1052/// This ensures each strategy gets the best chance to succeed with all
1053/// available agents before we try degraded fallback prompts.
1054fn 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
1106/// Log completion info and write session summary on success.
1107fn 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
1130/// Context for commit fallback handling.
1131struct CommitFallbackContext<'a> {
1132    log_file: &'a str,
1133}
1134
1135/// Handle fallback cases after all agents exhausted.
1136///
1137/// With XSD validation handling everything, the fallback logic is simple:
1138/// - If we have a last extraction with a valid message, use it
1139/// - Otherwise, use the hardcoded fallback
1140fn 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    // Use message from last extraction if available
1150    // (XSD validation already passed if we have an extraction)
1151    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    // Hardcoded fallback as last resort
1171    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
1187/// Create a commit with an automatically generated message using the standard pipeline.
1188///
1189/// This is a replacement for `commit_with_auto_message_fallback_result` in `git_helpers`
1190/// that uses the standard agent pipeline with proper logging and fallback support.
1191///
1192/// # Arguments
1193///
1194/// * `diff` - The git diff to generate a commit message from
1195/// * `commit_agent` - The primary agent to use for commit generation
1196/// * `git_user_name` - Optional git user name
1197/// * `git_user_email` - Optional git user email
1198/// * `ctx` - The phase context containing registry, logger, colors, config, and timer
1199///
1200/// # Returns
1201///
1202/// Returns `CommitResultFallback` indicating success, no changes, or failure.
1203pub 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    // Stage all changes first
1211    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    // Track execution start for commit generation
1223    let start_time = std::time::Instant::now();
1224
1225    // Set up the runtime
1226    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    // Generate commit message using the standard pipeline
1237    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            // Record failed generation in execution history
1249            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    // Capture generated prompts for checkpoint/resume
1264    for (key, prompt) in result.generated_prompts {
1265        ctx.capture_prompt(&key, &prompt);
1266    }
1267
1268    // Check if generation succeeded
1269    if !result.success || result.message.trim().is_empty() {
1270        // This should never happen after our fixes, but add defensive fallback
1271        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        // Record completion with fallback in execution history
1285        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        // Create the commit with the generated message
1303        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        // Record completion in execution history
1314        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
1339/// Import types needed for parsing trace helpers.
1340use crate::phases::commit_logging::{ParsingTraceLog, ParsingTraceStep};
1341
1342/// Write parsing trace log to file with error handling.
1343fn 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
1354/// Try XML extraction and record in parsing trace.
1355/// Returns `Some(result)` if extraction succeeded (XSD validation passed), `None` otherwise.
1356fn 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    // Try file-based extraction first - allows agents to write XML to .agent/tmp/commit_message.xml
1366    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            // Found XML in file - validate it
1370            let (validated, detail) = try_extract_xml_commit_with_trace(&file_xml);
1371            let detail = format!("file-based: {}", detail);
1372            (validated, detail)
1373        } else {
1374            // Fall back to log content extraction
1375            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        // XSD validation already passed inside try_extract_xml_commit_with_trace
1390        // Archive the XML file now that it's been successfully processed
1391        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    // XML extraction or XSD validation failed - file stays in place for agent to edit
1400    attempt_log.add_extraction_attempt(ExtractionAttempt::failure("XML", xml_detail));
1401    logger.info("  ✗ XML extraction failed");
1402    None
1403}
1404
1405/// Extract a commit message from agent logs with full tracing for diagnostics.
1406///
1407/// Records all extraction attempts in the provided `CommitAttemptLog` for debugging.
1408///
1409/// This function also creates and writes a `ParsingTraceLog` that captures
1410/// detailed information about each extraction step, including the exact
1411/// content being processed and XSD validation results.
1412fn 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    // Create parsing trace log
1421    let mut parsing_trace = ParsingTraceLog::new(
1422        attempt_log.attempt_number,
1423        &attempt_log.agent,
1424        &attempt_log.strategy,
1425    );
1426
1427    // Read and preprocess log content using workspace abstraction
1428    let Some(content) = read_log_content_with_trace(log_dir, logger, attempt_log, workspace)?
1429    else {
1430        return Ok(None);
1431    };
1432
1433    // Set raw output in parsing trace
1434    parsing_trace.set_raw_output(&content);
1435
1436    let mut step_number = 1;
1437
1438    // XML-only extraction with XSD validation
1439    // The XML extraction includes flexible parsing with 4 strategies and XSD validation
1440    // If XSD validation fails, the error is returned for in-session retry
1441    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    // XML extraction failed - add final failure step to parsing trace
1454    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    // Return None to trigger next strategy/agent fallback
1464    // The in-session retry loop will have already attempted XSD validation retries
1465    // if the error was an XSD validation failure (detected in attempt_log)
1466    Ok(None)
1467}
1468
1469/// Read and preprocess log content for extraction.
1470fn 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    // Apply preprocessing
1505    Ok(Some(preprocess_raw_content(&content)))
1506}
1507
1508/// Find the most recent log file in a directory using workspace abstraction.
1509///
1510/// This function uses the workspace trait for filesystem operations, making it
1511/// testable with `MemoryWorkspace` and ensuring proper architecture conformance.
1512///
1513/// # Arguments
1514///
1515/// * `log_dir` - The directory to search for log files
1516/// * `workspace` - The workspace to use for filesystem operations
1517///
1518/// # Returns
1519///
1520/// * `Ok(Some(path))` - Path to the most recent log file
1521/// * `Ok(None)` - No log files found
1522/// * `Err(e)` - Error reading directory
1523fn find_most_recent_log_in_workspace(
1524    log_dir: &Path,
1525    workspace: &dyn crate::workspace::Workspace,
1526) -> anyhow::Result<Option<std::path::PathBuf>> {
1527    // Use the pipeline::logfile module's workspace-based implementation
1528    // That module already has the proper workspace-based find_most_recent_logfile function
1529    // We need to adapt from directory search to prefix-based search
1530
1531    // If directory doesn't exist in workspace, return None
1532    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        // Only look at .log files
1549        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            // No modification time available, use this if we have no best yet
1569            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    /// Read log content using workspace abstraction (test helper).
1589    ///
1590    /// This function finds the most recent log file in the given directory and reads
1591    /// its content using the workspace trait, making it testable with `MemoryWorkspace`.
1592    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        // Create workspace with log files at different modification times
1613        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        // Test that find_most_recent_log works with workspace
1629        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        // Should find the most recent log file
1635        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        // Create workspace with a log file
1652        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        // read_log_content_with_workspace should read from workspace, not std::fs
1660        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        // Empty log should return None
1676        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        // Should be truncated
1685        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        // Should not be modified
1694        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        // Should not be modified when at exact limit
1703        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        // Should preserve complete file blocks
1718        assert!(truncated.contains("diff --git"));
1719        // Should contain truncation summary
1720        assert!(truncated.contains("Diff truncated"));
1721    }
1722
1723    #[test]
1724    fn test_prioritize_file_path() {
1725        // Source files get highest priority
1726        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        // Tests get lower priority than src
1730        assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("test/test.rs"));
1731
1732        // Config files get medium priority
1733        assert!(prioritize_file_path("Cargo.toml") > prioritize_file_path("docs/guide.md"));
1734
1735        // Docs get lowest priority
1736        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        // With a very small limit, should keep src/main.rs first
1749        let truncated = truncate_diff_if_large(diff, 80);
1750
1751        // Should include the high priority src file
1752        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        // Only fit first 3 lines (each 5 chars + newline = 6)
1765        let truncated = truncate_lines_to_fit(&lines, 18);
1766
1767        assert_eq!(truncated.len(), 3);
1768        // Last line should have truncation marker
1769        assert!(truncated[2].ends_with("[truncated...]"));
1770    }
1771
1772    #[test]
1773    fn test_hardcoded_fallback_commit() {
1774        // The hardcoded fallback should always be a valid conventional commit
1775        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}