Skip to main content

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_with_workspace, CheckpointBuilder, PipelinePhase};
13use crate::files::llm_output_extraction::xsd_validation::XsdValidationError;
14use crate::files::llm_output_extraction::{
15    archive_xml_file_with_workspace, extract_development_result_xml, extract_plan_xml,
16    extract_xml_with_file_fallback_with_workspace, validate_development_result_xml,
17    validate_plan_xml, xml_paths, PlanElements,
18};
19use crate::files::{delete_plan_file_with_workspace, update_status_with_workspace};
20use crate::format_xml_for_display;
21use crate::git_helpers::{git_snapshot, CommitResultFallback};
22use crate::logger::print_progress;
23use crate::phases::commit::commit_with_generated_message;
24use crate::phases::get_primary_commit_agent;
25use crate::phases::integrity::ensure_prompt_integrity;
26use crate::pipeline::{run_xsd_retry_with_session, PipelineRuntime, XsdRetryConfig};
27use crate::prompts::{
28    get_stored_or_generate_prompt, prompt_developer_iteration_continuation_xml,
29    prompt_developer_iteration_xml_with_context, prompt_developer_iteration_xsd_retry_with_context,
30    prompt_planning_xml_with_context, prompt_planning_xsd_retry_with_context, ContextLevel,
31};
32use crate::reducer::state::{ContinuationState, DevelopmentStatus};
33use std::path::Path;
34
35use super::context::PhaseContext;
36
37use crate::checkpoint::execution_history::{ExecutionStep, StepOutcome};
38
39use std::time::Instant;
40
41const CONTINUATION_CONTEXT_PATH: &str = ".agent/tmp/continuation_context.md";
42
43/// Result of the development phase.
44pub struct DevelopmentResult {
45    /// Whether any errors occurred during the phase.
46    pub had_errors: bool,
47}
48
49/// Run the development phase.
50///
51/// This phase runs `developer_iters` iterations, each consisting of:
52/// 1. Planning: Create PLAN.md from PROMPT.md
53/// 2. Execution: Execute the plan
54/// 3. Cleanup: Delete PLAN.md
55///
56/// # Arguments
57///
58/// * `ctx` - The phase context containing shared state
59/// * `start_iter` - The iteration to start from (for resume support)
60/// * `resume_context` - Optional resume context for resumed sessions
61///
62/// # Returns
63///
64/// Returns `Ok(DevelopmentResult)` on success, or an error if a critical failure occurs.
65pub fn run_development_phase(
66    ctx: &mut PhaseContext<'_>,
67    start_iter: u32,
68    resume_context: Option<&ResumeContext>,
69) -> anyhow::Result<DevelopmentResult> {
70    let mut had_errors = false;
71    let mut prev_snap = git_snapshot()?;
72    let developer_context = ContextLevel::from(ctx.config.developer_context);
73
74    for i in start_iter..=ctx.config.developer_iters {
75        ctx.logger.subheader(&format!(
76            "Iteration {} of {}",
77            i, ctx.config.developer_iters
78        ));
79        print_progress(i, ctx.config.developer_iters, "Overall");
80
81        let resuming_into_development = resume_context.is_some() && i == start_iter;
82
83        // Ensure continuation context from a previous iteration does not leak forward.
84        if !resuming_into_development {
85            let _ = cleanup_continuation_context_file(ctx);
86        }
87
88        // Step 1: Create PLAN from PROMPT (skip if resuming into development)
89        if resuming_into_development {
90            ctx.logger
91                .info("Resuming at development step; skipping plan generation");
92        } else {
93            run_planning_step(ctx, i)?;
94        }
95
96        // Verify PLAN.md was created (required)
97        let plan_ok = verify_plan_exists(ctx, i, resuming_into_development)?;
98        if !plan_ok {
99            anyhow::bail!("Planning phase did not create a non-empty .agent/PLAN.md");
100        }
101        ctx.logger.success("PLAN.md created");
102
103        // Save checkpoint at start of development phase (if enabled)
104        if ctx.config.features.checkpoint_enabled {
105            let builder = CheckpointBuilder::new()
106                .phase(PipelinePhase::Development, i, ctx.config.developer_iters)
107                .reviewer_pass(0, ctx.config.reviewer_reviews)
108                .capture_from_context(
109                    ctx.config,
110                    ctx.registry,
111                    ctx.developer_agent,
112                    ctx.reviewer_agent,
113                    ctx.logger,
114                    &ctx.run_context,
115                )
116                .with_executor_from_context(std::sync::Arc::clone(&ctx.executor_arc))
117                .with_execution_history(ctx.execution_history.clone())
118                .with_prompt_history(ctx.clone_prompt_history());
119
120            if let Some(checkpoint) = builder.build() {
121                let _ = save_checkpoint_with_workspace(ctx.workspace, &checkpoint);
122            }
123        }
124
125        // Record this iteration as completed
126        ctx.record_developer_iteration();
127
128        // Step 2: Execute the PLAN
129        ctx.logger.info("Executing plan...");
130        update_status_with_workspace(
131            ctx.workspace,
132            "Starting development iteration",
133            ctx.config.isolation_mode,
134        )?;
135
136        // Run development iteration with XML extraction and XSD validation.
137        // Config semantics: max_dev_continuations counts *continuation attempts* beyond the
138        // initial attempt. Total valid attempts is `1 + max_dev_continuations`.
139        let continuation_state = if resuming_into_development {
140            load_continuation_state_from_context_file(ctx.workspace).unwrap_or_default()
141        } else {
142            ContinuationState::new()
143        };
144        let max_continuations = ctx.config.max_dev_continuations.unwrap_or(2) as usize;
145        let max_total_attempts = 1 + max_continuations;
146        let continuation_config = ContinuationConfig {
147            state: &continuation_state,
148            max_attempts: max_total_attempts,
149        };
150
151        let dev_start_time = Instant::now();
152        let dev_result = run_development_iteration_with_xml_retry(
153            ctx,
154            i,
155            developer_context,
156            resuming_into_development,
157            resume_context,
158            None,
159            continuation_config,
160        )?;
161
162        // This iteration reached status="completed"; cleanup the continuation context file.
163        let _ = cleanup_continuation_context_file(ctx);
164
165        if dev_result.had_error {
166            ctx.logger.error(&format!(
167                "Iteration {i} encountered an error but continuing"
168            ));
169            had_errors = true;
170        }
171
172        // Record stats
173        ctx.stats.developer_runs_completed += 1;
174
175        // Record execution history
176        {
177            let duration = dev_start_time.elapsed().as_secs();
178            let outcome = if dev_result.had_error {
179                StepOutcome::failure("Agent exited with non-zero code".to_string(), true)
180            } else {
181                StepOutcome::success(
182                    dev_result.summary.clone(),
183                    dev_result.files_changed.clone().unwrap_or_default(),
184                )
185            };
186            let step = ExecutionStep::new("Development", i, "dev_run", outcome)
187                .with_agent(ctx.developer_agent)
188                .with_duration(duration);
189            ctx.execution_history.add_step(step);
190        }
191        update_status_with_workspace(
192            ctx.workspace,
193            "Completed progress step",
194            ctx.config.isolation_mode,
195        )?;
196
197        // Log the development result
198        if let Some(ref summary) = dev_result.summary {
199            ctx.logger
200                .info(&format!("Development summary: {}", summary));
201        }
202
203        let snap = git_snapshot()?;
204        if snap == prev_snap {
205            if snap.is_empty() {
206                ctx.logger
207                    .warn("No git-status change detected (repository is clean)");
208            } else {
209                ctx.logger.warn(&format!(
210                    "No git-status change detected (existing changes: {})",
211                    snap.lines().count()
212                ));
213            }
214        } else {
215            ctx.logger.success(&format!(
216                "Repository modified ({} file(s) changed)",
217                snap.lines().count()
218            ));
219            ctx.stats.changes_detected += 1;
220            handle_commit_after_development(ctx, i)?;
221        }
222        prev_snap = snap;
223
224        // Run fast check if configured
225        if let Some(ref fast_cmd) = ctx.config.fast_check_cmd {
226            run_fast_check(ctx, fast_cmd, i)?;
227        }
228
229        // Periodic restoration check - ensure PROMPT.md still exists
230        // This catches agent deletions and restores from backup
231        ensure_prompt_integrity(ctx.workspace, ctx.logger, "development", i);
232
233        // Step 3: Delete the PLAN
234        ctx.logger.info("Deleting PLAN.md...");
235        if let Err(err) = delete_plan_file_with_workspace(ctx.workspace) {
236            ctx.logger.warn(&format!("Failed to delete PLAN.md: {err}"));
237        }
238        ctx.logger.success("PLAN.md deleted");
239
240        // Save checkpoint after iteration completes (if enabled)
241        // This checkpoint captures the completed iteration so resume won't re-run it
242        if ctx.config.features.checkpoint_enabled {
243            let next_iteration = i + 1;
244            let builder = CheckpointBuilder::new()
245                .phase(
246                    PipelinePhase::Development,
247                    next_iteration,
248                    ctx.config.developer_iters,
249                )
250                .reviewer_pass(0, ctx.config.reviewer_reviews)
251                .capture_from_context(
252                    ctx.config,
253                    ctx.registry,
254                    ctx.developer_agent,
255                    ctx.reviewer_agent,
256                    ctx.logger,
257                    &ctx.run_context,
258                )
259                .with_executor_from_context(std::sync::Arc::clone(&ctx.executor_arc))
260                .with_execution_history(ctx.execution_history.clone())
261                .with_prompt_history(ctx.clone_prompt_history());
262
263            if let Some(checkpoint) = builder.build() {
264                let _ = save_checkpoint_with_workspace(ctx.workspace, &checkpoint);
265            }
266        }
267    }
268
269    Ok(DevelopmentResult { had_errors })
270}
271
272/// Result of a single development iteration.
273#[derive(Debug)]
274pub struct DevIterationResult {
275    /// Whether an error occurred during iteration.
276    pub had_error: bool,
277    /// Optional summary of what was done.
278    pub summary: Option<String>,
279    /// Optional list of files changed.
280    pub files_changed: Option<Vec<String>>,
281}
282
283/// Configuration for continuation-aware development iterations.
284///
285/// Groups the continuation state and limit together to reduce function argument count.
286#[derive(Debug, Clone)]
287pub struct ContinuationConfig<'a> {
288    /// Current continuation state for the iteration.
289    pub state: &'a ContinuationState,
290    /// Maximum number of total valid attempts allowed (initial attempt + continuations).
291    pub max_attempts: usize,
292}
293
294/// Result of a single development attempt (one session), including XSD retries.
295#[derive(Debug, Clone)]
296pub struct DevAttemptResult {
297    /// Whether any agent run returned a non-zero exit code.
298    pub had_error: bool,
299    /// Whether the output was successfully validated against the XSD.
300    pub output_valid: bool,
301    /// Development status (completed/partial/failed).
302    pub status: DevelopmentStatus,
303    /// Summary of what was done in this attempt.
304    pub summary: String,
305    /// Optional list of files changed in this attempt.
306    pub files_changed: Option<Vec<String>>,
307    /// Optional next steps recommended by the agent.
308    pub next_steps: Option<String>,
309}
310
311/// Run a single development attempt (one session) with XML extraction and XSD validation retry loop.
312///
313/// This does **not** perform continuation retries. If the agent returns status="partial" or
314/// status="failed", callers should trigger a fresh continuation attempt (new session) at the
315/// orchestration layer.
316pub fn run_development_attempt_with_xml_retry(
317    ctx: &mut PhaseContext<'_>,
318    iteration: u32,
319    _developer_context: ContextLevel,
320    _resuming_into_development: bool,
321    _resume_context: Option<&ResumeContext>,
322    _agent: Option<&str>,
323    continuation_state: &ContinuationState,
324) -> anyhow::Result<DevAttemptResult> {
325    let prompt_md = ctx
326        .workspace
327        .read(Path::new("PROMPT.md"))
328        .unwrap_or_default();
329    let plan_md = ctx
330        .workspace
331        .read(Path::new(".agent/PLAN.md"))
332        .unwrap_or_default();
333    let log_dir = format!(".agent/logs/developer_{iteration}");
334
335    let max_xsd_retries = crate::reducer::state::MAX_DEV_VALIDATION_RETRY_ATTEMPTS as usize;
336    let is_continuation = continuation_state.is_continuation();
337    let mut had_error = false;
338
339    let mut xsd_error: Option<String> = None;
340    let mut session_info: Option<crate::pipeline::session::SessionInfo> = None;
341
342    // Inner loop: XSD validation retry with error feedback
343    // Session continuation allows the AI to retain memory between XSD retries
344    for retry_num in 0..max_xsd_retries {
345        let is_retry = retry_num > 0;
346        let total_attempts = retry_num + 1;
347
348        // Before each retry, check if the XML file is writable and clean up if locked
349        // This prevents "permission denied" errors from stale file handles
350        if is_retry {
351            use crate::files::io::check_and_cleanup_xml_before_retry_with_workspace;
352
353            let xml_path =
354                Path::new(crate::files::llm_output_extraction::xml_paths::DEVELOPMENT_RESULT_XML);
355            let _ = check_and_cleanup_xml_before_retry_with_workspace(
356                ctx.workspace,
357                xml_path,
358                ctx.logger,
359            );
360        }
361
362        // For initial attempt, use XML prompt
363        // For retries, use XSD retry prompt with error feedback
364        let dev_prompt = if !is_retry && !is_continuation {
365            // First attempt ever - use initial XML prompt
366            let prompt_key = format!("development_{}", iteration);
367            let (prompt, was_replayed) =
368                get_stored_or_generate_prompt(&prompt_key, &ctx.prompt_history, || {
369                    prompt_developer_iteration_xml_with_context(
370                        ctx.template_context,
371                        &prompt_md,
372                        &plan_md,
373                    )
374                });
375
376            if !was_replayed {
377                ctx.capture_prompt(&prompt_key, &prompt);
378            } else {
379                ctx.logger.info(&format!(
380                    "Using stored prompt from checkpoint for determinism: {}",
381                    prompt_key
382                ));
383            }
384
385            prompt
386        } else if !is_continuation {
387            // XSD retry only (no continuation yet)
388            ctx.logger.info(&format!(
389                "  In-session retry {}/{} for XSD validation (total attempt: {})",
390                retry_num,
391                max_xsd_retries - 1,
392                total_attempts
393            ));
394            if let Some(ref error) = xsd_error {
395                ctx.logger.info(&format!("  XSD error: {}", error));
396            }
397
398            let last_output = read_last_development_output(Path::new(&log_dir), ctx.workspace);
399
400            prompt_developer_iteration_xsd_retry_with_context(
401                ctx.template_context,
402                &prompt_md,
403                &plan_md,
404                xsd_error.as_deref().unwrap_or("Unknown error"),
405                &last_output,
406                ctx.workspace,
407            )
408        } else if !is_retry {
409            // Continuation only (first XSD attempt after continuation)
410            ctx.logger.info(&format!(
411                "  Continuation attempt {} (XSD validation attempt {}/{})",
412                continuation_state.continuation_attempt, 1, max_xsd_retries
413            ));
414
415            let prompt_key = format!(
416                "development_{}_continuation_{}",
417                iteration, continuation_state.continuation_attempt
418            );
419            let (prompt, was_replayed) =
420                get_stored_or_generate_prompt(&prompt_key, &ctx.prompt_history, || {
421                    prompt_developer_iteration_continuation_xml(
422                        ctx.template_context,
423                        continuation_state,
424                    )
425                });
426
427            if !was_replayed {
428                ctx.capture_prompt(&prompt_key, &prompt);
429            } else {
430                ctx.logger.info(&format!(
431                    "Using stored prompt from checkpoint for determinism: {}",
432                    prompt_key
433                ));
434            }
435
436            prompt
437        } else {
438            // Both continuation and XSD retry
439            ctx.logger.info(&format!(
440                "  Continuation retry {}/{} for XSD validation (total attempt: {})",
441                retry_num,
442                max_xsd_retries - 1,
443                total_attempts
444            ));
445            if let Some(ref error) = xsd_error {
446                ctx.logger.info(&format!("  XSD error: {}", error));
447            }
448
449            let last_output = read_last_development_output(Path::new(&log_dir), ctx.workspace);
450
451            prompt_developer_iteration_xsd_retry_with_context(
452                ctx.template_context,
453                &prompt_md,
454                &plan_md,
455                xsd_error.as_deref().unwrap_or("Unknown error"),
456                &last_output,
457                ctx.workspace,
458            )
459        };
460
461        // Run the agent with session continuation for XSD retries
462        // This is completely fault-tolerant - if session continuation fails for any reason
463        // (including agent crash, segfault, invalid session), it falls back to normal behavior
464        let exit_code = {
465            let mut runtime = PipelineRuntime {
466                timer: ctx.timer,
467                logger: ctx.logger,
468                colors: ctx.colors,
469                config: ctx.config,
470                executor: ctx.executor,
471                executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
472                workspace: ctx.workspace,
473            };
474            let base_label = format!(
475                "run #{}{}",
476                iteration,
477                if is_continuation {
478                    format!(
479                        " (continuation {})",
480                        continuation_state.continuation_attempt
481                    )
482                } else {
483                    String::new()
484                }
485            );
486            let mut xsd_retry_config = XsdRetryConfig {
487                role: AgentRole::Developer,
488                base_label: &base_label,
489                prompt: &dev_prompt,
490                logfile_prefix: &log_dir,
491                runtime: &mut runtime,
492                registry: ctx.registry,
493                primary_agent: _agent.unwrap_or(ctx.developer_agent),
494                session_info: session_info.as_ref(),
495                retry_num,
496                output_validator: None,
497                workspace: ctx.workspace,
498            };
499            run_xsd_retry_with_session(&mut xsd_retry_config)?
500        };
501
502        if exit_code != 0 {
503            had_error = true;
504        }
505
506        // Extract and validate the development result XML
507        let log_dir_path = Path::new(&log_dir);
508        let dev_content = read_last_development_output(log_dir_path, ctx.workspace);
509
510        // Extract session info for potential retry (only if we don't have it yet)
511        // This is best-effort - if extraction fails, we just won't use session continuation
512        if session_info.is_none() {
513            if let Some(agent_config) = ctx.registry.resolve_config(ctx.developer_agent) {
514                ctx.logger.info(&format!(
515                    "  [dev] Extracting session from {:?} with parser {:?}",
516                    log_dir_path, agent_config.json_parser
517                ));
518                session_info = crate::pipeline::session::extract_session_info_from_log_prefix(
519                    log_dir_path,
520                    agent_config.json_parser,
521                    Some(ctx.developer_agent),
522                    ctx.workspace,
523                );
524                if let Some(ref info) = session_info {
525                    ctx.logger.info(&format!(
526                        "  [dev] Extracted session: agent={}, session_id={}...",
527                        info.agent_name,
528                        &info.session_id[..8.min(info.session_id.len())]
529                    ));
530                } else {
531                    ctx.logger
532                        .warn("  [dev] Failed to extract session info from log");
533                }
534            }
535        }
536
537        // Try file-based extraction first - allows agents to write XML to .agent/tmp/development_result.xml
538        let xml_to_validate = extract_xml_with_file_fallback_with_workspace(
539            ctx.workspace,
540            Path::new(xml_paths::DEVELOPMENT_RESULT_XML),
541            &dev_content,
542            extract_development_result_xml,
543        )
544        .unwrap_or_else(|| {
545            // No XML found anywhere - assume entire log content is XML for validation
546            // This allows us to get specific XSD errors to send back to the agent
547            dev_content.clone()
548        });
549
550        match validate_development_result_xml(&xml_to_validate) {
551            Ok(result_elements) => {
552                // XSD validation passed - format and log the result
553                let formatted_xml = format_xml_for_display(&xml_to_validate);
554
555                // Archive the XML file for debugging (moves to .xml.processed)
556                archive_xml_file_with_workspace(
557                    ctx.workspace,
558                    Path::new(xml_paths::DEVELOPMENT_RESULT_XML),
559                );
560
561                if is_retry {
562                    ctx.logger
563                        .success(&format!("Status validated after {} retries", retry_num));
564                } else {
565                    ctx.logger.success("Status extracted and validated (XML)");
566                }
567
568                ctx.logger.info(&format!("\n{}", formatted_xml));
569
570                let files_changed = result_elements
571                    .files_changed
572                    .as_ref()
573                    .map(|f| f.lines().map(|s| s.to_string()).collect());
574
575                let status = if result_elements.is_completed() {
576                    DevelopmentStatus::Completed
577                } else if result_elements.is_partial() {
578                    DevelopmentStatus::Partial
579                } else {
580                    DevelopmentStatus::Failed
581                };
582
583                return Ok(DevAttemptResult {
584                    had_error,
585                    output_valid: true,
586                    status,
587                    summary: result_elements.summary.clone(),
588                    files_changed,
589                    next_steps: result_elements.next_steps.clone(),
590                });
591            }
592            Err(xsd_err) => {
593                let error_msg = format_xsd_error(&xsd_err);
594                ctx.logger
595                    .warn(&format!("  XSD validation failed: {}", error_msg));
596
597                if retry_num < max_xsd_retries - 1 {
598                    xsd_error = Some(error_msg);
599                    continue;
600                }
601
602                ctx.logger.warn(&format!(
603                    "  XSD retries exhausted ({}/{}). Will attempt fresh continuation.",
604                    retry_num + 1,
605                    max_xsd_retries
606                ));
607                break;
608            }
609        }
610    }
611
612    Ok(DevAttemptResult {
613        had_error,
614        output_valid: false,
615        status: DevelopmentStatus::Failed,
616        summary: "XML output failed validation. Your previous (invalid) output is at .agent/tmp/last_output.xml for reference.".to_string(),
617        files_changed: None,
618        next_steps: Some(
619            "Complete the task and provide valid XML output conforming to the XSD schema."
620                .to_string(),
621        ),
622    })
623}
624
625/// Run a single development iteration with XML extraction and XSD validation retry loop.
626///
627/// This function implements a nested loop structure:
628/// - **Outer loop (continuation)**: Continue while status != "completed" (max configurable)
629/// - **Inner loop (XSD retry)**: Retry XSD validation with error feedback
630///   (max `MAX_DEV_VALIDATION_RETRY_ATTEMPTS`, currently 10)
631///
632/// The continuation logic ignores non-XSD errors and only looks for valid XML.
633/// If XML passes XSD validation with status="completed", we're done for this iteration.
634/// If XML passes XSD validation with status="partial", we continue the outer loop.
635/// If XML passes XSD validation with status="failed", we continue the outer loop.
636///
637/// The development iteration produces side effects (file changes) as its primary output.
638/// The XML status is secondary - we use it for logging/tracking but don't fail the
639/// entire iteration if XML is missing or invalid.
640///
641/// # Arguments
642///
643/// * `ctx` - Phase context with access to workspace, logger, and configuration
644/// * `iteration` - Current iteration number
645/// * `_developer_context` - Context level (deprecated, unused)
646/// * `_resuming_into_development` - Whether resuming into development phase
647/// * `_resume_context` - Optional resume context from checkpoint
648/// * `_agent` - Optional agent override
649/// * `continuation_config` - Configuration for continuation-aware prompting
650pub fn run_development_iteration_with_xml_retry(
651    ctx: &mut PhaseContext<'_>,
652    iteration: u32,
653    _developer_context: ContextLevel,
654    _resuming_into_development: bool,
655    _resume_context: Option<&ResumeContext>,
656    _agent: Option<&str>,
657    continuation_config: ContinuationConfig<'_>,
658) -> anyhow::Result<DevIterationResult> {
659    let max_xsd_retries = crate::reducer::state::MAX_DEV_VALIDATION_RETRY_ATTEMPTS as usize;
660    let max_total_attempts = continuation_config.max_attempts;
661    let max_continuations = max_total_attempts.saturating_sub(1);
662
663    // Track local continuation state (starts from the provided state)
664    let mut local_continuation = continuation_config.state.clone();
665    let mut had_any_error = false;
666    let mut last_summary: Option<String> = None;
667
668    // Outer loop: Continue until agent returns status="completed" or we hit the limit.
669    // The loop count is total valid attempts (initial + continuations).
670    for _ in 0..max_total_attempts {
671        if local_continuation.is_continuation() {
672            ctx.logger.info(&format!(
673                "Continuation {} of {} (status was not 'completed')",
674                local_continuation.continuation_attempt, max_continuations
675            ));
676        }
677
678        let attempt = run_development_attempt_with_xml_retry(
679            ctx,
680            iteration,
681            _developer_context,
682            _resuming_into_development,
683            _resume_context,
684            _agent,
685            &local_continuation,
686        )?;
687
688        had_any_error |= attempt.had_error;
689        last_summary = Some(attempt.summary.clone());
690
691        if attempt.output_valid && matches!(attempt.status, DevelopmentStatus::Completed) {
692            return Ok(DevIterationResult {
693                had_error: had_any_error,
694                summary: Some(attempt.summary),
695                files_changed: attempt.files_changed,
696            });
697        }
698
699        // Trigger a fresh continuation attempt (outer loop will continue).
700        // This treats "couldn't parse response" as equivalent to "failed" status.
701        local_continuation = local_continuation.trigger_continuation(
702            attempt.status,
703            attempt.summary,
704            attempt.files_changed,
705            attempt.next_steps,
706        );
707
708        // Persist continuation context for resumability through checkpoints.
709        // This file is referenced by the continuation prompt template.
710        let _ = write_continuation_context_file(ctx, iteration, &local_continuation);
711    }
712
713    // If we get here, we exhausted the continuation limit without ever reaching
714    // status="completed". This is an explicit failure signal: proceeding would
715    // silently allow the pipeline to continue despite incomplete work.
716    let summary = last_summary.unwrap_or_else(|| {
717        format!(
718            "Continuation stopped after {} attempts",
719            max_total_attempts * max_xsd_retries
720        )
721    });
722    anyhow::bail!(
723        "Development iteration did not reach status='completed' after {} total valid attempts (max_continuations={}, max_xsd_retries={} per attempt). Last summary: {}",
724        max_total_attempts,
725        max_continuations,
726        max_xsd_retries,
727        summary
728    );
729}
730
731fn cleanup_continuation_context_file(ctx: &mut PhaseContext<'_>) -> anyhow::Result<()> {
732    let path = Path::new(CONTINUATION_CONTEXT_PATH);
733    if ctx.workspace.exists(path) {
734        ctx.workspace.remove(path)?;
735    }
736    Ok(())
737}
738
739fn write_continuation_context_file(
740    ctx: &mut PhaseContext<'_>,
741    iteration: u32,
742    continuation_state: &ContinuationState,
743) -> anyhow::Result<()> {
744    let tmp_dir = Path::new(".agent/tmp");
745    if !ctx.workspace.exists(tmp_dir) {
746        ctx.workspace.create_dir_all(tmp_dir)?;
747    }
748
749    let mut content = String::new();
750    content.push_str("# Development Continuation Context\n\n");
751    content.push_str(&format!("- Iteration: {iteration}\n"));
752    content.push_str(&format!(
753        "- Continuation attempt: {}\n",
754        continuation_state.continuation_attempt
755    ));
756    if let Some(ref status) = continuation_state.previous_status {
757        content.push_str(&format!("- Previous status: {status}\n\n"));
758    } else {
759        content.push_str("- Previous status: unknown\n\n");
760    }
761
762    content.push_str("## Previous summary\n\n");
763    if let Some(ref summary) = continuation_state.previous_summary {
764        content.push_str(summary);
765    }
766    content.push('\n');
767
768    if let Some(ref files) = continuation_state.previous_files_changed {
769        content.push_str("\n## Files changed\n\n");
770        for file in files {
771            content.push_str("- ");
772            content.push_str(file);
773            content.push('\n');
774        }
775    }
776
777    if let Some(ref next_steps) = continuation_state.previous_next_steps {
778        content.push_str("\n## Recommended next steps\n\n");
779        content.push_str(next_steps);
780        content.push('\n');
781    }
782
783    content.push_str("\n## Reference files (do not modify)\n\n");
784    content.push_str("- PROMPT.md\n");
785    content.push_str("- .agent/PLAN.md\n");
786
787    ctx.workspace
788        .write(Path::new(CONTINUATION_CONTEXT_PATH), &content)?;
789
790    Ok(())
791}
792
793fn load_continuation_state_from_context_file(
794    workspace: &dyn crate::workspace::Workspace,
795) -> Option<ContinuationState> {
796    let path = Path::new(CONTINUATION_CONTEXT_PATH);
797    if !workspace.exists(path) {
798        return None;
799    }
800    let content = workspace.read(path).ok()?;
801    parse_continuation_context_markdown(&content)
802}
803
804fn parse_continuation_context_markdown(content: &str) -> Option<ContinuationState> {
805    let mut continuation_attempt: Option<u32> = None;
806    let mut previous_status: Option<DevelopmentStatus> = None;
807    let mut previous_summary_lines: Vec<String> = Vec::new();
808    let mut previous_next_steps_lines: Vec<String> = Vec::new();
809    let mut previous_files_changed: Vec<String> = Vec::new();
810
811    enum Section {
812        None,
813        PreviousSummary,
814        FilesChanged,
815        NextSteps,
816    }
817
818    let mut section = Section::None;
819
820    for line in content.lines() {
821        let line = line.trim_end();
822
823        if let Some(rest) = line.strip_prefix("- Continuation attempt:") {
824            continuation_attempt = rest.trim().parse::<u32>().ok();
825            continue;
826        }
827        if let Some(rest) = line.strip_prefix("- Previous status:") {
828            let s = rest.trim().to_ascii_lowercase();
829            previous_status = match s.as_str() {
830                "completed" => Some(DevelopmentStatus::Completed),
831                "partial" => Some(DevelopmentStatus::Partial),
832                "failed" => Some(DevelopmentStatus::Failed),
833                _ => None,
834            };
835            continue;
836        }
837
838        if line == "## Previous summary" {
839            section = Section::PreviousSummary;
840            continue;
841        }
842        if line == "## Files changed" {
843            section = Section::FilesChanged;
844            continue;
845        }
846        if line == "## Recommended next steps" {
847            section = Section::NextSteps;
848            continue;
849        }
850        if line.starts_with("## ") {
851            section = Section::None;
852            continue;
853        }
854
855        match section {
856            Section::PreviousSummary => previous_summary_lines.push(line.to_string()),
857            Section::FilesChanged => {
858                if let Some(item) = line.strip_prefix("- ") {
859                    if !item.trim().is_empty() {
860                        previous_files_changed.push(item.trim().to_string());
861                    }
862                }
863            }
864            Section::NextSteps => previous_next_steps_lines.push(line.to_string()),
865            Section::None => {}
866        }
867    }
868
869    let continuation_attempt = continuation_attempt?;
870    let previous_summary = previous_summary_lines.join("\n").trim().to_string();
871    let previous_next_steps = previous_next_steps_lines.join("\n").trim().to_string();
872
873    Some(ContinuationState {
874        previous_status,
875        previous_summary: if previous_summary.is_empty() {
876            None
877        } else {
878            Some(previous_summary)
879        },
880        previous_files_changed: if previous_files_changed.is_empty() {
881            None
882        } else {
883            Some(previous_files_changed)
884        },
885        previous_next_steps: if previous_next_steps.is_empty() {
886            None
887        } else {
888            Some(previous_next_steps)
889        },
890        continuation_attempt,
891    })
892}
893
894/// Run the planning step to create PLAN.md.
895///
896/// The orchestrator ALWAYS extracts and writes PLAN.md from agent XML output.
897/// Uses XSD validation with retry loop to ensure valid XML format.
898pub fn run_planning_step(ctx: &mut PhaseContext<'_>, iteration: u32) -> anyhow::Result<()> {
899    let start_time = Instant::now();
900    // Save checkpoint at start of planning phase (if enabled)
901    if ctx.config.features.checkpoint_enabled {
902        let builder = CheckpointBuilder::new()
903            .phase(
904                PipelinePhase::Planning,
905                iteration,
906                ctx.config.developer_iters,
907            )
908            .reviewer_pass(0, ctx.config.reviewer_reviews)
909            .capture_from_context(
910                ctx.config,
911                ctx.registry,
912                ctx.developer_agent,
913                ctx.reviewer_agent,
914                ctx.logger,
915                &ctx.run_context,
916            )
917            .with_executor_from_context(std::sync::Arc::clone(&ctx.executor_arc))
918            .with_execution_history(ctx.execution_history.clone())
919            .with_prompt_history(ctx.clone_prompt_history());
920
921        if let Some(checkpoint) = builder.build() {
922            let _ = save_checkpoint_with_workspace(ctx.workspace, &checkpoint);
923        }
924    }
925
926    ctx.logger.info("Creating plan from PROMPT.md...");
927    update_status_with_workspace(
928        ctx.workspace,
929        "Starting planning phase",
930        ctx.config.isolation_mode,
931    )?;
932
933    // Read PROMPT.md content to include directly in the planning prompt
934    // This prevents agents from discovering PROMPT.md through file exploration,
935    // which reduces the risk of accidental deletion.
936    let prompt_md_content = ctx.workspace.read(Path::new("PROMPT.md")).ok();
937
938    // Note: We don't set is_resume for planning since planning runs on each iteration.
939    // The resume context is set during the development execution step.
940    let prompt_key = format!("planning_{}", iteration);
941    let prompt_md_str = prompt_md_content.as_deref().unwrap_or("");
942
943    // Use prompt replay if available, otherwise generate new prompt
944    let (plan_prompt, was_replayed) =
945        get_stored_or_generate_prompt(&prompt_key, &ctx.prompt_history, || {
946            prompt_planning_xml_with_context(
947                ctx.template_context,
948                Some(prompt_md_str),
949                ctx.workspace,
950            )
951        });
952
953    // Capture the planning prompt for checkpoint/resume (only if newly generated)
954    if !was_replayed {
955        ctx.capture_prompt(&prompt_key, &plan_prompt);
956    } else {
957        ctx.logger.info(&format!(
958            "Using stored prompt from checkpoint for determinism: {}",
959            prompt_key
960        ));
961    }
962
963    let log_dir = format!(".agent/logs/planning_{iteration}");
964    let plan_path = Path::new(".agent/PLAN.md");
965
966    // Ensure .agent directory exists
967    if let Some(parent) = plan_path.parent() {
968        ctx.workspace.create_dir_all(parent)?;
969    }
970
971    // In-session retry loop with XSD validation feedback
972    // Session continuation allows the AI to retain memory between XSD retries
973    let max_retries = crate::reducer::state::MAX_VALIDATION_RETRY_ATTEMPTS as usize;
974    let mut xsd_error: Option<String> = None;
975    let mut session_info: Option<crate::pipeline::session::SessionInfo> = None;
976
977    for retry_num in 0..max_retries {
978        // Before each retry, check if the XML file is writable and clean up if locked
979        if retry_num > 0 {
980            use crate::files::io::check_and_cleanup_xml_before_retry_with_workspace;
981            let xml_path = Path::new(crate::files::llm_output_extraction::xml_paths::PLAN_XML);
982            let _ = check_and_cleanup_xml_before_retry_with_workspace(
983                ctx.workspace,
984                xml_path,
985                ctx.logger,
986            );
987        }
988
989        // For initial attempt, use XML prompt
990        // For retries, use XSD retry prompt with error feedback
991        let plan_prompt = if retry_num == 0 {
992            plan_prompt.clone()
993        } else {
994            ctx.logger.info(&format!(
995                "  In-session retry {}/{} for XSD validation",
996                retry_num,
997                max_retries - 1
998            ));
999            if let Some(ref error) = xsd_error {
1000                ctx.logger.info(&format!("  XSD error: {}", error));
1001            }
1002
1003            // Read the last output for retry context (used as fallback if session continuation fails)
1004            let last_output = read_last_planning_output(Path::new(&log_dir), ctx.workspace);
1005
1006            prompt_planning_xsd_retry_with_context(
1007                ctx.template_context,
1008                prompt_md_str,
1009                xsd_error.as_deref().unwrap_or("Unknown error"),
1010                &last_output,
1011                ctx.workspace,
1012            )
1013        };
1014
1015        let mut runtime = PipelineRuntime {
1016            timer: ctx.timer,
1017            logger: ctx.logger,
1018            colors: ctx.colors,
1019            config: ctx.config,
1020            executor: ctx.executor,
1021            executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
1022            workspace: ctx.workspace,
1023        };
1024
1025        // Use session continuation for XSD retries (retry_num > 0)
1026        // This is completely fault-tolerant - if session continuation fails for any reason
1027        // (including agent crash, segfault, invalid session), it falls back to normal behavior
1028        let mut xsd_retry_config = XsdRetryConfig {
1029            role: AgentRole::Developer,
1030            base_label: &format!("planning #{}", iteration),
1031            prompt: &plan_prompt,
1032            logfile_prefix: &log_dir,
1033            runtime: &mut runtime,
1034            registry: ctx.registry,
1035            primary_agent: ctx.developer_agent,
1036            session_info: session_info.as_ref(),
1037            retry_num,
1038            output_validator: None,
1039            workspace: ctx.workspace,
1040        };
1041
1042        let _exit_code = run_xsd_retry_with_session(&mut xsd_retry_config)?;
1043
1044        // Extract and validate the plan XML
1045        let log_dir_path = Path::new(&log_dir);
1046        let plan_content = read_last_planning_output(log_dir_path, ctx.workspace);
1047
1048        // Extract session info for potential retry (only if we don't have it yet)
1049        // This is best-effort - if extraction fails, we just won't use session continuation
1050        if session_info.is_none() {
1051            if let Some(agent_config) = ctx.registry.resolve_config(ctx.developer_agent) {
1052                session_info = crate::pipeline::session::extract_session_info_from_log_prefix(
1053                    log_dir_path,
1054                    agent_config.json_parser,
1055                    Some(ctx.developer_agent),
1056                    ctx.workspace,
1057                );
1058            }
1059        }
1060
1061        // Try file-based extraction first - allows agents to write XML to .agent/tmp/plan.xml
1062        let xml_to_validate = extract_xml_with_file_fallback_with_workspace(
1063            ctx.workspace,
1064            Path::new(xml_paths::PLAN_XML),
1065            &plan_content,
1066            extract_plan_xml,
1067        )
1068        .unwrap_or_else(|| {
1069            // No XML found anywhere - assume entire log content is XML for validation
1070            // This allows us to get specific XSD errors to send back to the agent
1071            plan_content.clone()
1072        });
1073
1074        // Try to validate against XSD
1075        match validate_plan_xml(&xml_to_validate) {
1076            Ok(plan_elements) => {
1077                // XSD validation passed - convert XML to markdown format for PLAN.md
1078                // Note: XML display is handled via UIEvent::XmlOutput in the effect handler
1079                let markdown = format_plan_as_markdown(&plan_elements);
1080                ctx.workspace.write(plan_path, &markdown)?;
1081
1082                // Archive the XML file for debugging (moves to .xml.processed)
1083                archive_xml_file_with_workspace(ctx.workspace, Path::new(xml_paths::PLAN_XML));
1084
1085                if retry_num > 0 {
1086                    ctx.logger
1087                        .success(&format!("Plan validated after {} retries", retry_num));
1088                } else {
1089                    ctx.logger.success("Plan extracted and validated (XML)");
1090                }
1091
1092                // Record execution history before returning
1093                {
1094                    let duration = start_time.elapsed().as_secs();
1095                    let step = ExecutionStep::new(
1096                        "Planning",
1097                        iteration,
1098                        "plan_generation",
1099                        StepOutcome::success(None, vec![".agent/PLAN.md".to_string()]),
1100                    )
1101                    .with_agent(ctx.developer_agent)
1102                    .with_duration(duration);
1103                    ctx.execution_history.add_step(step);
1104                }
1105
1106                return Ok(());
1107            }
1108            Err(xsd_err) => {
1109                // XSD validation failed - check if we can retry
1110                let error_msg = format_xsd_error(&xsd_err);
1111                ctx.logger
1112                    .warn(&format!("  XSD validation failed: {}", error_msg));
1113
1114                if retry_num < max_retries - 1 {
1115                    // Store error for next retry attempt
1116                    xsd_error = Some(error_msg);
1117                    // Continue to next retry iteration
1118                    continue;
1119                } else {
1120                    ctx.logger
1121                        .error("  No more in-session XSD retries remaining");
1122                    // Write placeholder and fail
1123                    let placeholder = "# Plan\n\nAgent produced no valid XML output. Only XML format is accepted.\n";
1124                    ctx.workspace.write(plan_path, placeholder)?;
1125                    anyhow::bail!(
1126                        "Planning agent did not produce valid XML output after {} attempts",
1127                        max_retries
1128                    );
1129                }
1130            }
1131        }
1132    }
1133
1134    // Record execution history for failed planning (should never be reached since we always return above)
1135    {
1136        let duration = start_time.elapsed().as_secs();
1137        let step = ExecutionStep::new(
1138            "Planning",
1139            iteration,
1140            "plan_generation",
1141            StepOutcome::failure("No valid XML output produced".to_string(), false),
1142        )
1143        .with_agent(ctx.developer_agent)
1144        .with_duration(duration);
1145        ctx.execution_history.add_step(step);
1146    }
1147
1148    anyhow::bail!("Planning failed after {} XSD retry attempts", max_retries)
1149}
1150
1151/// Read the last planning output from logs.
1152///
1153/// The `log_prefix` is a path prefix (not a directory) like `.agent/logs/planning_1`.
1154/// Actual log files are named `{prefix}_{agent}_{model}.log`, e.g.:
1155/// `.agent/logs/planning_1_ccs-glm_0.log`
1156fn read_last_planning_output(
1157    log_prefix: &Path,
1158    workspace: &dyn crate::workspace::Workspace,
1159) -> String {
1160    read_last_output_from_prefix(log_prefix, workspace)
1161}
1162
1163/// Read the last development output from logs.
1164///
1165/// The `log_prefix` is a path prefix (not a directory) like `.agent/logs/development_1`.
1166/// Actual log files are named `{prefix}_{agent}_{model}.log`, e.g.:
1167/// `.agent/logs/development_1_ccs-glm_0.log`
1168fn read_last_development_output(
1169    log_prefix: &Path,
1170    workspace: &dyn crate::workspace::Workspace,
1171) -> String {
1172    read_last_output_from_prefix(log_prefix, workspace)
1173}
1174
1175/// Read the most recent log file matching a prefix pattern.
1176///
1177/// This is a shared helper for reading log output. Truncation of large prompts
1178/// is handled centrally in `build_agent_command` to prevent E2BIG errors.
1179fn read_last_output_from_prefix(
1180    log_prefix: &Path,
1181    workspace: &dyn crate::workspace::Workspace,
1182) -> String {
1183    crate::pipeline::logfile::read_most_recent_logfile(log_prefix, workspace)
1184}
1185
1186/// Format XSD error for display.
1187fn format_xsd_error(error: &XsdValidationError) -> String {
1188    format!(
1189        "{} - expected: {}, found: {}",
1190        error.element_path, error.expected, error.found
1191    )
1192}
1193
1194/// Format plan elements as markdown for PLAN.md.
1195fn format_plan_as_markdown(elements: &PlanElements) -> String {
1196    let mut result = String::new();
1197
1198    // Summary section
1199    result.push_str("## Summary\n\n");
1200    result.push_str(&elements.summary.context);
1201    result.push_str("\n\n");
1202
1203    // Scope items
1204    result.push_str("### Scope\n\n");
1205    for item in &elements.summary.scope_items {
1206        if let Some(ref count) = item.count {
1207            result.push_str(&format!("- **{}** {}", count, item.description));
1208        } else {
1209            result.push_str(&format!("- {}", item.description));
1210        }
1211        if let Some(ref category) = item.category {
1212            result.push_str(&format!(" ({})", category));
1213        }
1214        result.push('\n');
1215    }
1216    result.push('\n');
1217
1218    // Implementation steps
1219    result.push_str("## Implementation Steps\n\n");
1220    for step in &elements.steps {
1221        // Step header
1222        let step_type_str = match step.step_type {
1223            crate::files::llm_output_extraction::xsd_validation_plan::StepType::FileChange => {
1224                "file-change"
1225            }
1226            crate::files::llm_output_extraction::xsd_validation_plan::StepType::Action => "action",
1227            crate::files::llm_output_extraction::xsd_validation_plan::StepType::Research => {
1228                "research"
1229            }
1230        };
1231        let priority_str = step.priority.map_or(String::new(), |p| {
1232            format!(
1233                " [{}]",
1234                match p {
1235                    crate::files::llm_output_extraction::xsd_validation_plan::Priority::Critical =>
1236                        "critical",
1237                    crate::files::llm_output_extraction::xsd_validation_plan::Priority::High =>
1238                        "high",
1239                    crate::files::llm_output_extraction::xsd_validation_plan::Priority::Medium =>
1240                        "medium",
1241                    crate::files::llm_output_extraction::xsd_validation_plan::Priority::Low =>
1242                        "low",
1243                }
1244            )
1245        });
1246
1247        result.push_str(&format!(
1248            "### Step {} ({}){}:  {}\n\n",
1249            step.number, step_type_str, priority_str, step.title
1250        ));
1251
1252        // Target files
1253        if !step.target_files.is_empty() {
1254            result.push_str("**Target Files:**\n");
1255            for tf in &step.target_files {
1256                let action_str = match tf.action {
1257                    crate::files::llm_output_extraction::xsd_validation_plan::FileAction::Create => {
1258                        "create"
1259                    }
1260                    crate::files::llm_output_extraction::xsd_validation_plan::FileAction::Modify => {
1261                        "modify"
1262                    }
1263                    crate::files::llm_output_extraction::xsd_validation_plan::FileAction::Delete => {
1264                        "delete"
1265                    }
1266                };
1267                result.push_str(&format!("- `{}` ({})\n", tf.path, action_str));
1268            }
1269            result.push('\n');
1270        }
1271
1272        // Location
1273        if let Some(ref location) = step.location {
1274            result.push_str(&format!("**Location:** {}\n\n", location));
1275        }
1276
1277        // Rationale
1278        if let Some(ref rationale) = step.rationale {
1279            result.push_str(&format!("**Rationale:** {}\n\n", rationale));
1280        }
1281
1282        // Content
1283        result.push_str(&format_rich_content(&step.content));
1284        result.push('\n');
1285
1286        // Dependencies
1287        if !step.depends_on.is_empty() {
1288            result.push_str("**Depends on:** ");
1289            let deps: Vec<String> = step
1290                .depends_on
1291                .iter()
1292                .map(|d| format!("Step {}", d))
1293                .collect();
1294            result.push_str(&deps.join(", "));
1295            result.push_str("\n\n");
1296        }
1297    }
1298
1299    // Critical files
1300    result.push_str("## Critical Files\n\n");
1301    result.push_str("### Primary Files\n\n");
1302    for pf in &elements.critical_files.primary_files {
1303        let action_str = match pf.action {
1304            crate::files::llm_output_extraction::xsd_validation_plan::FileAction::Create => {
1305                "create"
1306            }
1307            crate::files::llm_output_extraction::xsd_validation_plan::FileAction::Modify => {
1308                "modify"
1309            }
1310            crate::files::llm_output_extraction::xsd_validation_plan::FileAction::Delete => {
1311                "delete"
1312            }
1313        };
1314        if let Some(ref est) = pf.estimated_changes {
1315            result.push_str(&format!("- `{}` ({}) - {}\n", pf.path, action_str, est));
1316        } else {
1317            result.push_str(&format!("- `{}` ({})\n", pf.path, action_str));
1318        }
1319    }
1320    result.push('\n');
1321
1322    if !elements.critical_files.reference_files.is_empty() {
1323        result.push_str("### Reference Files\n\n");
1324        for rf in &elements.critical_files.reference_files {
1325            result.push_str(&format!("- `{}` - {}\n", rf.path, rf.purpose));
1326        }
1327        result.push('\n');
1328    }
1329
1330    // Risks and mitigations
1331    result.push_str("## Risks & Mitigations\n\n");
1332    for rp in &elements.risks_mitigations {
1333        let severity_str = rp.severity.map_or(String::new(), |s| {
1334            format!(
1335                " [{}]",
1336                match s {
1337                    crate::files::llm_output_extraction::xsd_validation_plan::Severity::Low =>
1338                        "low",
1339                    crate::files::llm_output_extraction::xsd_validation_plan::Severity::Medium =>
1340                        "medium",
1341                    crate::files::llm_output_extraction::xsd_validation_plan::Severity::High =>
1342                        "high",
1343                    crate::files::llm_output_extraction::xsd_validation_plan::Severity::Critical =>
1344                        "critical",
1345                }
1346            )
1347        });
1348        result.push_str(&format!("**Risk{}:** {}\n", severity_str, rp.risk));
1349        result.push_str(&format!("**Mitigation:** {}\n\n", rp.mitigation));
1350    }
1351
1352    // Verification strategy
1353    result.push_str("## Verification Strategy\n\n");
1354    for (i, v) in elements.verification_strategy.iter().enumerate() {
1355        result.push_str(&format!("{}. **{}**\n", i + 1, v.method));
1356        result.push_str(&format!("   Expected: {}\n\n", v.expected_outcome));
1357    }
1358
1359    result
1360}
1361
1362/// Format rich content elements to markdown.
1363fn format_rich_content(
1364    content: &crate::files::llm_output_extraction::xsd_validation_plan::RichContent,
1365) -> String {
1366    use crate::files::llm_output_extraction::xsd_validation_plan::ContentElement;
1367
1368    let mut result = String::new();
1369
1370    for element in &content.elements {
1371        match element {
1372            ContentElement::Paragraph(p) => {
1373                result.push_str(&format_inline_content(&p.content));
1374                result.push_str("\n\n");
1375            }
1376            ContentElement::CodeBlock(cb) => {
1377                let lang = cb.language.as_deref().unwrap_or("");
1378                result.push_str(&format!("```{}\n", lang));
1379                result.push_str(&cb.content);
1380                if !cb.content.ends_with('\n') {
1381                    result.push('\n');
1382                }
1383                result.push_str("```\n\n");
1384            }
1385            ContentElement::Table(t) => {
1386                if let Some(ref caption) = t.caption {
1387                    result.push_str(&format!("**{}**\n\n", caption));
1388                }
1389                // Header row
1390                if !t.columns.is_empty() {
1391                    result.push_str("| ");
1392                    result.push_str(&t.columns.join(" | "));
1393                    result.push_str(" |\n");
1394                    result.push('|');
1395                    for _ in &t.columns {
1396                        result.push_str(" --- |");
1397                    }
1398                    result.push('\n');
1399                } else if let Some(first_row) = t.rows.first() {
1400                    // Infer column count from first row
1401                    result.push('|');
1402                    for _ in &first_row.cells {
1403                        result.push_str(" --- |");
1404                    }
1405                    result.push('\n');
1406                }
1407                // Data rows
1408                for row in &t.rows {
1409                    result.push_str("| ");
1410                    let cells: Vec<String> = row
1411                        .cells
1412                        .iter()
1413                        .map(|c| format_inline_content(&c.content))
1414                        .collect();
1415                    result.push_str(&cells.join(" | "));
1416                    result.push_str(" |\n");
1417                }
1418                result.push('\n');
1419            }
1420            ContentElement::List(l) => {
1421                result.push_str(&format_list(l, 0));
1422                result.push('\n');
1423            }
1424            ContentElement::Heading(h) => {
1425                let prefix = "#".repeat(h.level as usize);
1426                result.push_str(&format!("{} {}\n\n", prefix, h.text));
1427            }
1428        }
1429    }
1430
1431    result
1432}
1433
1434/// Format inline content elements.
1435fn format_inline_content(
1436    content: &[crate::files::llm_output_extraction::xsd_validation_plan::InlineElement],
1437) -> String {
1438    use crate::files::llm_output_extraction::xsd_validation_plan::InlineElement;
1439
1440    content
1441        .iter()
1442        .map(|e| match e {
1443            InlineElement::Text(s) => s.clone(),
1444            InlineElement::Emphasis(s) => format!("**{}**", s),
1445            InlineElement::Code(s) => format!("`{}`", s),
1446            InlineElement::Link { href, text } => format!("[{}]({})", text, href),
1447        })
1448        .collect::<Vec<_>>()
1449        .join("")
1450}
1451
1452/// Format a list element with proper indentation.
1453fn format_list(
1454    list: &crate::files::llm_output_extraction::xsd_validation_plan::List,
1455    indent: usize,
1456) -> String {
1457    use crate::files::llm_output_extraction::xsd_validation_plan::ListType;
1458
1459    let mut result = String::new();
1460    let indent_str = "  ".repeat(indent);
1461
1462    for (i, item) in list.items.iter().enumerate() {
1463        let marker = match list.list_type {
1464            ListType::Ordered => format!("{}. ", i + 1),
1465            ListType::Unordered => "- ".to_string(),
1466        };
1467
1468        result.push_str(&indent_str);
1469        result.push_str(&marker);
1470        result.push_str(&format_inline_content(&item.content));
1471        result.push('\n');
1472
1473        if let Some(ref nested) = item.nested_list {
1474            result.push_str(&format_list(nested, indent + 1));
1475        }
1476    }
1477
1478    result
1479}
1480
1481/// Verify that PLAN.md exists and is non-empty.
1482///
1483/// With orchestrator-controlled file I/O, `run_planning_step` always writes
1484/// PLAN.md (even if just a placeholder). This function checks if the file
1485/// exists and has meaningful content. If resuming and plan is missing,
1486/// re-run planning.
1487fn verify_plan_exists(
1488    ctx: &mut PhaseContext<'_>,
1489    iteration: u32,
1490    resuming_into_development: bool,
1491) -> anyhow::Result<bool> {
1492    let plan_path = Path::new(".agent/PLAN.md");
1493
1494    let plan_ok = ctx
1495        .workspace
1496        .exists(plan_path)
1497        .then(|| ctx.workspace.read(plan_path).ok())
1498        .flatten()
1499        .is_some_and(|s| !s.trim().is_empty());
1500
1501    // If resuming and plan is missing, re-run planning to recover
1502    if !plan_ok && resuming_into_development {
1503        ctx.logger
1504            .warn("Missing .agent/PLAN.md; rerunning plan generation to recover");
1505        run_planning_step(ctx, iteration)?;
1506
1507        // Check again after rerunning - orchestrator guarantees file exists
1508        let plan_ok = ctx
1509            .workspace
1510            .exists(plan_path)
1511            .then(|| ctx.workspace.read(plan_path).ok())
1512            .flatten()
1513            .is_some_and(|s| !s.trim().is_empty());
1514
1515        return Ok(plan_ok);
1516    }
1517
1518    Ok(plan_ok)
1519}
1520
1521/// Run fast check command.
1522fn run_fast_check(ctx: &PhaseContext<'_>, fast_cmd: &str, iteration: u32) -> anyhow::Result<()> {
1523    let argv = crate::common::split_command(fast_cmd)
1524        .map_err(|e| anyhow::anyhow!("FAST_CHECK_CMD parse error (iteration {iteration}): {e}"))?;
1525    if argv.is_empty() {
1526        ctx.logger
1527            .warn("FAST_CHECK_CMD is empty; skipping fast check");
1528        return Ok(());
1529    }
1530
1531    let display_cmd = crate::common::format_argv_for_log(&argv);
1532    ctx.logger.info(&format!(
1533        "Running fast check: {}{}{}",
1534        ctx.colors.dim(),
1535        display_cmd,
1536        ctx.colors.reset()
1537    ));
1538
1539    let Some((program, cmd_args)) = argv.split_first() else {
1540        ctx.logger
1541            .warn("FAST_CHECK_CMD is empty after parsing; skipping fast check");
1542        return Ok(());
1543    };
1544    let args_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
1545    let output = ctx.executor.execute(program, &args_refs, &[], None)?;
1546    let status = output.status;
1547
1548    if status.success() {
1549        ctx.logger.success("Fast check passed");
1550    } else {
1551        ctx.logger.warn("Fast check had issues (non-blocking)");
1552    }
1553
1554    Ok(())
1555}
1556
1557#[cfg(test)]
1558mod tests {
1559    use super::*;
1560    use crate::agents::AgentRegistry;
1561    use crate::checkpoint::execution_history::ExecutionHistory;
1562    use crate::checkpoint::RunContext;
1563    use crate::config::Config;
1564    use crate::executor::MockProcessExecutor;
1565    use crate::logger::{Colors, Logger};
1566    use crate::pipeline::{Stats, Timer};
1567    use crate::prompts::template_context::TemplateContext;
1568    use crate::workspace::MemoryWorkspace;
1569    use crate::workspace::Workspace;
1570    use std::path::{Path, PathBuf};
1571
1572    struct TestFixture {
1573        config: Config,
1574        registry: AgentRegistry,
1575        colors: Colors,
1576        logger: Logger,
1577        timer: Timer,
1578        stats: Stats,
1579        template_context: TemplateContext,
1580        executor_arc: std::sync::Arc<dyn crate::executor::ProcessExecutor>,
1581        repo_root: PathBuf,
1582        workspace: MemoryWorkspace,
1583    }
1584
1585    impl TestFixture {
1586        fn new() -> Self {
1587            let colors = Colors { enabled: false };
1588            let executor = MockProcessExecutor::new();
1589            let executor_arc = std::sync::Arc::new(executor)
1590                as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
1591            let repo_root = PathBuf::from("/test/repo");
1592            let workspace = MemoryWorkspace::new(repo_root.clone());
1593            let registry = AgentRegistry::new().unwrap();
1594
1595            Self {
1596                config: Config::default(),
1597                registry,
1598                colors,
1599                logger: Logger::new(colors),
1600                timer: Timer::new(),
1601                stats: Stats::default(),
1602                template_context: TemplateContext::default(),
1603                executor_arc,
1604                repo_root,
1605                workspace,
1606            }
1607        }
1608    }
1609
1610    #[test]
1611    fn test_run_development_iteration_with_xml_retry_errors_when_continuations_exhausted_without_completion(
1612    ) {
1613        let mut fixture = TestFixture::new();
1614        fixture.config.max_dev_continuations = Some(1);
1615
1616        fixture
1617            .workspace
1618            .write(Path::new("PROMPT.md"), "do the thing")
1619            .unwrap();
1620        fixture
1621            .workspace
1622            .write(Path::new(".agent/PLAN.md"), "plan")
1623            .unwrap();
1624        fixture
1625            .workspace
1626            .create_dir_all(Path::new(".agent/tmp"))
1627            .unwrap();
1628        fixture
1629            .workspace
1630            .write(
1631                Path::new(".agent/tmp/development_result.xml"),
1632                r#"<ralph-development-result>
1633<ralph-status>partial</ralph-status>
1634<ralph-summary>partial work</ralph-summary>
1635</ralph-development-result>"#,
1636            )
1637            .unwrap();
1638
1639        let mut ctx = PhaseContext {
1640            config: &fixture.config,
1641            registry: &fixture.registry,
1642            logger: &fixture.logger,
1643            colors: &fixture.colors,
1644            timer: &mut fixture.timer,
1645            stats: &mut fixture.stats,
1646            developer_agent: "codex",
1647            reviewer_agent: "codex",
1648            review_guidelines: None,
1649            template_context: &fixture.template_context,
1650            run_context: RunContext::new(),
1651            execution_history: ExecutionHistory::new(),
1652            prompt_history: std::collections::HashMap::new(),
1653            executor: &*fixture.executor_arc,
1654            executor_arc: std::sync::Arc::clone(&fixture.executor_arc),
1655            repo_root: &fixture.repo_root,
1656            workspace: &fixture.workspace,
1657        };
1658
1659        let continuation_state = ContinuationState::new();
1660        let continuation_config = ContinuationConfig {
1661            state: &continuation_state,
1662            // Config semantics: max_dev_continuations counts continuation attempts beyond the
1663            // initial attempt.
1664            max_attempts: 1 + fixture.config.max_dev_continuations.unwrap_or(2) as usize,
1665        };
1666
1667        let result = run_development_iteration_with_xml_retry(
1668            &mut ctx,
1669            1,
1670            ContextLevel::Minimal,
1671            false,
1672            None::<&crate::checkpoint::restore::ResumeContext>,
1673            Some("codex"),
1674            continuation_config,
1675        );
1676
1677        assert!(
1678            result.is_err(),
1679            "Expected error when continuations exhausted without status='completed'"
1680        );
1681    }
1682}
1683
1684/// Handle commit creation after development changes are detected.
1685///
1686/// Creates a commit with an auto-generated message using the primary commit agent.
1687/// This is done by the orchestrator, not the agent, using fallback-aware commit
1688/// generation which tries multiple agents if needed.
1689fn handle_commit_after_development(
1690    ctx: &mut PhaseContext<'_>,
1691    iteration: u32,
1692) -> anyhow::Result<()> {
1693    let start_time = Instant::now();
1694    // Get the primary commit agent from the registry
1695    let commit_agent = get_primary_commit_agent(ctx);
1696
1697    if let Some(agent) = commit_agent {
1698        ctx.logger.info(&format!(
1699            "Creating commit with auto-generated message (agent: {agent})..."
1700        ));
1701
1702        // Get the diff for commit message generation
1703        let diff = match crate::git_helpers::git_diff() {
1704            Ok(d) => d,
1705            Err(e) => {
1706                ctx.logger
1707                    .error(&format!("Failed to get diff for commit: {e}"));
1708                return Err(anyhow::anyhow!(e));
1709            }
1710        };
1711
1712        // Get git identity from config
1713        let git_name = ctx.config.git_user_name.as_deref();
1714        let git_email = ctx.config.git_user_email.as_deref();
1715
1716        let result = commit_with_generated_message(&diff, &agent, git_name, git_email, ctx);
1717
1718        match result {
1719            CommitResultFallback::Success(oid) => {
1720                ctx.logger
1721                    .success(&format!("Commit created successfully: {oid}"));
1722                ctx.stats.commits_created += 1;
1723
1724                {
1725                    let duration = start_time.elapsed().as_secs();
1726                    let step = ExecutionStep::new(
1727                        "Development",
1728                        iteration,
1729                        "commit",
1730                        StepOutcome::success(Some(oid.to_string()), vec![]),
1731                    )
1732                    .with_agent(&agent)
1733                    .with_duration(duration);
1734                    ctx.execution_history.add_step(step);
1735                }
1736            }
1737            CommitResultFallback::NoChanges => {
1738                // No meaningful changes to commit (already handled by has_meaningful_changes)
1739                ctx.logger.info("No commit created (no meaningful changes)");
1740
1741                {
1742                    let duration = start_time.elapsed().as_secs();
1743                    let step = ExecutionStep::new(
1744                        "Development",
1745                        iteration,
1746                        "commit",
1747                        StepOutcome::skipped("No meaningful changes to commit".to_string()),
1748                    )
1749                    .with_duration(duration);
1750                    ctx.execution_history.add_step(step);
1751                }
1752            }
1753            CommitResultFallback::Failed(err) => {
1754                // Actual git operation failed - this is critical
1755                ctx.logger.error(&format!(
1756                    "Failed to create commit (git operation failed): {err}"
1757                ));
1758
1759                {
1760                    let duration = start_time.elapsed().as_secs();
1761                    let step = ExecutionStep::new(
1762                        "Development",
1763                        iteration,
1764                        "commit",
1765                        StepOutcome::failure(err.to_string(), false),
1766                    )
1767                    .with_duration(duration);
1768                    ctx.execution_history.add_step(step);
1769                }
1770
1771                // Don't continue - this is a real error that needs attention
1772                return Err(anyhow::anyhow!(err));
1773            }
1774        }
1775    } else {
1776        ctx.logger
1777            .warn("Unable to get primary commit agent for commit");
1778
1779        {
1780            let duration = start_time.elapsed().as_secs();
1781            let step = ExecutionStep::new(
1782                "Development",
1783                iteration,
1784                "commit",
1785                StepOutcome::failure("No commit agent available".to_string(), true),
1786            )
1787            .with_duration(duration);
1788            ctx.execution_history.add_step(step);
1789        }
1790    }
1791
1792    Ok(())
1793}