Skip to main content

ralph_workflow/reducer/
handler.rs

1//! Main effect handler implementation.
2//!
3//! This module implements the EffectHandler trait to execute pipeline side effects
4//! through the reducer architecture. Effect handlers perform actual work (agent
5//! invocation, git operations, file I/O) and emit events.
6
7use crate::agents::AgentRole;
8use crate::checkpoint::{
9    save_checkpoint_with_workspace, CheckpointBuilder, PipelinePhase as CheckpointPhase,
10};
11use crate::files::llm_output_extraction::file_based_extraction::paths as xml_paths;
12use crate::files::llm_output_extraction::validate_issues_xml;
13use crate::phases::{commit, development, get_primary_commit_agent, review, PhaseContext};
14use crate::pipeline::PipelineRuntime;
15use crate::prompts::ContextLevel;
16use crate::reducer::effect::{Effect, EffectHandler, EffectResult};
17use crate::reducer::event::{
18    CheckpointTrigger, ConflictStrategy, PipelineEvent, PipelinePhase, RebasePhase,
19};
20use crate::reducer::fault_tolerant_executor::{
21    execute_agent_fault_tolerantly, AgentExecutionConfig,
22};
23use crate::reducer::state::PipelineState;
24use crate::reducer::ui_event::{UIEvent, XmlCodeSnippet, XmlOutputContext, XmlOutputType};
25use crate::workspace::Workspace;
26use anyhow::Result;
27use regex::Regex;
28use std::path::{Path, PathBuf};
29
30/// Main effect handler implementation.
31///
32/// This handler executes effects by calling existing pipeline functions,
33/// maintaining compatibility while migrating to reducer architecture.
34pub struct MainEffectHandler {
35    /// Current pipeline state
36    pub state: PipelineState,
37    /// Event log for replay/debugging
38    pub event_log: Vec<PipelineEvent>,
39}
40
41impl MainEffectHandler {
42    /// Create a new effect handler.
43    pub fn new(state: PipelineState) -> Self {
44        Self {
45            state,
46            event_log: Vec::new(),
47        }
48    }
49}
50
51impl<'ctx> EffectHandler<'ctx> for MainEffectHandler {
52    fn execute(&mut self, effect: Effect, ctx: &mut PhaseContext<'_>) -> Result<EffectResult> {
53        let result = self.execute_effect(effect, ctx)?;
54        self.event_log.push(result.event.clone());
55        Ok(result)
56    }
57}
58
59impl crate::app::event_loop::StatefulHandler for MainEffectHandler {
60    fn update_state(&mut self, state: PipelineState) {
61        self.state = state;
62    }
63}
64
65impl MainEffectHandler {
66    /// Helper to create phase transition UI event.
67    fn phase_transition_ui(&self, to: PipelinePhase) -> UIEvent {
68        UIEvent::PhaseTransition {
69            from: Some(self.state.phase),
70            to,
71        }
72    }
73
74    fn execute_effect(
75        &mut self,
76        effect: Effect,
77        ctx: &mut PhaseContext<'_>,
78    ) -> Result<EffectResult> {
79        match effect {
80            Effect::AgentInvocation {
81                role,
82                agent,
83                model,
84                prompt,
85            } => self.invoke_agent(ctx, role, agent, model, prompt),
86
87            Effect::InitializeAgentChain { role } => self.initialize_agent_chain(ctx, role),
88
89            Effect::GeneratePlan { iteration } => self.generate_plan(ctx, iteration),
90
91            Effect::RunDevelopmentIteration { iteration } => {
92                self.run_development_iteration(ctx, iteration)
93            }
94
95            Effect::RunReviewPass { pass } => self.run_review_pass(ctx, pass),
96
97            Effect::RunFixAttempt { pass } => self.run_fix_attempt(ctx, pass),
98
99            Effect::RunRebase {
100                phase,
101                target_branch,
102            } => self.run_rebase(ctx, phase, target_branch),
103
104            Effect::ResolveRebaseConflicts { strategy } => {
105                self.resolve_rebase_conflicts(ctx, strategy)
106            }
107
108            Effect::GenerateCommitMessage => self.generate_commit_message(ctx),
109
110            Effect::CreateCommit { message } => self.create_commit(ctx, message),
111
112            Effect::SkipCommit { reason } => self.skip_commit(ctx, reason),
113
114            Effect::ValidateFinalState => self.validate_final_state(ctx),
115
116            Effect::SaveCheckpoint { trigger } => self.save_checkpoint(ctx, trigger),
117
118            Effect::CleanupContext => self.cleanup_context(ctx),
119
120            Effect::RestorePromptPermissions => self.restore_prompt_permissions(ctx),
121        }
122    }
123
124    fn invoke_agent(
125        &mut self,
126        ctx: &mut PhaseContext<'_>,
127        role: AgentRole,
128        agent: String,
129        model: Option<String>,
130        prompt: String,
131    ) -> Result<EffectResult> {
132        // Use agent from state.agent_chain if available
133        let effective_agent = self
134            .state
135            .agent_chain
136            .current_agent()
137            .unwrap_or(&agent)
138            .clone();
139
140        let model_name = self.state.agent_chain.current_model();
141
142        // Use continuation prompt if available (from rate-limited predecessor).
143        //
144        // Important: only use it when it's the *same* prompt as this invocation.
145        // If the pipeline has generated a new prompt (retry/fallback instructions,
146        // different phase/role, etc.), do not override it with stale continuation
147        // context.
148        let effective_prompt = match self
149            .state
150            .agent_chain
151            .rate_limit_continuation_prompt
152            .as_ref()
153        {
154            Some(saved) if saved == &prompt => saved.clone(),
155            _ => prompt,
156        };
157
158        ctx.logger.info(&format!(
159            "Executing with agent: {}, model: {:?}",
160            effective_agent, model_name
161        ));
162
163        // Get agent configuration from registry
164        let agent_config = ctx
165            .registry
166            .resolve_config(&effective_agent)
167            .ok_or_else(|| anyhow::anyhow!("Agent not found: {}", effective_agent))?;
168
169        // Determine log file path
170        let safe_agent_name =
171            crate::pipeline::logfile::sanitize_agent_name(&effective_agent.to_lowercase());
172        let logfile = format!(".agent/logs/{}.log", safe_agent_name);
173
174        // Build command string, honoring reducer-selected model (if any).
175        // The reducer's agent chain drives model fallback (advance_to_next_model).
176        // When present, the selected model must be threaded into the command.
177        let model_override = model_name
178            .map(std::string::String::as_str)
179            .or(model.as_deref());
180        let cmd_str = agent_config.build_cmd_with_model(true, true, true, model_override);
181
182        // Build pipeline runtime
183        let mut runtime = PipelineRuntime {
184            timer: ctx.timer,
185            logger: ctx.logger,
186            colors: ctx.colors,
187            config: ctx.config,
188            executor: ctx.executor,
189            executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
190            workspace: ctx.workspace,
191        };
192
193        // Execute agent with fault-tolerant wrapper
194        let config = AgentExecutionConfig {
195            role,
196            agent_name: &effective_agent,
197            cmd_str: &cmd_str,
198            parser_type: agent_config.json_parser,
199            env_vars: &agent_config.env_vars,
200            prompt: &effective_prompt,
201            display_name: &effective_agent,
202            logfile: &logfile,
203        };
204
205        let event = execute_agent_fault_tolerantly(config, &mut runtime)?;
206
207        // Emit UI event for agent activity
208        let ui_event = UIEvent::AgentActivity {
209            agent: effective_agent.clone(),
210            message: format!("Completed {} task", role),
211        };
212
213        Ok(EffectResult::with_ui(event, vec![ui_event]))
214    }
215
216    fn generate_plan(
217        &mut self,
218        ctx: &mut PhaseContext<'_>,
219        iteration: u32,
220    ) -> Result<EffectResult> {
221        match development::run_planning_step(ctx, iteration) {
222            Ok(_) => {
223                // Validate plan was created
224                let plan_path = Path::new(".agent/PLAN.md");
225                let plan_exists = ctx.workspace.exists(plan_path);
226                let plan_content = if plan_exists {
227                    ctx.workspace.read(plan_path).ok().unwrap_or_default()
228                } else {
229                    String::new()
230                };
231
232                let is_valid = plan_exists && !plan_content.trim().is_empty();
233
234                let event = PipelineEvent::PlanGenerationCompleted {
235                    iteration,
236                    valid: is_valid,
237                };
238
239                // Build UI events
240                let mut ui_events = vec![];
241
242                // Emit phase transition UI event when plan is valid
243                if is_valid {
244                    ui_events.push(self.phase_transition_ui(PipelinePhase::Development));
245
246                    // Try to read plan XML for semantic rendering
247                    let plan_xml_path = Path::new(".agent/tmp/plan.xml");
248                    let processed_path = Path::new(".agent/tmp/plan.xml.processed");
249                    if let Some(xml_content) = ctx
250                        .workspace
251                        .read(plan_xml_path)
252                        .ok()
253                        .or_else(|| ctx.workspace.read(processed_path).ok())
254                    {
255                        ui_events.push(UIEvent::XmlOutput {
256                            xml_type: XmlOutputType::DevelopmentPlan,
257                            content: xml_content,
258                            context: Some(XmlOutputContext {
259                                iteration: Some(iteration),
260                                pass: None,
261                                snippets: Vec::new(),
262                            }),
263                        });
264                    }
265                }
266
267                Ok(EffectResult::with_ui(event, ui_events))
268            }
269            Err(_) => Ok(EffectResult::event(
270                PipelineEvent::PlanGenerationCompleted {
271                    iteration,
272                    valid: false,
273                },
274            )),
275        }
276    }
277
278    fn run_development_iteration(
279        &mut self,
280        ctx: &mut PhaseContext<'_>,
281        iteration: u32,
282    ) -> Result<EffectResult> {
283        use crate::checkpoint::restore::ResumeContext;
284        let developer_context = ContextLevel::from(ctx.config.developer_context);
285
286        // Get current agent from agent chain
287        let dev_agent = self.state.agent_chain.current_agent().cloned();
288
289        // Get continuation state from reducer state
290        let continuation_state = &self.state.continuation;
291        // Config semantics: max_dev_continuations counts *continuation attempts* (fresh sessions)
292        // allowed after the initial attempt. Total valid attempts per iteration is
293        // `1 + max_dev_continuations`.
294        let max_continuations = ctx.config.max_dev_continuations.unwrap_or(2);
295
296        // Defensive guard: if checkpoint state already exceeds the configured limit,
297        // abort rather than looping indefinitely.
298        if continuation_state.continuation_attempt > max_continuations {
299            return Ok(EffectResult::event(PipelineEvent::PipelineAborted {
300                reason: format!(
301                    "Development continuation attempts exhausted (continuation_attempt={}, max_continuations={})",
302                    continuation_state.continuation_attempt, max_continuations
303                ),
304            }));
305        }
306
307        // Clean stale continuation context when starting a fresh attempt.
308        if continuation_state.continuation_attempt == 0 {
309            let _ = cleanup_continuation_context_file(ctx);
310        }
311
312        // If the agent repeatedly fails to produce valid XML even after in-session
313        // XSD retries, rerun the attempt a small number of times without consuming
314        // the continuation budget (which is reserved for valid partial/failed work).
315        const MAX_INVALID_OUTPUT_RERUNS: u32 = 2;
316
317        let mut invalid_reruns: u32 = 0;
318        let attempt = loop {
319            // Run a single development attempt (one session) with XSD retry.
320            let attempt = development::run_development_attempt_with_xml_retry(
321                ctx,
322                iteration,
323                developer_context,
324                false,
325                None::<&ResumeContext>,
326                dev_agent.as_deref(),
327                continuation_state,
328            );
329
330            let attempt = match attempt {
331                Ok(a) => a,
332                Err(err) => {
333                    return Ok(EffectResult::event(PipelineEvent::PipelineAborted {
334                        reason: format!("Development attempt failed: {err}"),
335                    }));
336                }
337            };
338
339            match decide_dev_iteration_next_step(
340                continuation_state.continuation_attempt,
341                max_continuations,
342                &attempt,
343            ) {
344                DevIterationNextStep::RetryInvalidOutput
345                    if invalid_reruns < MAX_INVALID_OUTPUT_RERUNS =>
346                {
347                    invalid_reruns += 1;
348                    ctx.logger.info(&format!(
349                        "Development output invalid after XSD retries; rerunning attempt without consuming continuation budget ({}/{})",
350                        invalid_reruns, MAX_INVALID_OUTPUT_RERUNS
351                    ));
352                    continue;
353                }
354                DevIterationNextStep::RetryInvalidOutput => {
355                    return Ok(EffectResult::event(PipelineEvent::PipelineAborted {
356                        reason: format!(
357                            "Development output remained invalid after XSD retries and {} reruns. Last summary={}",
358                            MAX_INVALID_OUTPUT_RERUNS,
359                            attempt.summary
360                        ),
361                    }));
362                }
363                _ => break attempt,
364            }
365        };
366
367        // If we reached completed, the iteration can transition to commit.
368        if matches!(
369            decide_dev_iteration_next_step(
370                continuation_state.continuation_attempt,
371                max_continuations,
372                &attempt
373            ),
374            DevIterationNextStep::Completed
375        ) {
376            let _ = cleanup_continuation_context_file(ctx);
377
378            let event = if continuation_state.is_continuation() {
379                PipelineEvent::DevelopmentIterationContinuationSucceeded {
380                    iteration,
381                    total_continuation_attempts: continuation_state.continuation_attempt,
382                }
383            } else {
384                PipelineEvent::DevelopmentIterationCompleted {
385                    iteration,
386                    output_valid: true,
387                }
388            };
389
390            let ui_event = UIEvent::IterationProgress {
391                current: iteration,
392                total: self.state.total_iterations,
393            };
394
395            let mut ui_events = vec![ui_event];
396
397            // Try to read development result XML for semantic rendering.
398            let dev_xml_path = Path::new(".agent/tmp/development_result.xml");
399            let processed_path = Path::new(".agent/tmp/development_result.xml.processed");
400            if let Some(xml_content) = ctx
401                .workspace
402                .read(dev_xml_path)
403                .ok()
404                .or_else(|| ctx.workspace.read(processed_path).ok())
405            {
406                ui_events.push(UIEvent::XmlOutput {
407                    xml_type: XmlOutputType::DevelopmentResult,
408                    content: xml_content,
409                    context: Some(XmlOutputContext {
410                        iteration: Some(iteration),
411                        pass: None,
412                        snippets: Vec::new(),
413                    }),
414                });
415            }
416
417            return Ok(EffectResult::with_ui(event, ui_events));
418        }
419
420        // Not completed (valid output): partial/failed status triggers a continuation attempt.
421        let next_attempt = match decide_dev_iteration_next_step(
422            continuation_state.continuation_attempt,
423            max_continuations,
424            &attempt,
425        ) {
426            DevIterationNextStep::Continue {
427                next_continuation_attempt,
428            } => next_continuation_attempt,
429            DevIterationNextStep::Abort { .. } => {
430                let _ = cleanup_continuation_context_file(ctx);
431                let total_valid_attempts = 1 + max_continuations;
432                return Ok(EffectResult::event(PipelineEvent::PipelineAborted {
433                    reason: format!(
434                        "Development did not reach status='completed' after {} total valid attempts. Last status={:?}. Last summary={}",
435                        total_valid_attempts,
436                        attempt.status,
437                        attempt.summary
438                    ),
439                }));
440            }
441            DevIterationNextStep::RetryInvalidOutput | DevIterationNextStep::Completed => {
442                // Completed is handled above. Invalid output is handled by the rerun loop above.
443                unreachable!("Unexpected dev iteration next step after invalid-output handling")
444            }
445        };
446
447        ctx.logger.info(&format!(
448            "Triggering development continuation attempt {}/{} (previous status={})",
449            next_attempt, max_continuations, attempt.status
450        ));
451
452        // Write continuation context for the next attempt.
453        write_continuation_context_file(ctx, iteration, next_attempt, &attempt)?;
454        ctx.logger
455            .info("Continuation context written to .agent/tmp/continuation_context.md");
456
457        let event = PipelineEvent::DevelopmentIterationContinuationTriggered {
458            iteration,
459            status: attempt.status,
460            summary: attempt.summary,
461            files_changed: attempt.files_changed,
462            next_steps: attempt.next_steps,
463        };
464
465        let mut ui_events = vec![UIEvent::IterationProgress {
466            current: iteration,
467            total: self.state.total_iterations,
468        }];
469
470        // Try to read development result XML for semantic rendering.
471        let dev_xml_path = Path::new(".agent/tmp/development_result.xml");
472        let processed_path = Path::new(".agent/tmp/development_result.xml.processed");
473        if let Some(xml_content) = ctx
474            .workspace
475            .read(dev_xml_path)
476            .ok()
477            .or_else(|| ctx.workspace.read(processed_path).ok())
478        {
479            ui_events.push(UIEvent::XmlOutput {
480                xml_type: XmlOutputType::DevelopmentResult,
481                content: xml_content,
482                context: Some(XmlOutputContext {
483                    iteration: Some(iteration),
484                    pass: None,
485                    snippets: Vec::new(),
486                }),
487            });
488        }
489
490        Ok(EffectResult::with_ui(event, ui_events))
491    }
492
493    fn run_review_pass(&mut self, ctx: &mut PhaseContext<'_>, pass: u32) -> Result<EffectResult> {
494        let review_label = format!("review_{}", pass);
495
496        // Get current reviewer agent from agent chain
497        let review_agent = self.state.agent_chain.current_agent().cloned();
498
499        match review::run_review_pass(ctx, pass, &review_label, "", review_agent.as_deref()) {
500            Ok(result) => {
501                let event = PipelineEvent::ReviewCompleted {
502                    pass,
503                    issues_found: !result.early_exit,
504                };
505
506                // Build UI events
507                let mut ui_events = vec![
508                    // Emit UI event for review progress
509                    UIEvent::ReviewProgress {
510                        pass,
511                        total: self.state.total_reviewer_passes,
512                    },
513                ];
514
515                // Try to read issues XML for semantic rendering
516                let issues_xml_path = Path::new(".agent/tmp/issues.xml");
517                let processed_path = Path::new(".agent/tmp/issues.xml.processed");
518                if let Some(xml_content) = ctx
519                    .workspace
520                    .read(issues_xml_path)
521                    .ok()
522                    .or_else(|| ctx.workspace.read(processed_path).ok())
523                {
524                    let snippets = collect_review_issue_snippets(ctx.workspace, &xml_content);
525                    ui_events.push(UIEvent::XmlOutput {
526                        xml_type: XmlOutputType::ReviewIssues,
527                        content: xml_content,
528                        context: Some(XmlOutputContext {
529                            iteration: None,
530                            pass: Some(pass),
531                            snippets,
532                        }),
533                    });
534                }
535
536                Ok(EffectResult::with_ui(event, ui_events))
537            }
538            Err(_) => Ok(EffectResult::event(PipelineEvent::ReviewCompleted {
539                pass,
540                issues_found: false,
541            })),
542        }
543    }
544
545    fn run_fix_attempt(&mut self, ctx: &mut PhaseContext<'_>, pass: u32) -> Result<EffectResult> {
546        use crate::checkpoint::restore::ResumeContext;
547        let reviewer_context = ContextLevel::from(ctx.config.reviewer_context);
548
549        // Get current reviewer agent from agent chain
550        let fix_agent = self.state.agent_chain.current_agent().cloned();
551
552        match review::run_fix_pass(
553            ctx,
554            pass,
555            reviewer_context,
556            None::<&ResumeContext>,
557            fix_agent.as_deref(),
558        ) {
559            Ok(_) => {
560                let event = PipelineEvent::FixAttemptCompleted {
561                    pass,
562                    changes_made: true,
563                };
564
565                // Build UI events - try to read fix result XML for semantic rendering
566                let mut ui_events = vec![];
567                let fix_xml_path = Path::new(".agent/tmp/fix_result.xml");
568                let processed_path = Path::new(".agent/tmp/fix_result.xml.processed");
569                if let Some(xml_content) = ctx
570                    .workspace
571                    .read(fix_xml_path)
572                    .ok()
573                    .or_else(|| ctx.workspace.read(processed_path).ok())
574                {
575                    ui_events.push(UIEvent::XmlOutput {
576                        xml_type: XmlOutputType::FixResult,
577                        content: xml_content,
578                        context: Some(XmlOutputContext {
579                            iteration: None,
580                            pass: Some(pass),
581                            snippets: Vec::new(),
582                        }),
583                    });
584                }
585
586                Ok(EffectResult::with_ui(event, ui_events))
587            }
588            Err(_) => Ok(EffectResult::event(PipelineEvent::FixAttemptCompleted {
589                pass,
590                changes_made: false,
591            })),
592        }
593    }
594
595    fn run_rebase(
596        &mut self,
597        _ctx: &mut PhaseContext<'_>,
598        phase: RebasePhase,
599        target_branch: String,
600    ) -> Result<EffectResult> {
601        use crate::git_helpers::{get_conflicted_files, rebase_onto};
602
603        match rebase_onto(&target_branch, _ctx.executor) {
604            Ok(_) => {
605                // Check for conflicts
606                let conflicted_files = get_conflicted_files().unwrap_or_default();
607
608                if !conflicted_files.is_empty() {
609                    let files = conflicted_files.into_iter().map(|s| s.into()).collect();
610
611                    Ok(EffectResult::event(PipelineEvent::RebaseConflictDetected {
612                        files,
613                    }))
614                } else {
615                    // Get current head for success case
616                    let new_head = match git2::Repository::open(".") {
617                        Ok(repo) => {
618                            match repo.head().ok().and_then(|head| head.peel_to_commit().ok()) {
619                                Some(commit) => commit.id().to_string(),
620                                None => "unknown".to_string(),
621                            }
622                        }
623                        Err(_) => "unknown".to_string(),
624                    };
625
626                    Ok(EffectResult::event(PipelineEvent::RebaseSucceeded {
627                        phase,
628                        new_head,
629                    }))
630                }
631            }
632            Err(e) => Ok(EffectResult::event(PipelineEvent::RebaseFailed {
633                phase,
634                reason: e.to_string(),
635            })),
636        }
637    }
638
639    fn resolve_rebase_conflicts(
640        &mut self,
641        _ctx: &mut PhaseContext<'_>,
642        strategy: ConflictStrategy,
643    ) -> Result<EffectResult> {
644        use crate::git_helpers::{abort_rebase, continue_rebase, get_conflicted_files};
645
646        match strategy {
647            ConflictStrategy::Continue => match continue_rebase(_ctx.executor) {
648                Ok(_) => {
649                    let files = get_conflicted_files()
650                        .unwrap_or_default()
651                        .into_iter()
652                        .map(|s| s.into())
653                        .collect();
654
655                    Ok(EffectResult::event(PipelineEvent::RebaseConflictResolved {
656                        files,
657                    }))
658                }
659                Err(e) => Ok(EffectResult::event(PipelineEvent::RebaseFailed {
660                    phase: RebasePhase::PostReview,
661                    reason: e.to_string(),
662                })),
663            },
664            ConflictStrategy::Abort => match abort_rebase(_ctx.executor) {
665                Ok(_) => {
666                    let restored_to = match git2::Repository::open(".") {
667                        Ok(repo) => {
668                            match repo.head().ok().and_then(|head| head.peel_to_commit().ok()) {
669                                Some(commit) => commit.id().to_string(),
670                                None => "HEAD".to_string(),
671                            }
672                        }
673                        Err(_) => "HEAD".to_string(),
674                    };
675
676                    Ok(EffectResult::event(PipelineEvent::RebaseAborted {
677                        phase: RebasePhase::PostReview,
678                        restored_to,
679                    }))
680                }
681                Err(e) => Ok(EffectResult::event(PipelineEvent::RebaseFailed {
682                    phase: RebasePhase::PostReview,
683                    reason: e.to_string(),
684                })),
685            },
686            ConflictStrategy::Skip => {
687                Ok(EffectResult::event(PipelineEvent::RebaseConflictResolved {
688                    files: Vec::new(),
689                }))
690            }
691        }
692    }
693
694    fn generate_commit_message(&mut self, ctx: &mut PhaseContext<'_>) -> Result<EffectResult> {
695        let attempt = match &self.state.commit {
696            crate::reducer::state::CommitState::Generating { attempt, .. } => *attempt,
697            _ => 1,
698        };
699
700        // Get git diff for commit message generation
701        let diff = crate::git_helpers::git_diff().unwrap_or_default();
702
703        // Check if diff is empty BEFORE attempting to generate commit message
704        // This prevents the "Empty diff provided to generate_commit_message" warning
705        if diff.trim().is_empty() {
706            ctx.logger
707                .info("No changes to commit (empty diff), skipping commit");
708            return Ok(EffectResult::event(PipelineEvent::CommitSkipped {
709                reason: "No changes to commit (empty diff)".to_string(),
710            }));
711        }
712
713        // Get commit agent first to avoid borrow conflicts
714        let commit_agent = get_primary_commit_agent(ctx).unwrap_or_else(|| "commit".to_string());
715
716        let mut runtime = PipelineRuntime {
717            timer: ctx.timer,
718            logger: ctx.logger,
719            colors: ctx.colors,
720            config: ctx.config,
721            executor: ctx.executor,
722            executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
723            workspace: ctx.workspace,
724        };
725
726        match commit::generate_commit_message(
727            &diff,
728            ctx.registry,
729            &mut runtime,
730            &commit_agent,
731            ctx.template_context,
732            ctx.workspace,
733            &ctx.prompt_history,
734        ) {
735            Ok(result) => {
736                let event = PipelineEvent::CommitMessageGenerated {
737                    message: result.message.clone(),
738                    attempt,
739                };
740
741                // Build UI events
742                let mut ui_events = vec![
743                    // Emit phase transition UI event
744                    self.phase_transition_ui(PipelinePhase::CommitMessage),
745                ];
746
747                // Try to read commit message XML for semantic rendering
748                if let Some(xml_content) = read_commit_message_xml(ctx.workspace) {
749                    ui_events.push(UIEvent::XmlOutput {
750                        xml_type: XmlOutputType::CommitMessage,
751                        content: xml_content,
752                        context: None,
753                    });
754                }
755
756                Ok(EffectResult::with_ui(event, ui_events))
757            }
758            Err(_) => Ok(EffectResult::event(PipelineEvent::CommitMessageGenerated {
759                message: "chore: automated commit".to_string(),
760                attempt,
761            })),
762        }
763    }
764
765    fn create_commit(
766        &mut self,
767        ctx: &mut PhaseContext<'_>,
768        message: String,
769    ) -> Result<EffectResult> {
770        use crate::git_helpers::{git_add_all, git_commit};
771
772        // Stage all changes
773        git_add_all()?;
774
775        // Create commit
776        match git_commit(&message, None, None, Some(ctx.executor)) {
777            Ok(Some(hash)) => Ok(EffectResult::event(PipelineEvent::CommitCreated {
778                hash: hash.to_string(),
779                message,
780            })),
781            Ok(None) => {
782                // No changes to commit - skip to FinalValidation instead of failing
783                // This prevents infinite loop when there are no changes
784                Ok(EffectResult::event(PipelineEvent::CommitSkipped {
785                    reason: "No changes to commit".to_string(),
786                }))
787            }
788            Err(e) => Ok(EffectResult::event(PipelineEvent::CommitGenerationFailed {
789                reason: e.to_string(),
790            })),
791        }
792    }
793
794    fn skip_commit(&mut self, _ctx: &mut PhaseContext<'_>, reason: String) -> Result<EffectResult> {
795        Ok(EffectResult::event(PipelineEvent::CommitSkipped { reason }))
796    }
797
798    fn validate_final_state(&mut self, _ctx: &mut PhaseContext<'_>) -> Result<EffectResult> {
799        // Transition to Finalizing phase to restore PROMPT.md permissions
800        // via the effect system before marking the pipeline complete
801        let event = PipelineEvent::FinalizingStarted;
802
803        // Emit phase transition UI event
804        let ui_event = self.phase_transition_ui(PipelinePhase::Finalizing);
805
806        Ok(EffectResult::with_ui(event, vec![ui_event]))
807    }
808
809    fn save_checkpoint(
810        &mut self,
811        ctx: &mut PhaseContext<'_>,
812        trigger: CheckpointTrigger,
813    ) -> Result<EffectResult> {
814        if ctx.config.features.checkpoint_enabled {
815            let _ = save_checkpoint_from_state(&self.state, ctx);
816        }
817
818        Ok(EffectResult::event(PipelineEvent::CheckpointSaved {
819            trigger,
820        }))
821    }
822
823    fn initialize_agent_chain(
824        &mut self,
825        ctx: &mut PhaseContext<'_>,
826        role: AgentRole,
827    ) -> Result<EffectResult> {
828        let agents = match role {
829            AgentRole::Developer => vec![ctx.developer_agent.to_string()],
830            AgentRole::Reviewer => vec![ctx.reviewer_agent.to_string()],
831            AgentRole::Commit => {
832                if let Some(commit_agent) = get_primary_commit_agent(ctx) {
833                    vec![commit_agent]
834                } else {
835                    vec![]
836                }
837            }
838        };
839
840        let _models_per_agent: Vec<Vec<String>> = agents.iter().map(|_| vec![]).collect();
841
842        let max_cycles = self.state.agent_chain.max_cycles;
843
844        ctx.logger.info(&format!(
845            "Initializing agent chain with {} cycles",
846            max_cycles
847        ));
848
849        let event = PipelineEvent::AgentChainInitialized { role, agents };
850
851        // Emit phase transition when entering a new major phase
852        let ui_events = match role {
853            AgentRole::Developer if self.state.phase == PipelinePhase::Planning => {
854                vec![UIEvent::PhaseTransition {
855                    from: None,
856                    to: PipelinePhase::Planning,
857                }]
858            }
859            AgentRole::Reviewer if self.state.phase == PipelinePhase::Review => {
860                vec![self.phase_transition_ui(PipelinePhase::Review)]
861            }
862            _ => vec![],
863        };
864
865        Ok(EffectResult::with_ui(event, ui_events))
866    }
867
868    fn cleanup_context(&mut self, ctx: &mut PhaseContext<'_>) -> Result<EffectResult> {
869        use std::path::Path;
870
871        ctx.logger
872            .info("Cleaning up context files to prevent pollution...");
873
874        let mut cleaned_count = 0;
875        let mut failed_count = 0;
876
877        // Delete PLAN.md via workspace
878        let plan_path = Path::new(".agent/PLAN.md");
879        if ctx.workspace.exists(plan_path) {
880            if let Err(err) = ctx.workspace.remove(plan_path) {
881                ctx.logger.warn(&format!("Failed to delete PLAN.md: {err}"));
882                failed_count += 1;
883            } else {
884                cleaned_count += 1;
885            }
886        }
887
888        // Delete ISSUES.md (may not exist if in isolation mode) via workspace
889        let issues_path = Path::new(".agent/ISSUES.md");
890        if ctx.workspace.exists(issues_path) {
891            if let Err(err) = ctx.workspace.remove(issues_path) {
892                ctx.logger
893                    .warn(&format!("Failed to delete ISSUES.md: {err}"));
894                failed_count += 1;
895            } else {
896                cleaned_count += 1;
897            }
898        }
899
900        // Delete ALL .xml files in .agent/tmp/ to prevent context pollution via workspace
901        let tmp_dir = Path::new(".agent/tmp");
902        if ctx.workspace.exists(tmp_dir) {
903            if let Ok(entries) = ctx.workspace.read_dir(tmp_dir) {
904                for entry in entries {
905                    let path = entry.path();
906                    if path.extension().and_then(|s| s.to_str()) == Some("xml") {
907                        if let Err(err) = ctx.workspace.remove(path) {
908                            ctx.logger.warn(&format!(
909                                "Failed to delete {}: {}",
910                                path.display(),
911                                err
912                            ));
913                            failed_count += 1;
914                        } else {
915                            cleaned_count += 1;
916                        }
917                    }
918                }
919            }
920        }
921
922        // Delete continuation context file (if present) via workspace
923        let _ = cleanup_continuation_context_file(ctx);
924
925        if cleaned_count > 0 {
926            ctx.logger.success(&format!(
927                "Context cleanup complete: {} files deleted{}",
928                cleaned_count,
929                if failed_count > 0 {
930                    format!(", {} failures", failed_count)
931                } else {
932                    String::new()
933                }
934            ));
935        } else {
936            ctx.logger.info("No context files to clean up");
937        }
938
939        Ok(EffectResult::event(PipelineEvent::ContextCleaned))
940    }
941
942    fn restore_prompt_permissions(&mut self, ctx: &mut PhaseContext<'_>) -> Result<EffectResult> {
943        use crate::files::make_prompt_writable_with_workspace;
944
945        ctx.logger.info("Restoring PROMPT.md write permissions...");
946
947        // Use workspace-based function for testability
948        if let Some(warning) = make_prompt_writable_with_workspace(ctx.workspace) {
949            ctx.logger.warn(&warning);
950        }
951
952        let event = PipelineEvent::PromptPermissionsRestored;
953
954        // Emit phase transition UI event to Complete
955        let ui_event = self.phase_transition_ui(PipelinePhase::Complete);
956
957        Ok(EffectResult::with_ui(event, vec![ui_event]))
958    }
959}
960
961fn collect_review_issue_snippets(
962    workspace: &dyn Workspace,
963    issues_xml: &str,
964) -> Vec<XmlCodeSnippet> {
965    let validated = match validate_issues_xml(issues_xml) {
966        Ok(v) => v,
967        Err(_) => return Vec::new(),
968    };
969
970    let mut snippets = Vec::new();
971    let mut seen = std::collections::BTreeSet::new();
972
973    for issue in validated.issues {
974        if let Some((file, issue_start, issue_end)) = parse_issue_location(&issue) {
975            if let Some(snippet) = read_snippet_for_issue(workspace, &file, issue_start, issue_end)
976            {
977                let key = (
978                    snippet.file.clone(),
979                    snippet.line_start,
980                    snippet.line_end,
981                    snippet.content.clone(),
982                );
983                if seen.insert(key) {
984                    snippets.push(snippet);
985                }
986            }
987        }
988    }
989
990    snippets
991}
992
993fn read_commit_message_xml(workspace: &dyn Workspace) -> Option<String> {
994    let primary_path = Path::new(xml_paths::COMMIT_MESSAGE_XML);
995    let primary_processed_path =
996        PathBuf::from(format!("{}.processed", xml_paths::COMMIT_MESSAGE_XML));
997    let legacy_path = Path::new(".agent/tmp/commit.xml");
998    let legacy_processed_path = Path::new(".agent/tmp/commit.xml.processed");
999
1000    workspace
1001        .read(primary_path)
1002        .ok()
1003        .or_else(|| workspace.read(&primary_processed_path).ok())
1004        .or_else(|| workspace.read(legacy_path).ok())
1005        .or_else(|| workspace.read(legacy_processed_path).ok())
1006}
1007
1008fn parse_issue_location(issue: &str) -> Option<(String, u32, u32)> {
1009    let location_re = Regex::new(
1010        r"(?m)(?P<file>[-_./A-Za-z0-9]+\.[A-Za-z0-9]+):(?P<start>\d+)(?:[-–—](?P<end>\d+))?(?::(?P<col>\d+))?",
1011    )
1012    .ok()?;
1013    let gh_location_re = Regex::new(
1014        r"(?m)(?P<file>[-_./A-Za-z0-9]+\.[A-Za-z0-9]+)#L(?P<start>\d+)(?:-L(?P<end>\d+))?",
1015    )
1016    .ok()?;
1017
1018    if let Some(cap) = location_re.captures(issue) {
1019        let file = cap.name("file")?.as_str().to_string();
1020        let start = cap.name("start")?.as_str().parse::<u32>().ok()?;
1021        let end = cap
1022            .name("end")
1023            .and_then(|m| m.as_str().parse::<u32>().ok())
1024            .unwrap_or(start);
1025        return Some((file, start, end));
1026    }
1027
1028    if let Some(cap) = gh_location_re.captures(issue) {
1029        let file = cap.name("file")?.as_str().to_string();
1030        let start = cap.name("start")?.as_str().parse::<u32>().ok()?;
1031        let end = cap
1032            .name("end")
1033            .and_then(|m| m.as_str().parse::<u32>().ok())
1034            .unwrap_or(start);
1035        return Some((file, start, end));
1036    }
1037
1038    None
1039}
1040
1041fn read_snippet_for_issue(
1042    workspace: &dyn Workspace,
1043    file: &str,
1044    issue_start: u32,
1045    issue_end: u32,
1046) -> Option<XmlCodeSnippet> {
1047    let issue_start = issue_start.max(1);
1048    let issue_end = issue_end.max(issue_start);
1049
1050    let context_lines: u32 = 2;
1051    let start = issue_start.saturating_sub(context_lines).max(1);
1052    let end = issue_end.saturating_add(context_lines);
1053
1054    let content = workspace.read(Path::new(file)).ok()?;
1055    let lines: Vec<&str> = content.lines().collect();
1056    if lines.is_empty() {
1057        return None;
1058    }
1059
1060    let max_line = u32::try_from(lines.len()).ok()?;
1061    let end = end.min(max_line);
1062    if start > end {
1063        return None;
1064    }
1065
1066    let mut snippet = String::new();
1067    for line_no in start..=end {
1068        let idx = usize::try_from(line_no.saturating_sub(1)).ok()?;
1069        let line = lines.get(idx).copied().unwrap_or_default();
1070        snippet.push_str(&format!("{:>4} | {}\n", line_no, line));
1071    }
1072
1073    Some(XmlCodeSnippet {
1074        file: file.to_string(),
1075        line_start: start,
1076        line_end: end,
1077        content: snippet,
1078    })
1079}
1080
1081fn cleanup_continuation_context_file(ctx: &mut PhaseContext<'_>) -> anyhow::Result<()> {
1082    let path = Path::new(".agent/tmp/continuation_context.md");
1083    if ctx.workspace.exists(path) {
1084        ctx.workspace.remove(path)?;
1085    }
1086    Ok(())
1087}
1088
1089fn write_continuation_context_file(
1090    ctx: &mut PhaseContext<'_>,
1091    iteration: u32,
1092    continuation_attempt: u32,
1093    attempt: &development::DevAttemptResult,
1094) -> anyhow::Result<()> {
1095    let tmp_dir = Path::new(".agent/tmp");
1096    if !ctx.workspace.exists(tmp_dir) {
1097        ctx.workspace.create_dir_all(tmp_dir)?;
1098    }
1099
1100    let mut content = String::new();
1101    content.push_str("# Development Continuation Context\n\n");
1102    content.push_str(&format!("- Iteration: {iteration}\n"));
1103    content.push_str(&format!("- Continuation attempt: {continuation_attempt}\n"));
1104    content.push_str(&format!("- Previous status: {}\n\n", attempt.status));
1105
1106    content.push_str("## Previous summary\n\n");
1107    content.push_str(&attempt.summary);
1108    content.push('\n');
1109
1110    if let Some(ref files) = attempt.files_changed {
1111        content.push_str("\n## Files changed\n\n");
1112        for file in files {
1113            content.push_str("- ");
1114            content.push_str(file);
1115            content.push('\n');
1116        }
1117    }
1118
1119    if let Some(ref next_steps) = attempt.next_steps {
1120        content.push_str("\n## Recommended next steps\n\n");
1121        content.push_str(next_steps);
1122        content.push('\n');
1123    }
1124
1125    content.push_str("\n## Reference files (do not modify)\n\n");
1126    content.push_str("- PROMPT.md\n");
1127    content.push_str("- .agent/PLAN.md\n");
1128
1129    ctx.workspace
1130        .write(Path::new(".agent/tmp/continuation_context.md"), &content)?;
1131
1132    Ok(())
1133}
1134
1135/// Save checkpoint from current pipeline state.
1136fn save_checkpoint_from_state(
1137    state: &PipelineState,
1138    ctx: &mut PhaseContext<'_>,
1139) -> anyhow::Result<()> {
1140    let builder = CheckpointBuilder::new()
1141        .phase(
1142            map_to_checkpoint_phase(state.phase),
1143            state.iteration,
1144            state.total_iterations,
1145        )
1146        .reviewer_pass(state.reviewer_pass, state.total_reviewer_passes)
1147        .capture_from_context(
1148            ctx.config,
1149            ctx.registry,
1150            ctx.developer_agent,
1151            ctx.reviewer_agent,
1152            ctx.logger,
1153            &ctx.run_context,
1154        )
1155        .with_executor_from_context(std::sync::Arc::clone(&ctx.executor_arc))
1156        .with_execution_history(ctx.execution_history.clone())
1157        .with_prompt_history(ctx.clone_prompt_history());
1158
1159    if let Some(checkpoint) = builder.build() {
1160        let _ = save_checkpoint_with_workspace(ctx.workspace, &checkpoint);
1161    }
1162
1163    Ok(())
1164}
1165
1166/// Map reducer phase to checkpoint phase.
1167fn map_to_checkpoint_phase(phase: crate::reducer::event::PipelinePhase) -> CheckpointPhase {
1168    match phase {
1169        crate::reducer::event::PipelinePhase::Planning => CheckpointPhase::Planning,
1170        crate::reducer::event::PipelinePhase::Development => CheckpointPhase::Development,
1171        crate::reducer::event::PipelinePhase::Review => CheckpointPhase::Review,
1172        crate::reducer::event::PipelinePhase::CommitMessage => CheckpointPhase::CommitMessage,
1173        crate::reducer::event::PipelinePhase::FinalValidation => CheckpointPhase::FinalValidation,
1174        crate::reducer::event::PipelinePhase::Finalizing => CheckpointPhase::FinalValidation,
1175        crate::reducer::event::PipelinePhase::Complete => CheckpointPhase::Complete,
1176        crate::reducer::event::PipelinePhase::Interrupted => CheckpointPhase::Interrupted,
1177    }
1178}
1179
1180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1181enum DevIterationNextStep {
1182    Completed,
1183    RetryInvalidOutput,
1184    Continue { next_continuation_attempt: u32 },
1185    Abort { next_continuation_attempt: u32 },
1186}
1187
1188fn decide_dev_iteration_next_step(
1189    continuation_attempt: u32,
1190    max_continuations: u32,
1191    attempt: &crate::phases::development::DevAttemptResult,
1192) -> DevIterationNextStep {
1193    if !attempt.output_valid {
1194        return DevIterationNextStep::RetryInvalidOutput;
1195    }
1196
1197    if attempt.output_valid
1198        && matches!(
1199            attempt.status,
1200            crate::reducer::state::DevelopmentStatus::Completed
1201        )
1202    {
1203        return DevIterationNextStep::Completed;
1204    }
1205
1206    let next_attempt = continuation_attempt + 1;
1207    // Config semantics: max_continuations counts *continuation attempts* beyond the initial
1208    // attempt (where continuation_attempt == 0). So next_attempt is allowed as long as it does
1209    // not exceed max_continuations.
1210    if next_attempt > max_continuations {
1211        DevIterationNextStep::Abort {
1212            next_continuation_attempt: next_attempt,
1213        }
1214    } else {
1215        DevIterationNextStep::Continue {
1216            next_continuation_attempt: next_attempt,
1217        }
1218    }
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223    use super::*;
1224
1225    /// Test that RestorePromptPermissions effect returns the correct event.
1226    ///
1227    /// The actual workspace interaction is tested via integration tests.
1228    /// This unit test verifies the mock handler returns the expected event.
1229    #[test]
1230    fn test_mock_handler_restore_prompt_permissions() {
1231        use crate::reducer::mock_effect_handler::MockEffectHandler;
1232
1233        let state = PipelineState::initial(1, 0);
1234        let mut handler = MockEffectHandler::new(state);
1235
1236        let result = handler.execute_mock(Effect::RestorePromptPermissions);
1237
1238        assert!(
1239            matches!(result.event, PipelineEvent::PromptPermissionsRestored),
1240            "RestorePromptPermissions effect should return PromptPermissionsRestored event"
1241        );
1242
1243        assert!(
1244            handler.was_effect_executed(|e| matches!(e, Effect::RestorePromptPermissions)),
1245            "Effect should be captured"
1246        );
1247    }
1248
1249    /// Test that ValidateFinalState transitions to Finalizing phase, not Complete.
1250    ///
1251    /// This ensures that the reducer goes through Finalizing phase to restore
1252    /// PROMPT.md permissions before marking the pipeline complete.
1253    #[test]
1254    fn test_mock_handler_validate_final_state_goes_to_finalizing() {
1255        use crate::reducer::mock_effect_handler::MockEffectHandler;
1256
1257        let state = PipelineState::initial(1, 0);
1258        let mut handler = MockEffectHandler::new(state);
1259
1260        let result = handler.execute_mock(Effect::ValidateFinalState);
1261
1262        assert!(
1263            matches!(result.event, PipelineEvent::FinalizingStarted),
1264            "ValidateFinalState should return FinalizingStarted to trigger finalization phase, got: {:?}",
1265            result.event
1266        );
1267    }
1268
1269    #[test]
1270    fn test_map_to_checkpoint_phase_interrupted_maps_to_interrupted() {
1271        use crate::reducer::event::PipelinePhase;
1272
1273        assert_eq!(
1274            map_to_checkpoint_phase(PipelinePhase::Interrupted),
1275            CheckpointPhase::Interrupted
1276        );
1277    }
1278
1279    /// Test that cleanup_context uses workspace for file operations.
1280    ///
1281    /// This verifies that cleanup_context:
1282    /// 1. Deletes PLAN.md via workspace
1283    /// 2. Deletes ISSUES.md via workspace  
1284    /// 3. Deletes .xml files in .agent/tmp/ via workspace
1285    #[test]
1286    fn test_cleanup_context_uses_workspace() {
1287        use crate::agents::AgentRegistry;
1288        use crate::checkpoint::{ExecutionHistory, RunContext};
1289        use crate::config::Config;
1290        use crate::executor::MockProcessExecutor;
1291        use crate::logger::{Colors, Logger};
1292        use crate::phases::context::PhaseContext;
1293        use crate::pipeline::{Stats, Timer};
1294        use crate::prompts::template_context::TemplateContext;
1295        use crate::workspace::{MemoryWorkspace, Workspace};
1296        use std::path::{Path, PathBuf};
1297
1298        // Create workspace with files that should be cleaned
1299        let workspace = MemoryWorkspace::new_test()
1300            .with_file(".agent/PLAN.md", "# Plan")
1301            .with_file(".agent/ISSUES.md", "# Issues")
1302            .with_dir(".agent/tmp")
1303            .with_file(".agent/tmp/issues.xml", "<issues/>")
1304            .with_file(".agent/tmp/development_result.xml", "<result/>")
1305            .with_file(".agent/tmp/keep.txt", "not xml");
1306
1307        // Set up all the context fields
1308        let config = Config::default();
1309        let registry = AgentRegistry::new().unwrap();
1310        let colors = Colors { enabled: false };
1311        let logger = Logger::new(colors);
1312        let mut timer = Timer::new();
1313        let mut stats = Stats::default();
1314        let template_context = TemplateContext::default();
1315        let executor_arc = std::sync::Arc::new(MockProcessExecutor::new())
1316            as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
1317        let repo_root = PathBuf::from("/test/repo");
1318
1319        let mut ctx = PhaseContext {
1320            config: &config,
1321            registry: &registry,
1322            logger: &logger,
1323            colors: &colors,
1324            timer: &mut timer,
1325            stats: &mut stats,
1326            developer_agent: "test-dev",
1327            reviewer_agent: "test-reviewer",
1328            review_guidelines: None,
1329            template_context: &template_context,
1330            run_context: RunContext::new(),
1331            execution_history: ExecutionHistory::new(),
1332            prompt_history: std::collections::HashMap::new(),
1333            executor: &*executor_arc,
1334            executor_arc: std::sync::Arc::clone(&executor_arc),
1335            repo_root: &repo_root,
1336            workspace: &workspace,
1337        };
1338
1339        // Create a real handler and call cleanup_context
1340        let state = PipelineState::initial(1, 0);
1341        let mut handler = super::MainEffectHandler::new(state);
1342        let result = handler.cleanup_context(&mut ctx);
1343
1344        assert!(result.is_ok(), "cleanup_context should succeed");
1345
1346        // Verify files were deleted via workspace
1347        assert!(
1348            !workspace.exists(Path::new(".agent/PLAN.md")),
1349            "PLAN.md should be deleted via workspace"
1350        );
1351        assert!(
1352            !workspace.exists(Path::new(".agent/ISSUES.md")),
1353            "ISSUES.md should be deleted via workspace"
1354        );
1355        assert!(
1356            !workspace.exists(Path::new(".agent/tmp/issues.xml")),
1357            "issues.xml should be deleted via workspace"
1358        );
1359        assert!(
1360            !workspace.exists(Path::new(".agent/tmp/development_result.xml")),
1361            "development_result.xml should be deleted via workspace"
1362        );
1363        // Non-xml files should remain
1364        assert!(
1365            workspace.exists(Path::new(".agent/tmp/keep.txt")),
1366            "non-xml file should not be deleted"
1367        );
1368    }
1369
1370    /// Test that save_checkpoint uses workspace for file operations.
1371    ///
1372    /// This verifies that save_checkpoint writes to .agent/checkpoint.json
1373    /// via the workspace abstraction, not std::fs.
1374    #[test]
1375    fn test_save_checkpoint_uses_workspace() {
1376        use crate::agents::AgentRegistry;
1377        use crate::checkpoint::{ExecutionHistory, RunContext};
1378        use crate::config::Config;
1379        use crate::executor::MockProcessExecutor;
1380        use crate::logger::{Colors, Logger};
1381        use crate::phases::context::PhaseContext;
1382        use crate::pipeline::{Stats, Timer};
1383        use crate::prompts::template_context::TemplateContext;
1384        use crate::workspace::{MemoryWorkspace, Workspace};
1385        use std::path::{Path, PathBuf};
1386
1387        // Create an empty workspace - no checkpoint should exist initially
1388        let workspace = MemoryWorkspace::new_test();
1389
1390        // Verify no checkpoint exists before
1391        assert!(
1392            !workspace.exists(Path::new(".agent/checkpoint.json")),
1393            "checkpoint should not exist initially"
1394        );
1395
1396        // Set up all the context fields - use real agent names from the registry
1397        let config = Config::default();
1398        let registry = AgentRegistry::new().unwrap();
1399        let colors = Colors { enabled: false };
1400        let logger = Logger::new(colors);
1401        let mut timer = Timer::new();
1402        let mut stats = Stats::default();
1403        let template_context = TemplateContext::default();
1404        let executor_arc = std::sync::Arc::new(MockProcessExecutor::new())
1405            as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
1406        let repo_root = PathBuf::from("/test/repo");
1407
1408        // Use "claude" which should exist in the default registry
1409        let developer_agent = "claude";
1410        let reviewer_agent = "claude";
1411
1412        let mut ctx = PhaseContext {
1413            config: &config,
1414            registry: &registry,
1415            logger: &logger,
1416            colors: &colors,
1417            timer: &mut timer,
1418            stats: &mut stats,
1419            developer_agent,
1420            reviewer_agent,
1421            review_guidelines: None,
1422            template_context: &template_context,
1423            run_context: RunContext::new(),
1424            execution_history: ExecutionHistory::new(),
1425            prompt_history: std::collections::HashMap::new(),
1426            executor: &*executor_arc,
1427            executor_arc: std::sync::Arc::clone(&executor_arc),
1428            repo_root: &repo_root,
1429            workspace: &workspace,
1430        };
1431
1432        // Create state and handler
1433        let state = PipelineState::initial(1, 0);
1434        let mut handler = super::MainEffectHandler::new(state);
1435
1436        // Execute save checkpoint effect
1437        let result = handler.save_checkpoint(&mut ctx, CheckpointTrigger::PhaseTransition);
1438
1439        assert!(result.is_ok(), "save_checkpoint should succeed");
1440
1441        // Verify checkpoint was written via workspace
1442        assert!(
1443            workspace.exists(Path::new(".agent/checkpoint.json")),
1444            "checkpoint should be written via workspace"
1445        );
1446
1447        // Verify the content is valid JSON
1448        let content = workspace.read(Path::new(".agent/checkpoint.json")).unwrap();
1449        assert!(
1450            content.contains("\"phase\""),
1451            "checkpoint should contain phase field"
1452        );
1453        assert!(
1454            content.contains("\"version\""),
1455            "checkpoint should contain version field"
1456        );
1457    }
1458
1459    #[test]
1460    fn test_read_commit_message_xml_falls_back_to_legacy_commit_xml() {
1461        use crate::workspace::MemoryWorkspace;
1462
1463        let workspace = MemoryWorkspace::new_test()
1464            .with_dir(".agent/tmp")
1465            .with_file(".agent/tmp/commit.xml", "<legacy/>");
1466
1467        let xml = read_commit_message_xml(&workspace).expect("expected xml");
1468        assert_eq!(xml, "<legacy/>");
1469    }
1470
1471    #[test]
1472    fn test_read_commit_message_xml_prefers_commit_message_xml() {
1473        use crate::workspace::MemoryWorkspace;
1474
1475        let workspace = MemoryWorkspace::new_test()
1476            .with_dir(".agent/tmp")
1477            .with_file(".agent/tmp/commit.xml", "<legacy/>")
1478            .with_file(".agent/tmp/commit_message.xml", "<preferred/>");
1479
1480        let xml = read_commit_message_xml(&workspace).expect("expected xml");
1481        assert_eq!(xml, "<preferred/>");
1482    }
1483
1484    #[test]
1485    fn test_invoke_agent_sanitizes_logfile_name() {
1486        use crate::agents::{AgentConfig, AgentRegistry, JsonParserType};
1487        use crate::checkpoint::{ExecutionHistory, RunContext};
1488        use crate::config::Config;
1489        use crate::executor::MockProcessExecutor;
1490        use crate::logger::{Colors, Logger};
1491        use crate::phases::context::PhaseContext;
1492        use crate::pipeline::{Stats, Timer};
1493        use crate::prompts::template_context::TemplateContext;
1494        use crate::reducer::state::AgentChainState;
1495        use crate::workspace::MemoryWorkspace;
1496        use std::path::PathBuf;
1497
1498        let mut registry = AgentRegistry::new().unwrap();
1499        registry.register(
1500            "ccs/glm",
1501            AgentConfig {
1502                cmd: "mock-glm-agent".to_string(),
1503                output_flag: String::new(),
1504                yolo_flag: String::new(),
1505                verbose_flag: String::new(),
1506                can_commit: true,
1507                json_parser: JsonParserType::Generic,
1508                model_flag: None,
1509                print_flag: String::new(),
1510                streaming_flag: String::new(),
1511                session_flag: String::new(),
1512                env_vars: std::collections::HashMap::new(),
1513                display_name: Some("mock".to_string()),
1514            },
1515        );
1516
1517        let colors = Colors { enabled: false };
1518        let logger = Logger::new(colors);
1519        let mut timer = Timer::new();
1520        let mut stats = Stats::default();
1521        let template_context = TemplateContext::default();
1522
1523        let mock_executor = std::sync::Arc::new(MockProcessExecutor::new().with_agent_result(
1524            "mock-glm-agent",
1525            Ok(crate::executor::AgentCommandResult::success()),
1526        ));
1527        let executor_arc =
1528            mock_executor.clone() as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
1529
1530        let workspace = MemoryWorkspace::new_test();
1531        let repo_root = PathBuf::from("/test/repo");
1532        let config = Config::default();
1533
1534        let mut ctx = PhaseContext {
1535            config: &config,
1536            registry: &registry,
1537            logger: &logger,
1538            colors: &colors,
1539            timer: &mut timer,
1540            stats: &mut stats,
1541            developer_agent: "ccs/glm",
1542            reviewer_agent: "ccs/glm",
1543            review_guidelines: None,
1544            template_context: &template_context,
1545            run_context: RunContext::new(),
1546            execution_history: ExecutionHistory::new(),
1547            prompt_history: std::collections::HashMap::new(),
1548            executor: executor_arc.as_ref(),
1549            executor_arc: executor_arc.clone(),
1550            repo_root: &repo_root,
1551            workspace: &workspace,
1552        };
1553
1554        let state = PipelineState {
1555            agent_chain: AgentChainState::initial().with_agents(
1556                vec!["ccs/glm".to_string()],
1557                vec![vec![]],
1558                AgentRole::Developer,
1559            ),
1560            ..PipelineState::initial(1, 0)
1561        };
1562
1563        let mut handler = super::MainEffectHandler::new(state);
1564        let _ = handler
1565            .invoke_agent(
1566                &mut ctx,
1567                AgentRole::Developer,
1568                "ccs/glm".to_string(),
1569                None,
1570                "prompt".to_string(),
1571            )
1572            .unwrap();
1573
1574        let calls = mock_executor.agent_calls_for("mock-glm-agent");
1575        assert_eq!(calls.len(), 1, "expected one agent spawn call");
1576        let logfile = &calls[0].logfile;
1577        assert!(
1578            !logfile.contains("ccs/glm"),
1579            "logfile should not contain raw agent name with slashes: {logfile}"
1580        );
1581        assert!(
1582            logfile.contains("ccs-glm"),
1583            "logfile should use sanitized agent name: {logfile}"
1584        );
1585    }
1586
1587    #[test]
1588    fn test_invoke_agent_applies_selected_model_to_command() {
1589        use crate::agents::{AgentConfig, AgentRegistry, JsonParserType};
1590        use crate::checkpoint::{ExecutionHistory, RunContext};
1591        use crate::config::Config;
1592        use crate::executor::MockProcessExecutor;
1593        use crate::logger::{Colors, Logger};
1594        use crate::phases::context::PhaseContext;
1595        use crate::pipeline::{Stats, Timer};
1596        use crate::prompts::template_context::TemplateContext;
1597        use crate::reducer::state::AgentChainState;
1598        use crate::workspace::MemoryWorkspace;
1599        use std::path::PathBuf;
1600
1601        let mut registry = AgentRegistry::new().unwrap();
1602        registry.register(
1603            "mock-agent",
1604            AgentConfig {
1605                cmd: "mock-agent-bin".to_string(),
1606                output_flag: String::new(),
1607                yolo_flag: String::new(),
1608                verbose_flag: String::new(),
1609                can_commit: true,
1610                json_parser: JsonParserType::Generic,
1611                model_flag: None,
1612                print_flag: String::new(),
1613                streaming_flag: String::new(),
1614                session_flag: String::new(),
1615                env_vars: std::collections::HashMap::new(),
1616                display_name: Some("mock".to_string()),
1617            },
1618        );
1619
1620        let colors = Colors { enabled: false };
1621        let logger = Logger::new(colors);
1622        let mut timer = Timer::new();
1623        let mut stats = Stats::default();
1624        let template_context = TemplateContext::default();
1625
1626        let mock_executor = std::sync::Arc::new(MockProcessExecutor::new().with_agent_result(
1627            "mock-agent-bin",
1628            Ok(crate::executor::AgentCommandResult::success()),
1629        ));
1630        let executor_arc =
1631            mock_executor.clone() as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
1632
1633        let workspace = MemoryWorkspace::new_test();
1634        let repo_root = PathBuf::from("/test/repo");
1635        let config = Config::default();
1636
1637        let mut ctx = PhaseContext {
1638            config: &config,
1639            registry: &registry,
1640            logger: &logger,
1641            colors: &colors,
1642            timer: &mut timer,
1643            stats: &mut stats,
1644            developer_agent: "mock-agent",
1645            reviewer_agent: "mock-agent",
1646            review_guidelines: None,
1647            template_context: &template_context,
1648            run_context: RunContext::new(),
1649            execution_history: ExecutionHistory::new(),
1650            prompt_history: std::collections::HashMap::new(),
1651            executor: executor_arc.as_ref(),
1652            executor_arc: executor_arc.clone(),
1653            repo_root: &repo_root,
1654            workspace: &workspace,
1655        };
1656
1657        let selected_model = "-m openai/gpt-5.2".to_string();
1658        let state = PipelineState {
1659            agent_chain: AgentChainState::initial().with_agents(
1660                vec!["mock-agent".to_string()],
1661                vec![vec![selected_model.clone()]],
1662                AgentRole::Developer,
1663            ),
1664            ..PipelineState::initial(1, 0)
1665        };
1666
1667        let mut handler = super::MainEffectHandler::new(state);
1668        let _ = handler
1669            .invoke_agent(
1670                &mut ctx,
1671                AgentRole::Developer,
1672                "mock-agent".to_string(),
1673                None,
1674                "prompt".to_string(),
1675            )
1676            .unwrap();
1677
1678        let calls = mock_executor.agent_calls_for("mock-agent-bin");
1679        assert_eq!(calls.len(), 1, "expected one agent spawn call");
1680        assert!(
1681            calls[0].args.iter().any(|a| a == "-m"
1682                || a == "-m=openai/gpt-5.2"
1683                || a.contains("openai/gpt-5.2")
1684                || a == &selected_model),
1685            "expected selected model to be threaded into agent command args; args={:?}",
1686            calls[0].args
1687        );
1688    }
1689
1690    #[test]
1691    fn test_invoke_agent_does_not_override_new_prompt_with_stale_rate_limit_prompt() {
1692        use crate::agents::{AgentConfig, AgentRegistry, JsonParserType};
1693        use crate::checkpoint::{ExecutionHistory, RunContext};
1694        use crate::config::Config;
1695        use crate::executor::MockProcessExecutor;
1696        use crate::logger::{Colors, Logger};
1697        use crate::phases::context::PhaseContext;
1698        use crate::pipeline::{Stats, Timer};
1699        use crate::prompts::template_context::TemplateContext;
1700        use crate::reducer::state::AgentChainState;
1701        use crate::workspace::MemoryWorkspace;
1702        use std::path::PathBuf;
1703
1704        let mut registry = AgentRegistry::new().unwrap();
1705        registry.register(
1706            "mock-agent",
1707            AgentConfig {
1708                cmd: "mock-agent-bin".to_string(),
1709                output_flag: String::new(),
1710                yolo_flag: String::new(),
1711                verbose_flag: String::new(),
1712                can_commit: true,
1713                json_parser: JsonParserType::Generic,
1714                model_flag: None,
1715                print_flag: String::new(),
1716                streaming_flag: String::new(),
1717                session_flag: String::new(),
1718                env_vars: std::collections::HashMap::new(),
1719                display_name: Some("mock".to_string()),
1720            },
1721        );
1722
1723        let colors = Colors { enabled: false };
1724        let logger = Logger::new(colors);
1725        let mut timer = Timer::new();
1726        let mut stats = Stats::default();
1727        let template_context = TemplateContext::default();
1728
1729        let mock_executor = std::sync::Arc::new(MockProcessExecutor::new().with_agent_result(
1730            "mock-agent-bin",
1731            Ok(crate::executor::AgentCommandResult::success()),
1732        ));
1733        let executor_arc =
1734            mock_executor.clone() as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
1735
1736        let workspace = MemoryWorkspace::new_test();
1737        let repo_root = PathBuf::from("/test/repo");
1738        let config = Config::default();
1739
1740        let mut ctx = PhaseContext {
1741            config: &config,
1742            registry: &registry,
1743            logger: &logger,
1744            colors: &colors,
1745            timer: &mut timer,
1746            stats: &mut stats,
1747            developer_agent: "mock-agent",
1748            reviewer_agent: "mock-agent",
1749            review_guidelines: None,
1750            template_context: &template_context,
1751            run_context: RunContext::new(),
1752            execution_history: ExecutionHistory::new(),
1753            prompt_history: std::collections::HashMap::new(),
1754            executor: executor_arc.as_ref(),
1755            executor_arc: executor_arc.clone(),
1756            repo_root: &repo_root,
1757            workspace: &workspace,
1758        };
1759
1760        let mut state = PipelineState {
1761            agent_chain: AgentChainState::initial().with_agents(
1762                vec!["mock-agent".to_string()],
1763                vec![vec![]],
1764                AgentRole::Developer,
1765            ),
1766            ..PipelineState::initial(1, 0)
1767        };
1768        state.agent_chain.rate_limit_continuation_prompt = Some("stale".to_string());
1769
1770        let mut handler = super::MainEffectHandler::new(state);
1771        let _ = handler
1772            .invoke_agent(
1773                &mut ctx,
1774                AgentRole::Developer,
1775                "mock-agent".to_string(),
1776                None,
1777                "fresh".to_string(),
1778            )
1779            .unwrap();
1780
1781        let calls = mock_executor.agent_calls_for("mock-agent-bin");
1782        assert_eq!(calls.len(), 1, "expected one agent spawn call");
1783        assert_eq!(
1784            calls[0].prompt,
1785            "fresh",
1786            "invoke_agent should not override a new prompt with a stale rate_limit_continuation_prompt"
1787        );
1788    }
1789
1790    #[test]
1791    fn test_decide_dev_iteration_next_step_invalid_output_does_not_consume_continuation_budget() {
1792        use crate::phases::development::DevAttemptResult;
1793        use crate::reducer::state::DevelopmentStatus;
1794
1795        let attempt = DevAttemptResult {
1796            had_error: true,
1797            output_valid: false,
1798            status: DevelopmentStatus::Failed,
1799            summary: "invalid xml".to_string(),
1800            files_changed: None,
1801            next_steps: None,
1802        };
1803
1804        let next = decide_dev_iteration_next_step(0, 2, &attempt);
1805
1806        assert_eq!(next, DevIterationNextStep::RetryInvalidOutput);
1807    }
1808
1809    #[test]
1810    fn test_decide_dev_iteration_next_step_partial_consumes_continuation_budget() {
1811        use crate::phases::development::DevAttemptResult;
1812        use crate::reducer::state::DevelopmentStatus;
1813
1814        let attempt = DevAttemptResult {
1815            had_error: false,
1816            output_valid: true,
1817            status: DevelopmentStatus::Partial,
1818            summary: "partial".to_string(),
1819            files_changed: None,
1820            next_steps: None,
1821        };
1822
1823        let next = decide_dev_iteration_next_step(0, 2, &attempt);
1824
1825        assert_eq!(
1826            next,
1827            DevIterationNextStep::Continue {
1828                next_continuation_attempt: 1
1829            }
1830        );
1831    }
1832
1833    #[test]
1834    fn test_decide_dev_iteration_next_step_partial_allows_max_continuations() {
1835        use crate::phases::development::DevAttemptResult;
1836        use crate::reducer::state::DevelopmentStatus;
1837
1838        let attempt = DevAttemptResult {
1839            had_error: false,
1840            output_valid: true,
1841            status: DevelopmentStatus::Partial,
1842            summary: "partial".to_string(),
1843            files_changed: None,
1844            next_steps: None,
1845        };
1846
1847        let next = decide_dev_iteration_next_step(1, 2, &attempt);
1848
1849        assert_eq!(
1850            next,
1851            DevIterationNextStep::Continue {
1852                next_continuation_attempt: 2
1853            }
1854        );
1855    }
1856
1857    #[test]
1858    fn test_decide_dev_iteration_next_step_partial_aborts_when_next_exceeds_max_continuations() {
1859        use crate::phases::development::DevAttemptResult;
1860        use crate::reducer::state::DevelopmentStatus;
1861
1862        let attempt = DevAttemptResult {
1863            had_error: false,
1864            output_valid: true,
1865            status: DevelopmentStatus::Partial,
1866            summary: "partial".to_string(),
1867            files_changed: None,
1868            next_steps: None,
1869        };
1870
1871        let next = decide_dev_iteration_next_step(2, 2, &attempt);
1872
1873        assert_eq!(
1874            next,
1875            DevIterationNextStep::Abort {
1876                next_continuation_attempt: 3
1877            }
1878        );
1879    }
1880}