Skip to main content

ralph_workflow/phases/commit/runner/
attempt.rs

1// Legacy phase-based code - deprecated in favor of reducer/handler architecture
2/// Outcome of commit message generation.
3///
4/// This is intentionally an enum so callers must handle skip explicitly.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum CommitMessageOutcome {
7    /// A normal commit message ready to be written to `commit-message.txt`.
8    Message(String),
9    /// The agent determined there are no changes to commit.
10    Skipped { reason: String },
11}
12
13/// Result of commit message generation.
14#[derive(Debug)]
15pub struct CommitMessageResult {
16    pub outcome: CommitMessageOutcome,
17}
18
19/// Outcome from a single commit attempt.
20pub struct CommitAttemptResult {
21    pub had_error: bool,
22    pub output_valid: bool,
23    pub message: Option<String>,
24    pub skip_reason: Option<String>,
25    pub files: Vec<String>,
26    pub excluded_files: Vec<crate::reducer::state::pipeline::ExcludedFile>,
27    pub validation_detail: String,
28    pub auth_failure: bool,
29}
30
31/// Run a single commit generation attempt with explicit agent and prompt.
32///
33/// This does **not** perform in-session XSD retries. If validation fails, the
34/// caller should emit a `MessageValidationFailed` event and let the reducer decide
35/// retry/fallback behavior.
36///
37/// **IMPORTANT:** The `model_safe_diff` parameter must be pre-truncated to the
38/// effective model budget. Use the reducer's `MaterializeCommitInputs` effect
39/// to truncate the diff before calling this function. The reducer writes the
40/// model-safe diff to `.agent/tmp/commit_diff.model_safe.txt`.
41///
42/// # Panics
43///
44/// Panics if invariants are violated.
45///
46/// # Errors
47///
48/// Returns error if the operation fails.
49pub fn run_commit_attempt(
50    ctx: &mut PhaseContext<'_>,
51    attempt: u32,
52    model_safe_diff: &str,
53    commit_agent: &str,
54) -> anyhow::Result<CommitAttemptResult> {
55    // NOTE: Truncation is now handled by materialize_commit_inputs in the reducer.
56    // The diff passed here is already truncated to the effective model budget.
57    // See: reducer/handler/commit.rs::materialize_commit_inputs
58
59    let (prompt, substitution_log) =
60        build_commit_prompt(ctx.template_context, model_safe_diff, ctx.workspace);
61
62    // Legacy phase-based code
63    // Validate freshly rendered prompts using substitution logs (no regex scanning).
64    if !substitution_log.is_complete() {
65        return Err(anyhow::anyhow!(
66            "Commit prompt has unresolved placeholders: {:?}",
67            substitution_log.unsubstituted
68        ));
69    }
70
71    let mut runtime = PipelineRuntime {
72        timer: ctx.timer,
73        logger: ctx.logger,
74        colors: ctx.colors,
75        config: ctx.config,
76        executor: ctx.executor,
77        executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
78        workspace: ctx.workspace,
79        workspace_arc: std::sync::Arc::clone(&ctx.workspace_arc),
80    };
81
82    let log_dir = ctx
83        .run_log_context
84        .run_dir()
85        .join("debug")
86        .join("commit_generation");
87    let mut session = CommitLogSession::new(
88        log_dir
89            .to_str()
90            .expect("Path contains invalid UTF-8 - all paths in this codebase should be UTF-8"),
91        ctx.workspace,
92    )
93    .unwrap_or_else(|_| CommitLogSession::noop());
94    let mut attempt_log = session.new_attempt(commit_agent, "single");
95    attempt_log.set_prompt_size(prompt.len());
96    // The diff passed here is already model-safe. However, for accurate debugging we still want
97    // to record whether truncation happened upstream. We infer truncation from the marker text
98    // emitted by `truncate_diff_to_model_budget`.
99    let diff_was_truncated =
100        model_safe_diff.contains("[Truncated:") || model_safe_diff.contains("[truncated...]");
101    attempt_log.set_diff_info(model_safe_diff.len(), diff_was_truncated);
102
103    let agent_config = ctx
104        .registry
105        .resolve_config(commit_agent)
106        .ok_or_else(|| anyhow::anyhow!("Agent not found: {commit_agent}"))?;
107    let cmd_str = agent_config.build_cmd_with_model(true, true, true, None);
108
109    // Use per-run log directory with simplified naming
110    let base_log_path = ctx.run_log_context.agent_log("commit", attempt, None);
111    let log_attempt = crate::pipeline::logfile::next_simplified_logfile_attempt_index(
112        &base_log_path,
113        ctx.workspace,
114    );
115    let logfile = if log_attempt == 0 {
116        base_log_path
117            .to_str()
118            .expect("Path contains invalid UTF-8 - all paths in this codebase should be UTF-8")
119            .to_string()
120    } else {
121        ctx.run_log_context
122            .agent_log("commit", attempt, Some(log_attempt))
123            .to_str()
124            .expect("Path contains invalid UTF-8 - all paths in this codebase should be UTF-8")
125            .to_string()
126    };
127
128    // Write log file header with agent metadata
129    // Use append_bytes to avoid overwriting if file exists (defense-in-depth)
130    let log_header = format!(
131        "# Ralph Agent Invocation Log\n\
132         # Role: Commit\n\
133         # Agent: {}\n\
134         # Model Index: 0\n\
135         # Attempt: {}\n\
136         # Phase: CommitMessage\n\
137         # Timestamp: {}\n\n",
138        commit_agent,
139        log_attempt,
140        chrono::Utc::now().to_rfc3339()
141    );
142    ctx.workspace
143        .append_bytes(std::path::Path::new(&logfile), log_header.as_bytes())
144        .context("Failed to write agent log header - log would be incomplete without metadata")?;
145
146    let log_prefix = format!("commit_{attempt}"); // For attribution only
147    let model_index = 0usize; // Default model index for attribution
148    let prompt_cmd = PromptCommand {
149        label: commit_agent,
150        display_name: commit_agent,
151        cmd_str: &cmd_str,
152        prompt: &prompt,
153        log_prefix: &log_prefix,
154        model_index: Some(model_index),
155        attempt: Some(log_attempt),
156        logfile: &logfile,
157        parser_type: agent_config.json_parser,
158        env_vars: &agent_config.env_vars,
159    };
160
161    let result = run_with_prompt(&prompt_cmd, &mut runtime)?;
162    let had_error = result.exit_code != 0;
163    let auth_failure = had_error && stderr_contains_auth_error(&result.stderr);
164    attempt_log.set_raw_output(&result.stderr);
165
166    if auth_failure {
167        attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(
168            "Authentication error detected".to_string(),
169        ));
170        if !session.is_noop() {
171            let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
172            let _ = session.write_summary(1, "AUTHENTICATION_FAILURE", ctx.workspace);
173        }
174        return Ok(CommitAttemptResult {
175            had_error,
176            output_valid: false,
177            message: None,
178            skip_reason: None,
179            files: vec![],
180            excluded_files: vec![],
181            validation_detail: "Authentication error detected".to_string(),
182            auth_failure: true,
183        });
184    }
185
186    let extraction = extract_commit_message_from_file_with_workspace(ctx.workspace);
187    let (outcome, detail, extraction_result, extraction_succeeded, skip_reason, files, excluded) =
188        match extraction {
189            CommitExtractionOutcome::Valid {
190                extracted: result,
191                files,
192                excluded_files,
193            } => (
194                AttemptOutcome::Success(result.clone().into_message()),
195                "Valid commit message extracted".to_string(),
196                Some(result),
197                true,
198                None,
199                files,
200                excluded_files,
201            ),
202            CommitExtractionOutcome::InvalidXml(detail) => (
203                AttemptOutcome::XsdValidationFailed(detail.clone()),
204                detail,
205                None,
206                false,
207                None,
208                vec![],
209                vec![],
210            ),
211            CommitExtractionOutcome::MissingFile(detail) => (
212                AttemptOutcome::ExtractionFailed(detail.clone()),
213                detail,
214                None,
215                false,
216                None,
217                vec![],
218                vec![],
219            ),
220            CommitExtractionOutcome::Skipped(reason) => (
221                AttemptOutcome::Success(format!("SKIPPED: {reason}")),
222                format!("Commit skipped: {reason}"),
223                None,
224                true,
225                Some(reason),
226                vec![],
227                vec![],
228            ),
229        };
230    attempt_log.add_extraction_attempt(if extraction_succeeded {
231        ExtractionAttempt::success("XML", detail.clone())
232    } else {
233        ExtractionAttempt::failure("XML", detail.clone())
234    });
235    attempt_log.set_outcome(outcome.clone());
236
237    if !session.is_noop() {
238        let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
239        let final_outcome = format!("{outcome}");
240        let _ = session.write_summary(1, &final_outcome, ctx.workspace);
241    }
242
243    if let Some(result) = extraction_result {
244        let message = result.into_message();
245        return Ok(CommitAttemptResult {
246            had_error,
247            output_valid: true,
248            message: Some(message),
249            skip_reason: None,
250            files,
251            excluded_files: excluded,
252            validation_detail: detail,
253            auth_failure: false,
254        });
255    }
256
257    Ok(CommitAttemptResult {
258        had_error,
259        output_valid: extraction_succeeded,
260        message: None,
261        skip_reason,
262        files,
263        excluded_files: excluded,
264        validation_detail: detail,
265        auth_failure: false,
266    })
267}