Skip to main content

ralph_workflow/phases/commit/
runner.rs

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