ralph_workflow/phases/
development.rs

1//! Development phase execution.
2//!
3//! This module handles the development phase of the Ralph pipeline, which consists
4//! of iterative planning and execution cycles. Each iteration:
5//! 1. Creates a PLAN.md from PROMPT.md
6//! 2. Executes the plan
7//! 3. Deletes PLAN.md
8//! 4. Optionally runs fast checks
9
10use crate::agents::AgentRole;
11use crate::checkpoint::restore::ResumeContext;
12use crate::checkpoint::{save_checkpoint, CheckpointBuilder, PipelinePhase};
13use crate::files::{delete_plan_file, update_status};
14use crate::files::{extract_plan, extract_plan_from_logs_text};
15use crate::git_helpers::{git_snapshot, CommitResultFallback};
16use crate::logger::print_progress;
17use crate::phases::commit::commit_with_generated_message;
18use crate::phases::get_primary_commit_agent;
19use crate::phases::integrity::ensure_prompt_integrity;
20use crate::pipeline::{run_with_fallback, PipelineRuntime};
21use crate::prompts::{
22    get_stored_or_generate_prompt, prompt_for_agent, Action, ContextLevel, PromptConfig, Role,
23};
24use std::fs;
25use std::path::Path;
26use std::process::Command;
27
28use super::context::PhaseContext;
29
30use crate::checkpoint::execution_history::{ExecutionStep, StepOutcome};
31
32use std::time::Instant;
33
34/// Result of the development phase.
35pub struct DevelopmentResult {
36    /// Whether any errors occurred during the phase.
37    pub had_errors: bool,
38}
39
40/// Run the development phase.
41///
42/// This phase runs `developer_iters` iterations, each consisting of:
43/// 1. Planning: Create PLAN.md from PROMPT.md
44/// 2. Execution: Execute the plan
45/// 3. Cleanup: Delete PLAN.md
46///
47/// # Arguments
48///
49/// * `ctx` - The phase context containing shared state
50/// * `start_iter` - The iteration to start from (for resume support)
51/// * `resume_context` - Optional resume context for resumed sessions
52///
53/// # Returns
54///
55/// Returns `Ok(DevelopmentResult)` on success, or an error if a critical failure occurs.
56pub fn run_development_phase(
57    ctx: &mut PhaseContext<'_>,
58    start_iter: u32,
59    resume_context: Option<&ResumeContext>,
60) -> anyhow::Result<DevelopmentResult> {
61    let mut had_errors = false;
62    let mut prev_snap = git_snapshot()?;
63    let developer_context = ContextLevel::from(ctx.config.developer_context);
64
65    for i in start_iter..=ctx.config.developer_iters {
66        ctx.logger.subheader(&format!(
67            "Iteration {} of {}",
68            i, ctx.config.developer_iters
69        ));
70        print_progress(i, ctx.config.developer_iters, "Overall");
71
72        let resuming_into_development = resume_context.is_some() && i == start_iter;
73
74        // Step 1: Create PLAN from PROMPT (skip if resuming into development)
75        if resuming_into_development {
76            ctx.logger
77                .info("Resuming at development step; skipping plan generation");
78        } else {
79            run_planning_step(ctx, i)?;
80        }
81
82        // Verify PLAN.md was created (required)
83        let plan_ok = verify_plan_exists(ctx, i, resuming_into_development)?;
84        if !plan_ok {
85            anyhow::bail!("Planning phase did not create a non-empty .agent/PLAN.md");
86        }
87        ctx.logger.success("PLAN.md created");
88
89        // Save checkpoint at start of development phase (if enabled)
90        if ctx.config.features.checkpoint_enabled {
91            let builder = CheckpointBuilder::new()
92                .phase(PipelinePhase::Development, i, ctx.config.developer_iters)
93                .reviewer_pass(0, ctx.config.reviewer_reviews)
94                .capture_from_context(
95                    ctx.config,
96                    ctx.registry,
97                    ctx.developer_agent,
98                    ctx.reviewer_agent,
99                    ctx.logger,
100                    &ctx.run_context,
101                )
102                .with_execution_history(ctx.execution_history.clone())
103                .with_prompt_history(ctx.clone_prompt_history());
104
105            if let Some(checkpoint) = builder.build() {
106                let _ = save_checkpoint(&checkpoint);
107            }
108        }
109
110        // Record this iteration as completed
111        ctx.record_developer_iteration();
112
113        // Step 2: Execute the PLAN
114        ctx.logger.info("Executing plan...");
115        update_status("Starting development iteration", ctx.config.isolation_mode)?;
116
117        // Read PROMPT.md and PLAN.md content directly to pass as context.
118        // This prevents agents from discovering these files through exploration,
119        // reducing the risk of accidental deletion.
120        let prompt_md = fs::read_to_string("PROMPT.md").unwrap_or_default();
121        let plan_md = fs::read_to_string(".agent/PLAN.md").unwrap_or_default();
122
123        let mut prompt_config = PromptConfig::new()
124            .with_iterations(i, ctx.config.developer_iters)
125            .with_prompt_and_plan(prompt_md, plan_md);
126
127        // Set resume context if this is the first iteration of a resumed session
128        if resuming_into_development {
129            if let Some(resume_ctx) = resume_context {
130                prompt_config = prompt_config.with_resume_context(resume_ctx.clone());
131            }
132        }
133
134        // Use prompt replay if available, otherwise generate new prompt
135        let prompt_key = format!("development_{}", i);
136        let (prompt, was_replayed) =
137            get_stored_or_generate_prompt(&prompt_key, &ctx.prompt_history, || {
138                prompt_for_agent(
139                    Role::Developer,
140                    Action::Iterate,
141                    developer_context,
142                    ctx.template_context,
143                    prompt_config.clone(),
144                )
145            });
146
147        // Capture the prompt for checkpoint/resume (only if newly generated)
148        if !was_replayed {
149            ctx.capture_prompt(&prompt_key, &prompt);
150        } else {
151            ctx.logger.info(&format!(
152                "Using stored prompt from checkpoint for determinism: {}",
153                prompt_key
154            ));
155        }
156
157        let dev_start_time = Instant::now();
158
159        let exit_code = {
160            let mut runtime = PipelineRuntime {
161                timer: ctx.timer,
162                logger: ctx.logger,
163                colors: ctx.colors,
164                config: ctx.config,
165                #[cfg(any(test, feature = "test-utils"))]
166                agent_executor: None,
167            };
168            run_with_fallback(
169                AgentRole::Developer,
170                &format!("run #{i}"),
171                &prompt,
172                &format!(".agent/logs/developer_{i}"),
173                &mut runtime,
174                ctx.registry,
175                ctx.developer_agent,
176            )?
177        };
178
179        if exit_code != 0 {
180            ctx.logger.error(&format!(
181                "Iteration {i} encountered an error but continuing"
182            ));
183            had_errors = true;
184        }
185
186        ctx.stats.developer_runs_completed += 1;
187
188        {
189            let duration = dev_start_time.elapsed().as_secs();
190            let outcome = if exit_code != 0 {
191                StepOutcome::failure(format!("Agent exited with code {exit_code}"), true)
192            } else {
193                StepOutcome::success(None, vec![])
194            };
195            let step = ExecutionStep::new("Development", i, "dev_run", outcome)
196                .with_agent(ctx.developer_agent)
197                .with_duration(duration);
198            ctx.execution_history.add_step(step);
199        }
200        update_status("Completed progress step", ctx.config.isolation_mode)?;
201
202        let snap = git_snapshot()?;
203        if snap == prev_snap {
204            if snap.is_empty() {
205                ctx.logger
206                    .warn("No git-status change detected (repository is clean)");
207            } else {
208                ctx.logger.warn(&format!(
209                    "No git-status change detected (existing changes: {})",
210                    snap.lines().count()
211                ));
212            }
213        } else {
214            ctx.logger.success(&format!(
215                "Repository modified ({} file(s) changed)",
216                snap.lines().count()
217            ));
218            ctx.stats.changes_detected += 1;
219            handle_commit_after_development(ctx, i)?;
220        }
221        prev_snap = snap;
222
223        // Run fast check if configured
224        if let Some(ref fast_cmd) = ctx.config.fast_check_cmd {
225            run_fast_check(ctx, fast_cmd, i)?;
226        }
227
228        // Periodic restoration check - ensure PROMPT.md still exists
229        // This catches agent deletions and restores from backup
230        ensure_prompt_integrity(ctx.logger, "development", i);
231
232        // Step 3: Delete the PLAN
233        ctx.logger.info("Deleting PLAN.md...");
234        if let Err(err) = delete_plan_file() {
235            ctx.logger.warn(&format!("Failed to delete PLAN.md: {err}"));
236        }
237        ctx.logger.success("PLAN.md deleted");
238
239        // Save checkpoint after iteration completes (if enabled)
240        // This checkpoint captures the completed iteration so resume won't re-run it
241        if ctx.config.features.checkpoint_enabled {
242            let next_iteration = i + 1;
243            let builder = CheckpointBuilder::new()
244                .phase(
245                    PipelinePhase::Development,
246                    next_iteration,
247                    ctx.config.developer_iters,
248                )
249                .reviewer_pass(0, ctx.config.reviewer_reviews)
250                .capture_from_context(
251                    ctx.config,
252                    ctx.registry,
253                    ctx.developer_agent,
254                    ctx.reviewer_agent,
255                    ctx.logger,
256                    &ctx.run_context,
257                )
258                .with_execution_history(ctx.execution_history.clone())
259                .with_prompt_history(ctx.clone_prompt_history());
260
261            if let Some(checkpoint) = builder.build() {
262                let _ = save_checkpoint(&checkpoint);
263            }
264        }
265    }
266
267    Ok(DevelopmentResult { had_errors })
268}
269
270/// Run the planning step to create PLAN.md.
271///
272/// The orchestrator ALWAYS extracts and writes PLAN.md from agent JSON output.
273/// Agent file writes are ignored - the orchestrator is the sole writer.
274fn run_planning_step(ctx: &mut PhaseContext<'_>, iteration: u32) -> anyhow::Result<()> {
275    let start_time = Instant::now();
276    // Save checkpoint at start of planning phase (if enabled)
277    if ctx.config.features.checkpoint_enabled {
278        let builder = CheckpointBuilder::new()
279            .phase(
280                PipelinePhase::Planning,
281                iteration,
282                ctx.config.developer_iters,
283            )
284            .reviewer_pass(0, ctx.config.reviewer_reviews)
285            .capture_from_context(
286                ctx.config,
287                ctx.registry,
288                ctx.developer_agent,
289                ctx.reviewer_agent,
290                ctx.logger,
291                &ctx.run_context,
292            )
293            .with_execution_history(ctx.execution_history.clone())
294            .with_prompt_history(ctx.clone_prompt_history());
295
296        if let Some(checkpoint) = builder.build() {
297            let _ = save_checkpoint(&checkpoint);
298        }
299    }
300
301    ctx.logger.info("Creating plan from PROMPT.md...");
302    update_status("Starting planning phase", ctx.config.isolation_mode)?;
303
304    // Read PROMPT.md content to include directly in the planning prompt
305    // This prevents agents from discovering PROMPT.md through file exploration,
306    // which reduces the risk of accidental deletion.
307    let prompt_md_content = std::fs::read_to_string("PROMPT.md").ok();
308
309    // Note: We don't set is_resume for planning since planning runs on each iteration.
310    // The resume context is set during the development execution step.
311    let prompt_key = format!("planning_{}", iteration);
312    let (plan_prompt, was_replayed) =
313        get_stored_or_generate_prompt(&prompt_key, &ctx.prompt_history, || {
314            prompt_for_agent(
315                Role::Developer,
316                Action::Plan,
317                ContextLevel::Normal,
318                ctx.template_context,
319                prompt_md_content
320                    .as_ref()
321                    .map(|content| PromptConfig::new().with_prompt_md(content.clone()))
322                    .unwrap_or_default(),
323            )
324        });
325
326    // Capture the planning prompt for checkpoint/resume (only if newly generated)
327    if !was_replayed {
328        ctx.capture_prompt(&prompt_key, &plan_prompt);
329    } else {
330        ctx.logger.info(&format!(
331            "Using stored prompt from checkpoint for determinism: {}",
332            prompt_key
333        ));
334    }
335
336    let log_dir = format!(".agent/logs/planning_{iteration}");
337    let mut runtime = PipelineRuntime {
338        timer: ctx.timer,
339        logger: ctx.logger,
340        colors: ctx.colors,
341        config: ctx.config,
342        #[cfg(any(test, feature = "test-utils"))]
343        agent_executor: None,
344    };
345    let _exit_code = run_with_fallback(
346        AgentRole::Developer,
347        &format!("planning #{iteration}"),
348        &plan_prompt,
349        &log_dir,
350        &mut runtime,
351        ctx.registry,
352        ctx.developer_agent,
353    )?;
354
355    // ORCHESTRATOR-CONTROLLED FILE I/O:
356    // Prefer extraction from JSON log (orchestrator write), but fall back to
357    // agent-written file if extraction fails (legacy/test compatibility).
358    let plan_path = Path::new(".agent/PLAN.md");
359    let log_dir_path = Path::new(&log_dir);
360
361    // Ensure .agent directory exists
362    if let Some(parent) = plan_path.parent() {
363        fs::create_dir_all(parent)?;
364    }
365
366    let extraction = extract_plan(log_dir_path)?;
367
368    if let Some(content) = extraction.raw_content {
369        // Extraction succeeded - orchestrator writes the file
370        fs::write(plan_path, &content)?;
371
372        if extraction.is_valid {
373            ctx.logger
374                .success("Plan extracted from agent output (JSON)");
375        } else {
376            ctx.logger.warn(&format!(
377                "Plan written but validation failed: {}",
378                extraction.validation_warning.unwrap_or_default()
379            ));
380        }
381    } else {
382        // JSON extraction failed - try text-based fallback
383        ctx.logger
384            .info("No JSON result event found, trying text-based extraction...");
385
386        if let Some(text_plan) = extract_plan_from_logs_text(log_dir_path)? {
387            fs::write(plan_path, &text_plan)?;
388            ctx.logger
389                .success("Plan extracted from agent output (text fallback)");
390        } else {
391            // Text extraction also failed - check if agent wrote the file directly (legacy fallback)
392            let agent_wrote_file = plan_path
393                .exists()
394                .then(|| fs::read_to_string(plan_path).ok())
395                .flatten()
396                .is_some_and(|s| !s.trim().is_empty());
397
398            if agent_wrote_file {
399                ctx.logger.info("Using agent-written PLAN.md (legacy mode)");
400            } else {
401                // No content from any source - write placeholder and fail
402                // The placeholder serves as a recovery mechanism (file exists for debugging)
403                // but the pipeline should still fail because we can't proceed without a plan
404                let placeholder = "# Plan\n\nAgent produced no extractable plan content.\n";
405                fs::write(plan_path, placeholder)?;
406                ctx.logger
407                    .error("No plan content found in agent output - wrote placeholder");
408                anyhow::bail!(
409                    "Planning agent completed successfully but no plan was found in output"
410                );
411            }
412        }
413    }
414
415    {
416        let duration = start_time.elapsed().as_secs();
417        let step = ExecutionStep::new(
418            "Planning",
419            iteration,
420            "plan_generation",
421            StepOutcome::success(None, vec![".agent/PLAN.md".to_string()]),
422        )
423        .with_agent(ctx.developer_agent)
424        .with_duration(duration);
425        ctx.execution_history.add_step(step);
426    }
427
428    Ok(())
429}
430
431/// Verify that PLAN.md exists and is non-empty.
432///
433/// With orchestrator-controlled file I/O, `run_planning_step` always writes
434/// PLAN.md (even if just a placeholder). This function checks if the file
435/// exists and has meaningful content. If resuming and plan is missing,
436/// re-run planning.
437fn verify_plan_exists(
438    ctx: &mut PhaseContext<'_>,
439    iteration: u32,
440    resuming_into_development: bool,
441) -> anyhow::Result<bool> {
442    let plan_path = Path::new(".agent/PLAN.md");
443
444    let plan_ok = plan_path
445        .exists()
446        .then(|| fs::read_to_string(plan_path).ok())
447        .flatten()
448        .is_some_and(|s| !s.trim().is_empty());
449
450    // If resuming and plan is missing, re-run planning to recover
451    if !plan_ok && resuming_into_development {
452        ctx.logger
453            .warn("Missing .agent/PLAN.md; rerunning plan generation to recover");
454        run_planning_step(ctx, iteration)?;
455
456        // Check again after rerunning - orchestrator guarantees file exists
457        let plan_ok = plan_path
458            .exists()
459            .then(|| fs::read_to_string(plan_path).ok())
460            .flatten()
461            .is_some_and(|s| !s.trim().is_empty());
462
463        return Ok(plan_ok);
464    }
465
466    Ok(plan_ok)
467}
468
469/// Run fast check command.
470fn run_fast_check(ctx: &PhaseContext<'_>, fast_cmd: &str, iteration: u32) -> anyhow::Result<()> {
471    let argv = crate::common::split_command(fast_cmd)
472        .map_err(|e| anyhow::anyhow!("FAST_CHECK_CMD parse error (iteration {iteration}): {e}"))?;
473    if argv.is_empty() {
474        ctx.logger
475            .warn("FAST_CHECK_CMD is empty; skipping fast check");
476        return Ok(());
477    }
478
479    let display_cmd = crate::common::format_argv_for_log(&argv);
480    ctx.logger.info(&format!(
481        "Running fast check: {}{}{}",
482        ctx.colors.dim(),
483        display_cmd,
484        ctx.colors.reset()
485    ));
486
487    let Some((program, cmd_args)) = argv.split_first() else {
488        ctx.logger
489            .warn("FAST_CHECK_CMD is empty after parsing; skipping fast check");
490        return Ok(());
491    };
492    let status = Command::new(program).args(cmd_args).status()?;
493
494    if status.success() {
495        ctx.logger.success("Fast check passed");
496    } else {
497        ctx.logger.warn("Fast check had issues (non-blocking)");
498    }
499
500    Ok(())
501}
502
503/// Handle commit creation after development changes are detected.
504///
505/// Creates a commit with an auto-generated message using the primary commit agent.
506/// This is done by the orchestrator, not the agent, using fallback-aware commit
507/// generation which tries multiple agents if needed.
508fn handle_commit_after_development(
509    ctx: &mut PhaseContext<'_>,
510    iteration: u32,
511) -> anyhow::Result<()> {
512    let start_time = Instant::now();
513    // Get the primary commit agent from the registry
514    let commit_agent = get_primary_commit_agent(ctx);
515
516    if let Some(agent) = commit_agent {
517        ctx.logger.info(&format!(
518            "Creating commit with auto-generated message (agent: {agent})..."
519        ));
520
521        // Get the diff for commit message generation
522        let diff = match crate::git_helpers::git_diff() {
523            Ok(d) => d,
524            Err(e) => {
525                ctx.logger
526                    .error(&format!("Failed to get diff for commit: {e}"));
527                return Err(anyhow::anyhow!(e));
528            }
529        };
530
531        // Get git identity from config
532        let git_name = ctx.config.git_user_name.as_deref();
533        let git_email = ctx.config.git_user_email.as_deref();
534
535        let result = commit_with_generated_message(&diff, &agent, git_name, git_email, ctx);
536
537        match result {
538            CommitResultFallback::Success(oid) => {
539                ctx.logger
540                    .success(&format!("Commit created successfully: {oid}"));
541                ctx.stats.commits_created += 1;
542
543                {
544                    let duration = start_time.elapsed().as_secs();
545                    let step = ExecutionStep::new(
546                        "Development",
547                        iteration,
548                        "commit",
549                        StepOutcome::success(Some(oid.to_string()), vec![]),
550                    )
551                    .with_agent(&agent)
552                    .with_duration(duration);
553                    ctx.execution_history.add_step(step);
554                }
555            }
556            CommitResultFallback::NoChanges => {
557                // No meaningful changes to commit (already handled by has_meaningful_changes)
558                ctx.logger.info("No commit created (no meaningful changes)");
559
560                {
561                    let duration = start_time.elapsed().as_secs();
562                    let step = ExecutionStep::new(
563                        "Development",
564                        iteration,
565                        "commit",
566                        StepOutcome::skipped("No meaningful changes to commit".to_string()),
567                    )
568                    .with_duration(duration);
569                    ctx.execution_history.add_step(step);
570                }
571            }
572            CommitResultFallback::Failed(err) => {
573                // Actual git operation failed - this is critical
574                ctx.logger.error(&format!(
575                    "Failed to create commit (git operation failed): {err}"
576                ));
577
578                {
579                    let duration = start_time.elapsed().as_secs();
580                    let step = ExecutionStep::new(
581                        "Development",
582                        iteration,
583                        "commit",
584                        StepOutcome::failure(err.to_string(), false),
585                    )
586                    .with_duration(duration);
587                    ctx.execution_history.add_step(step);
588                }
589
590                // Don't continue - this is a real error that needs attention
591                return Err(anyhow::anyhow!(err));
592            }
593        }
594    } else {
595        ctx.logger
596            .warn("Unable to get primary commit agent for commit");
597
598        {
599            let duration = start_time.elapsed().as_secs();
600            let step = ExecutionStep::new(
601                "Development",
602                iteration,
603                "commit",
604                StepOutcome::failure("No commit agent available".to_string(), true),
605            )
606            .with_duration(duration);
607            ctx.execution_history.add_step(step);
608        }
609    }
610
611    Ok(())
612}