Skip to main content

ralph_workflow/phases/commit/runner/
attempt.rs

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