Skip to main content

ralph_workflow/phases/commit/
runner.rs

1/// Result of commit message generation.
2#[derive(Debug)]
3pub struct CommitMessageResult {
4    /// The generated commit message
5    pub message: String,
6    /// Whether the generation was successful
7    pub success: bool,
8    /// Path to the agent log file for debugging (currently unused)
9    pub _log_path: String,
10    /// Prompts that were generated during this commit generation (key -> prompt)
11    pub generated_prompts: HashMap<String, String>,
12}
13
14/// Outcome from a single commit attempt.
15pub struct CommitAttemptResult {
16    pub had_error: bool,
17    pub output_valid: bool,
18    pub message: Option<String>,
19    pub validation_detail: String,
20    pub auth_failure: bool,
21}
22
23/// Run a single commit generation attempt with explicit agent and prompt.
24///
25/// This does **not** perform in-session XSD retries. If validation fails, the
26/// caller should emit a MessageValidationFailed event and let the reducer decide
27/// retry/fallback behavior.
28///
29/// **IMPORTANT:** The `model_safe_diff` parameter must be pre-truncated to the
30/// effective model budget. Use the reducer's `MaterializeCommitInputs` effect
31/// to truncate the diff before calling this function. The reducer writes the
32/// model-safe diff to `.agent/tmp/commit_diff.model_safe.txt`.
33pub fn run_commit_attempt(
34    ctx: &mut PhaseContext<'_>,
35    attempt: u32,
36    model_safe_diff: &str,
37    commit_agent: &str,
38) -> anyhow::Result<CommitAttemptResult> {
39    // NOTE: Truncation is now handled by materialize_commit_inputs in the reducer.
40    // The diff passed here is already truncated to the effective model budget.
41    // See: reducer/handler/commit.rs::materialize_commit_inputs
42
43    let prompt_key = format!("commit_message_attempt_{attempt}");
44    let (prompt, was_replayed) = build_commit_prompt(
45        &prompt_key,
46        ctx.template_context,
47        model_safe_diff,
48        ctx.workspace,
49        &ctx.prompt_history,
50    );
51
52    // Enforce that the rendered prompt does not contain unresolved template placeholders.
53    // This must happen before any agent invocation.
54    if let Err(err) = crate::prompts::validate_no_unresolved_placeholders_with_ignored_content(
55        &prompt,
56        &[model_safe_diff],
57    ) {
58        return Err(crate::prompts::TemplateVariablesInvalidError {
59            template_name: "commit_message_xml".to_string(),
60            missing_variables: Vec::new(),
61            unresolved_placeholders: err.unresolved_placeholders,
62        }
63        .into());
64    }
65
66    if !was_replayed {
67        ctx.capture_prompt(&prompt_key, &prompt);
68    }
69
70    let mut runtime = PipelineRuntime {
71        timer: ctx.timer,
72        logger: ctx.logger,
73        colors: ctx.colors,
74        config: ctx.config,
75        executor: ctx.executor,
76        executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
77        workspace: ctx.workspace,
78    };
79
80    let log_dir = Path::new(".agent/logs/commit_generation");
81    let mut session = CommitLogSession::new(
82        log_dir
83            .to_str()
84            .expect("Path contains invalid UTF-8 - all paths in this codebase should be UTF-8"),
85        ctx.workspace,
86    )
87    .unwrap_or_else(|_| CommitLogSession::noop());
88    let mut attempt_log = session.new_attempt(commit_agent, "single");
89    attempt_log.set_prompt_size(prompt.len());
90    // The diff passed here is already model-safe. However, for accurate debugging we still want
91    // to record whether truncation happened upstream. We infer truncation from the marker text
92    // emitted by `truncate_diff_to_model_budget`.
93    let diff_was_truncated =
94        model_safe_diff.contains("[Truncated:") || model_safe_diff.contains("[truncated...]");
95    attempt_log.set_diff_info(model_safe_diff.len(), diff_was_truncated);
96
97    let agent_config = ctx
98        .registry
99        .resolve_config(commit_agent)
100        .ok_or_else(|| anyhow::anyhow!("Agent not found: {}", commit_agent))?;
101    let cmd_str = agent_config.build_cmd_with_model(true, true, true, None);
102
103    // Use per-run log directory with simplified naming
104    let base_log_path = ctx.run_log_context.agent_log("commit", attempt, None);
105    let log_attempt = crate::pipeline::logfile::next_simplified_logfile_attempt_index(
106        &base_log_path,
107        ctx.workspace,
108    );
109    let logfile = if log_attempt == 0 {
110        base_log_path
111            .to_str()
112            .expect("Path contains invalid UTF-8 - all paths in this codebase should be UTF-8")
113            .to_string()
114    } else {
115        ctx.run_log_context
116            .agent_log("commit", attempt, Some(log_attempt))
117            .to_str()
118            .expect("Path contains invalid UTF-8 - all paths in this codebase should be UTF-8")
119            .to_string()
120    };
121
122    // Write log file header with agent metadata
123    // Use append_bytes to avoid overwriting if file exists (defense-in-depth)
124    let log_header = format!(
125        "# Ralph Agent Invocation Log\n\
126         # Role: Commit\n\
127         # Agent: {}\n\
128         # Model Index: 0\n\
129         # Attempt: {}\n\
130         # Phase: CommitMessage\n\
131         # Timestamp: {}\n\n",
132        commit_agent,
133        log_attempt,
134        chrono::Utc::now().to_rfc3339()
135    );
136    ctx.workspace
137        .append_bytes(std::path::Path::new(&logfile), log_header.as_bytes())
138        .context("Failed to write agent log header - log would be incomplete without metadata")?;
139
140    let log_prefix = format!("commit_{attempt}"); // For attribution only
141    let model_index = 0usize; // Default model index for attribution
142    let prompt_cmd = PromptCommand {
143        label: commit_agent,
144        display_name: commit_agent,
145        cmd_str: &cmd_str,
146        prompt: &prompt,
147        log_prefix: &log_prefix,
148        model_index: Some(model_index),
149        attempt: Some(log_attempt),
150        logfile: &logfile,
151        parser_type: agent_config.json_parser,
152        env_vars: &agent_config.env_vars,
153    };
154
155    let result = run_with_prompt(&prompt_cmd, &mut runtime)?;
156    let had_error = result.exit_code != 0;
157    let auth_failure = had_error && stderr_contains_auth_error(&result.stderr);
158    attempt_log.set_raw_output(&result.stderr);
159
160    if auth_failure {
161        attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(
162            "Authentication error detected".to_string(),
163        ));
164        if !session.is_noop() {
165            let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
166            let _ = session.write_summary(1, "AUTHENTICATION_FAILURE", ctx.workspace);
167        }
168        return Ok(CommitAttemptResult {
169            had_error,
170            output_valid: false,
171            message: None,
172            validation_detail: "Authentication error detected".to_string(),
173            auth_failure: true,
174        });
175    }
176
177    let extraction = extract_commit_message_from_file_with_workspace(ctx.workspace);
178    let (outcome, detail, extraction_result) = match extraction {
179        CommitExtractionOutcome::Valid(result) => (
180            AttemptOutcome::Success(result.clone().into_message()),
181            "Valid commit message extracted".to_string(),
182            Some(result),
183        ),
184        CommitExtractionOutcome::InvalidXml(detail) => (
185            AttemptOutcome::XsdValidationFailed(detail.clone()),
186            detail,
187            None,
188        ),
189        CommitExtractionOutcome::MissingFile(detail) => (
190            AttemptOutcome::ExtractionFailed(detail.clone()),
191            detail,
192            None,
193        ),
194    };
195    attempt_log.add_extraction_attempt(match &extraction_result {
196        Some(_) => ExtractionAttempt::success("XML", detail.clone()),
197        None => ExtractionAttempt::failure("XML", detail.clone()),
198    });
199    attempt_log.set_outcome(outcome.clone());
200
201    if !session.is_noop() {
202        let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
203        let final_outcome = format!("{outcome}");
204        let _ = session.write_summary(1, &final_outcome, ctx.workspace);
205    }
206
207    if let Some(result) = extraction_result {
208        let message = result.into_message();
209        return Ok(CommitAttemptResult {
210            had_error,
211            output_valid: true,
212            message: Some(message),
213            validation_detail: detail,
214            auth_failure: false,
215        });
216    }
217
218    Ok(CommitAttemptResult {
219        had_error,
220        output_valid: false,
221        message: None,
222        validation_detail: detail,
223        auth_failure: false,
224    })
225}
226
227/// Generate a commit message using a single agent attempt.
228///
229/// Returns an error if XML validation fails or the agent output is missing.
230///
231/// # Truncation Behavior (CLI vs Reducer)
232///
233/// **IMPORTANT:** This function uses **single-agent budget** for truncation, which
234/// differs from the reducer-driven path that uses **chain-minimum budget**.
235///
236/// | Path | Budget Calculation | When Used |
237/// |------|-------------------|-----------|
238/// | CLI (`--generate-commit-msg`) | `model_budget_bytes_for_agent_name(agent)` | Single agent, no fallback chain |
239/// | Reducer (`MaterializeCommitInputs`) | `effective_model_budget_bytes(&agents)` | Agent chain with potential fallbacks |
240///
241/// **Why this is acceptable:**
242/// - CLI plumbing commands (`--generate-commit-msg`) invoke a single, explicitly-specified
243///   agent with no fallback chain. There's no need to compute min budget across agents.
244/// - The reducer path handles multi-agent chains where the diff must fit the smallest
245///   agent's context window to ensure fallback attempts can succeed.
246///
247/// **Implication:** A diff that works via CLI might fail via reducer if the chain
248/// includes an agent with a smaller budget. This is by design - the CLI user
249/// explicitly chose the agent and accepts its budget constraints.
250pub fn generate_commit_message(
251    diff: &str,
252    registry: &AgentRegistry,
253    runtime: &mut PipelineRuntime,
254    commit_agent: &str,
255    template_context: &TemplateContext,
256    workspace: &dyn Workspace,
257    prompt_history: &HashMap<String, String>,
258) -> anyhow::Result<CommitMessageResult> {
259    // For CLI plumbing, we truncate to the single agent's budget.
260    // This is different from the reducer path which uses min budget across the chain.
261    let model_budget = model_budget_bytes_for_agent_name(commit_agent);
262    let (model_safe_diff, truncated) = truncate_diff_to_model_budget(diff, model_budget);
263    if truncated {
264        runtime.logger.warn(&format!(
265            "Diff size ({} KB) exceeds agent limit ({} KB). Truncated to {} KB.",
266            diff.len() / 1024,
267            model_budget / 1024,
268            model_safe_diff.len() / 1024
269        ));
270    }
271
272    let prompt_key = "commit_message_attempt_1";
273    let (prompt, was_replayed) = build_commit_prompt(
274        prompt_key,
275        template_context,
276        &model_safe_diff,
277        workspace,
278        prompt_history,
279    );
280
281    let mut generated_prompts = HashMap::new();
282    if !was_replayed {
283        generated_prompts.insert(prompt_key.to_string(), prompt.clone());
284    }
285
286    let agent_config = registry
287        .resolve_config(commit_agent)
288        .ok_or_else(|| anyhow::anyhow!("Agent not found: {}", commit_agent))?;
289    let cmd_str = agent_config.build_cmd_with_model(true, true, true, None);
290
291    let log_prefix = ".agent/logs/commit_generation/commit_generation";
292    let model_index = 0usize;
293    let attempt = 1u32;
294    let agent_for_log = commit_agent.to_lowercase();
295    let logfile = crate::pipeline::logfile::build_logfile_path_with_attempt(
296        log_prefix,
297        &agent_for_log,
298        model_index,
299        attempt,
300    );
301    let prompt_cmd = PromptCommand {
302        label: commit_agent,
303        display_name: commit_agent,
304        cmd_str: &cmd_str,
305        prompt: &prompt,
306        log_prefix,
307        model_index: Some(model_index),
308        attempt: Some(attempt),
309        logfile: &logfile,
310        parser_type: agent_config.json_parser,
311        env_vars: &agent_config.env_vars,
312    };
313
314    let result = run_with_prompt(&prompt_cmd, runtime)?;
315    let had_error = result.exit_code != 0;
316    let auth_failure = had_error && stderr_contains_auth_error(&result.stderr);
317    if auth_failure {
318        anyhow::bail!("Authentication error detected");
319    }
320
321    let extraction = extract_commit_message_from_file_with_workspace(workspace);
322    let result = match extraction {
323        CommitExtractionOutcome::Valid(result) => result,
324        CommitExtractionOutcome::InvalidXml(detail)
325        | CommitExtractionOutcome::MissingFile(detail) => anyhow::bail!(detail),
326    };
327
328    archive_xml_file_with_workspace(workspace, Path::new(xml_paths::COMMIT_MESSAGE_XML));
329
330    Ok(CommitMessageResult {
331        message: result.into_message(),
332        success: true,
333        _log_path: String::new(),
334        generated_prompts,
335    })
336}
337
338/// Generate a commit message with fallback chain support.
339///
340/// Tries each agent in the chain sequentially until one succeeds.
341/// Uses the minimum budget across all agents in the chain for truncation
342/// to ensure the diff fits all potential fallback agents.
343///
344/// # Arguments
345/// * `diff` - The diff to generate a commit message for
346/// * `registry` - Agent registry for resolving agent configs
347/// * `runtime` - Pipeline runtime for execution
348/// * `agents` - Chain of agents to try in order (first agent tried first)
349/// * `template_context` - Template context for prompt generation
350/// * `workspace` - Workspace for file operations
351/// * `prompt_history` - History of prompts for replay detection
352///
353/// # Returns
354/// * `Ok(CommitMessageResult)` - If any agent in the chain succeeds
355/// * `Err` - If all agents in the chain fail
356pub fn generate_commit_message_with_chain(
357    diff: &str,
358    registry: &AgentRegistry,
359    runtime: &mut PipelineRuntime,
360    agents: &[String],
361    template_context: &TemplateContext,
362    workspace: &dyn Workspace,
363    prompt_history: &HashMap<String, String>,
364) -> anyhow::Result<CommitMessageResult> {
365    if agents.is_empty() {
366        anyhow::bail!("No agents provided in commit chain");
367    }
368
369    // Use minimum budget across all agents in the chain
370    let model_budget = effective_model_budget_bytes(agents);
371    let (model_safe_diff, truncated) = truncate_diff_to_model_budget(diff, model_budget);
372    if truncated {
373        runtime.logger.warn(&format!(
374            "Diff size ({} KB) exceeds chain limit ({} KB). Truncated to {} KB.",
375            diff.len() / 1024,
376            model_budget / 1024,
377            model_safe_diff.len() / 1024
378        ));
379    }
380
381    let mut last_error: Option<anyhow::Error> = None;
382    let mut generated_prompts = HashMap::new();
383
384    for (agent_index, commit_agent) in agents.iter().enumerate() {
385        let prompt_key = format!("commit_message_chain_attempt_{}", agent_index + 1);
386        let (prompt, was_replayed) = build_commit_prompt(
387            &prompt_key,
388            template_context,
389            &model_safe_diff,
390            workspace,
391            prompt_history,
392        );
393
394        if !was_replayed {
395            generated_prompts.insert(prompt_key.clone(), prompt.clone());
396        }
397
398        let agent_config = match registry.resolve_config(commit_agent) {
399            Some(config) => config,
400            None => {
401                last_error = Some(anyhow::anyhow!("Agent not found: {}", commit_agent));
402                continue;
403            }
404        };
405        let cmd_str = agent_config.build_cmd_with_model(true, true, true, None);
406
407        let log_prefix = ".agent/logs/commit_generation/commit_generation";
408        let model_index = agent_index;
409        let attempt = 1u32;
410        let agent_for_log = commit_agent.to_lowercase();
411        let logfile = crate::pipeline::logfile::build_logfile_path_with_attempt(
412            log_prefix,
413            &agent_for_log,
414            model_index,
415            attempt,
416        );
417        let prompt_cmd = PromptCommand {
418            label: commit_agent,
419            display_name: commit_agent,
420            cmd_str: &cmd_str,
421            prompt: &prompt,
422            log_prefix,
423            model_index: Some(model_index),
424            attempt: Some(attempt),
425            logfile: &logfile,
426            parser_type: agent_config.json_parser,
427            env_vars: &agent_config.env_vars,
428        };
429
430        let result = match run_with_prompt(&prompt_cmd, runtime) {
431            Ok(r) => r,
432            Err(e) => {
433                last_error = Some(e.into());
434                continue;
435            }
436        };
437
438        let had_error = result.exit_code != 0;
439        let auth_failure = had_error && stderr_contains_auth_error(&result.stderr);
440
441        if auth_failure {
442            last_error = Some(anyhow::anyhow!("Authentication error detected"));
443            continue;
444        }
445
446        if had_error {
447            last_error = Some(anyhow::anyhow!(
448                "Agent {} failed with exit code {}",
449                commit_agent,
450                result.exit_code
451            ));
452            continue;
453        }
454
455        let extraction = extract_commit_message_from_file_with_workspace(workspace);
456        match extraction {
457            CommitExtractionOutcome::Valid(extracted) => {
458                archive_xml_file_with_workspace(
459                    workspace,
460                    Path::new(xml_paths::COMMIT_MESSAGE_XML),
461                );
462                return Ok(CommitMessageResult {
463                    message: extracted.into_message(),
464                    success: true,
465                    _log_path: String::new(),
466                    generated_prompts,
467                });
468            }
469            CommitExtractionOutcome::InvalidXml(detail)
470            | CommitExtractionOutcome::MissingFile(detail) => {
471                last_error = Some(anyhow::anyhow!(detail));
472                continue;
473            }
474        }
475    }
476
477    // All agents failed
478    Err(last_error.unwrap_or_else(|| anyhow::anyhow!("All agents in commit chain failed")))
479}