Skip to main content

ralph_workflow/reducer/handler/
mod.rs

1//! Effect handler implementation for pipeline side effects.
2//!
3//! This module implements the [`EffectHandler`] trait to execute pipeline effects
4//! through the reducer architecture. Effect handlers perform actual work (agent
5//! invocation, git operations, file I/O) and emit events that drive state transitions.
6//!
7//! # Architecture Contract
8//!
9//! ```text
10//! State → Orchestrator → Effect → Handler → Event → Reducer → State
11//!                                  ^^^^^^^
12//!                                  Impure execution (this module)
13//! ```
14//!
15//! ## Handler Responsibilities
16//!
17//! - **Execute effects**: Perform the I/O operation specified by the effect
18//! - **Report outcomes**: Emit events describing what happened (success/failure)
19//! - **Use workspace abstraction**: All filesystem access via `ctx.workspace`
20//! - **Single-task execution**: Execute exactly one effect, no hidden retry logic
21//!
22//! ## Reducer Responsibilities (NOT handler)
23//!
24//! - **Pure state transitions**: Process events to update state
25//! - **Policy decisions**: Retry, fallback, phase progression
26//! - **Control flow**: Determine what happens next based on events
27//!
28//! # Key Principle: Handlers Report, Reducers Decide
29//!
30//! Handlers must NOT contain decision logic. Examples:
31//!
32//! ```ignore
33//! // WRONG - Handler decides to retry
34//! fn handle_invoke_agent() -> Result<EffectResult> {
35//!     for attempt in 0..3 {  // NO! Reducer controls retry
36//!         if let Ok(output) = invoke_agent() {
37//!             return Ok(output);
38//!         }
39//!     }
40//! }
41//!
42//! // CORRECT - Handler reports outcome, reducer decides
43//! fn handle_invoke_agent() -> Result<EffectResult> {
44//!     match invoke_agent() {
45//!         Ok(output) => Ok(EffectResult::event(
46//!             AgentEvent::InvocationSucceeded { output }
47//!         )),
48//!         Err(e) => Ok(EffectResult::event(
49//!             AgentEvent::InvocationFailed { error: e, retriable: true }
50//!         )),
51//!     }
52//! }
53//! ```
54//!
55//! The reducer processes `InvocationFailed` and decides whether to retry
56//! (increment retry count, emit retry effect) or fallback (advance chain).
57//!
58//! # Workspace Abstraction
59//!
60//! All filesystem operations MUST use `ctx.workspace`:
61//!
62//! ```ignore
63//! // CORRECT
64//! ctx.workspace.write(path, content)?;
65//! let content = ctx.workspace.read(path)?;
66//!
67//! // WRONG - Never use std::fs in handlers
68//! std::fs::write(path, content)?;
69//! ```
70//!
71//! This abstraction enables:
72//! - In-memory testing with `MemoryWorkspace`
73//! - Proper error handling and path resolution
74//! - Consistent file operations across the pipeline
75//!
76//! See [`docs/agents/workspace-trait.md`] for details.
77//!
78//! # Testing Handlers
79//!
80//! Handlers require mocks for I/O (workspace) but NOT for reducer/orchestration:
81//!
82//! ```ignore
83//! #[test]
84//! fn test_invoke_agent_emits_success_event() {
85//!     let workspace = MemoryWorkspace::new_test();
86//!     let mut ctx = create_test_context(&workspace);
87//!
88//!     let result = handler.execute(
89//!         Effect::InvokeAgent { role, agent, prompt },
90//!         &mut ctx
91//!     )?;
92//!
93//!     assert!(matches!(
94//!         result.event,
95//!         PipelineEvent::Agent(AgentEvent::InvocationSucceeded { .. })
96//!     ));
97//! }
98//! ```
99//!
100//! # Module Organization
101//!
102//! - [`agent`] - Agent invocation and chain management
103//! - [`planning`] - Planning phase effects (prompt, XML, validation)
104//! - [`development`] - Development phase effects (iteration, continuation)
105//! - [`review`] - Review phase effects (issue detection, fix application)
106//! - [`commit`] - Commit phase effects (message generation, commit creation)
107//! - [`rebase`] - Rebase effects (conflict resolution, validation)
108//! - [`checkpoint`] - Checkpoint save/restore
109//! - [`context`] - Context preparation and cleanup
110//!
111//! [`docs/agents/workspace-trait.md`]: https://codeberg.org/mistlight/RalphWithReviewer/src/branch/main/docs/agents/workspace-trait.md
112
113mod agent;
114mod analysis;
115mod chain;
116mod checkpoint;
117mod cloud;
118mod commit;
119mod context;
120mod development;
121mod lifecycle;
122mod planning;
123mod rebase;
124mod retry_guidance;
125mod review;
126
127#[cfg(test)]
128mod tests;
129
130use crate::phases::PhaseContext;
131use crate::reducer::effect::{Effect, EffectHandler, EffectResult};
132use crate::reducer::event::{PipelineEvent, PipelinePhase};
133use crate::reducer::state::PipelineState;
134use crate::reducer::ui_event::UIEvent;
135use anyhow::Result;
136
137/// Main effect handler implementation.
138///
139/// This handler executes effects by calling pipeline subsystems and emitting reducer events.
140pub struct MainEffectHandler {
141    /// Current pipeline state
142    pub state: PipelineState,
143    /// Event log for replay/debugging
144    pub event_log: Vec<PipelineEvent>,
145}
146
147impl MainEffectHandler {
148    /// Create a new effect handler.
149    #[must_use]
150    pub const fn new(state: PipelineState) -> Self {
151        Self {
152            state,
153            event_log: Vec::new(),
154        }
155    }
156}
157
158impl EffectHandler<'_> for MainEffectHandler {
159    fn execute(&mut self, effect: Effect, ctx: &mut PhaseContext<'_>) -> Result<EffectResult> {
160        let result = self.execute_effect(effect, ctx)?;
161        self.event_log.push(result.event.clone());
162        self.event_log
163            .extend(result.additional_events.iter().cloned());
164        Ok(result)
165    }
166}
167
168impl crate::app::event_loop::StatefulHandler for MainEffectHandler {
169    fn update_state(&mut self, state: PipelineState) {
170        self.state = state;
171    }
172}
173
174impl MainEffectHandler {
175    /// Helper to create phase transition UI event.
176    const fn phase_transition_ui(&self, to: PipelinePhase) -> UIEvent {
177        UIEvent::PhaseTransition {
178            from: Some(self.state.phase),
179            to,
180        }
181    }
182
183    fn write_completion_marker(
184        ctx: &PhaseContext<'_>,
185        content: &str,
186        is_failure: bool,
187    ) -> std::result::Result<(), String> {
188        let marker_dir = std::path::Path::new(".agent/tmp");
189        if let Err(err) = ctx.workspace.create_dir_all(marker_dir) {
190            ctx.logger.warn(&format!(
191                "Failed to create completion marker directory: {err}"
192            ));
193        }
194
195        let marker_path = std::path::Path::new(".agent/tmp/completion_marker");
196        match ctx.workspace.write(marker_path, content) {
197            Ok(()) => {
198                ctx.logger.info(&format!(
199                    "Completion marker written: {}",
200                    if is_failure { "failure" } else { "success" }
201                ));
202                Ok(())
203            }
204            Err(err) => {
205                ctx.logger
206                    .warn(&format!("Failed to write completion marker: {err}"));
207                Err(err.to_string())
208            }
209        }
210    }
211
212    fn execute_effect(
213        &mut self,
214        effect: Effect,
215        ctx: &mut PhaseContext<'_>,
216    ) -> Result<EffectResult> {
217        match effect {
218            Effect::AgentInvocation {
219                role,
220                agent,
221                model,
222                prompt,
223            } => self.invoke_agent(
224                ctx,
225                crate::agents::AgentDrain::from(role),
226                role,
227                &agent,
228                model.as_deref(),
229                prompt,
230            ),
231
232            Effect::InitializeAgentChain { drain, .. } => {
233                Ok(self.initialize_agent_chain(ctx, drain))
234            }
235
236            Effect::PreparePlanningPrompt {
237                iteration,
238                prompt_mode,
239            } => self.prepare_planning_prompt(ctx, iteration, prompt_mode),
240
241            Effect::MaterializePlanningInputs { iteration } => {
242                self.materialize_planning_inputs(ctx, iteration)
243            }
244
245            Effect::CleanupRequiredFiles { files } => Ok(self.cleanup_required_files(ctx, &files)),
246
247            Effect::InvokePlanningAgent { iteration } => self.invoke_planning_agent(ctx, iteration),
248
249            Effect::ExtractPlanningXml { iteration } => {
250                Ok(self.extract_planning_xml(ctx, iteration))
251            }
252
253            Effect::ValidatePlanningXml { iteration } => self.validate_planning_xml(ctx, iteration),
254
255            Effect::WritePlanningMarkdown { iteration } => {
256                self.write_planning_markdown(ctx, iteration)
257            }
258
259            Effect::ArchivePlanningXml { iteration } => {
260                Ok(Self::archive_planning_xml(ctx, iteration))
261            }
262
263            Effect::ApplyPlanningOutcome { iteration, valid } => {
264                Ok(self.apply_planning_outcome(ctx, iteration, valid))
265            }
266
267            Effect::PrepareDevelopmentContext { iteration } => {
268                Ok(Self::prepare_development_context(ctx, iteration))
269            }
270
271            Effect::MaterializeDevelopmentInputs { iteration } => {
272                self.materialize_development_inputs(ctx, iteration)
273            }
274
275            Effect::PrepareDevelopmentPrompt {
276                iteration,
277                prompt_mode,
278            } => self.prepare_development_prompt(ctx, iteration, prompt_mode),
279
280            Effect::InvokeDevelopmentAgent { iteration } => {
281                self.invoke_development_agent(ctx, iteration)
282            }
283
284            Effect::InvokeAnalysisAgent { iteration } => self.invoke_analysis_agent(ctx, iteration),
285
286            Effect::ExtractDevelopmentXml { iteration } => {
287                Ok(self.extract_development_xml(ctx, iteration))
288            }
289
290            Effect::ValidateDevelopmentXml { iteration } => {
291                Ok(self.validate_development_xml(ctx, iteration))
292            }
293
294            Effect::ApplyDevelopmentOutcome { iteration } => {
295                self.apply_development_outcome(ctx, iteration)
296            }
297
298            Effect::ArchiveDevelopmentXml { iteration } => {
299                Ok(Self::archive_development_xml(ctx, iteration))
300            }
301
302            Effect::PrepareReviewContext { pass } => Ok(self.prepare_review_context(ctx, pass)),
303
304            Effect::MaterializeReviewInputs { pass } => self.materialize_review_inputs(ctx, pass),
305
306            Effect::PrepareReviewPrompt { pass, prompt_mode } => {
307                self.prepare_review_prompt(ctx, pass, prompt_mode)
308            }
309
310            Effect::InvokeReviewAgent { pass } => self.invoke_review_agent(ctx, pass),
311
312            Effect::ExtractReviewIssuesXml { pass } => {
313                Ok(self.extract_review_issues_xml(ctx, pass))
314            }
315
316            Effect::ValidateReviewIssuesXml { pass } => {
317                Ok(self.validate_review_issues_xml(ctx, pass))
318            }
319
320            Effect::WriteIssuesMarkdown { pass } => self.write_issues_markdown(ctx, pass),
321
322            Effect::ExtractReviewIssueSnippets { pass } => {
323                self.extract_review_issue_snippets(ctx, pass)
324            }
325
326            Effect::ArchiveReviewIssuesXml { pass } => {
327                Ok(Self::archive_review_issues_xml(ctx, pass))
328            }
329
330            Effect::ApplyReviewOutcome {
331                pass,
332                issues_found,
333                clean_no_issues,
334            } => Ok(Self::apply_review_outcome(
335                ctx,
336                pass,
337                issues_found,
338                clean_no_issues,
339            )),
340
341            Effect::PrepareFixPrompt { pass, prompt_mode } => {
342                self.prepare_fix_prompt(ctx, pass, prompt_mode)
343            }
344
345            Effect::InvokeFixAgent { pass } => self.invoke_fix_agent(ctx, pass),
346
347            Effect::InvokeFixAnalysisAgent { pass } => self.invoke_fix_analysis_agent(ctx, pass),
348
349            Effect::ExtractFixResultXml { pass } => Ok(self.extract_fix_result_xml(ctx, pass)),
350
351            Effect::ValidateFixResultXml { pass } => Ok(self.validate_fix_result_xml(ctx, pass)),
352
353            Effect::ApplyFixOutcome { pass } => self.apply_fix_outcome(ctx, pass),
354
355            Effect::ArchiveFixResultXml { pass } => Ok(self.archive_fix_result_xml(ctx, pass)),
356
357            Effect::RunRebase {
358                phase,
359                target_branch,
360            } => self.run_rebase(ctx, phase, &target_branch),
361
362            Effect::ResolveRebaseConflicts { strategy } => {
363                Ok(Self::resolve_rebase_conflicts(ctx, strategy))
364            }
365
366            Effect::PrepareCommitPrompt { prompt_mode } => {
367                self.prepare_commit_prompt(ctx, prompt_mode)
368            }
369
370            Effect::CheckCommitDiff => Self::check_commit_diff(ctx),
371
372            Effect::MaterializeCommitInputs { attempt } => {
373                self.materialize_commit_inputs(ctx, attempt)
374            }
375
376            Effect::InvokeCommitAgent => self.invoke_commit_agent(ctx),
377
378            Effect::ExtractCommitXml => Ok(self.extract_commit_xml(ctx)),
379
380            Effect::ValidateCommitXml => Ok(self.validate_commit_xml(ctx)),
381
382            Effect::ApplyCommitMessageOutcome => self.apply_commit_message_outcome(ctx),
383
384            Effect::ArchiveCommitXml => Ok(self.archive_commit_xml(ctx)),
385
386            Effect::CreateCommit {
387                message,
388                files,
389                excluded_files,
390            } => Self::create_commit(ctx, message, &files, &excluded_files),
391
392            Effect::SkipCommit { reason } => Ok(Self::skip_commit(ctx, reason)),
393
394            Effect::CheckResidualFiles { pass } => Self::check_residual_files(ctx, pass),
395
396            Effect::CheckUncommittedChangesBeforeTermination => {
397                Self::check_uncommitted_changes_before_termination(ctx)
398            }
399
400            Effect::BackoffWait {
401                role,
402                cycle,
403                duration_ms,
404            } => {
405                use std::time::Duration;
406                ctx.registry
407                    .retry_timer()
408                    .sleep(Duration::from_millis(duration_ms));
409                Ok(EffectResult::event(
410                    PipelineEvent::agent_retry_cycle_started(role, cycle),
411                ))
412            }
413
414            Effect::ReportAgentChainExhausted { role, phase, cycle } => {
415                use crate::reducer::event::ErrorEvent;
416                Err(ErrorEvent::AgentChainExhausted { role, phase, cycle }.into())
417            }
418
419            Effect::ValidateFinalState => Ok(self.validate_final_state(ctx)),
420
421            Effect::SaveCheckpoint { trigger } => Ok(self.save_checkpoint(ctx, trigger)),
422
423            Effect::EnsureGitignoreEntries => Ok(Self::ensure_gitignore_entries(ctx)),
424
425            Effect::CleanupContext => Self::cleanup_context(ctx),
426
427            Effect::LockPromptPermissions => Ok(Self::lock_prompt_permissions(ctx)),
428
429            Effect::RestorePromptPermissions => Ok(self.restore_prompt_permissions(ctx)),
430
431            Effect::WriteContinuationContext(ref data) => {
432                development::write_continuation_context_to_workspace(
433                    ctx.workspace,
434                    ctx.logger,
435                    data,
436                )?;
437                Ok(EffectResult::event(
438                    PipelineEvent::development_continuation_context_written(
439                        data.iteration,
440                        data.attempt,
441                    ),
442                ))
443            }
444
445            Effect::CleanupContinuationContext => Self::cleanup_continuation_context(ctx),
446
447            Effect::WriteTimeoutContext {
448                role,
449                logfile_path,
450                context_path,
451            } => Self::write_timeout_context(ctx, role, &logfile_path, &context_path),
452
453            Effect::TriggerLoopRecovery {
454                detected_loop,
455                loop_count,
456            } => Ok(Self::trigger_loop_recovery(ctx, &detected_loop, loop_count)),
457
458            Effect::EmitRecoveryReset {
459                reset_type,
460                target_phase,
461            } => Ok(self.emit_recovery_reset(ctx, &reset_type, target_phase)),
462
463            Effect::AttemptRecovery {
464                level,
465                attempt_count,
466            } => Ok(self.attempt_recovery(ctx, level, attempt_count)),
467
468            Effect::EmitRecoverySuccess {
469                level,
470                total_attempts,
471            } => Ok(Self::emit_recovery_success(ctx, level, total_attempts)),
472
473            Effect::TriggerDevFixFlow {
474                failed_phase,
475                failed_role,
476                retry_cycle,
477            } => Ok(self.trigger_dev_fix_flow(ctx, failed_phase, failed_role, retry_cycle)),
478
479            Effect::EmitCompletionMarkerAndTerminate { is_failure, reason } => Ok(
480                Self::emit_completion_marker_and_terminate(ctx, is_failure, reason),
481            ),
482
483            // Cloud mode effects - only executed when cloud mode is enabled
484            Effect::ConfigureGitAuth { auth_method } => {
485                Ok(Self::handle_configure_git_auth(ctx, &auth_method))
486            }
487
488            Effect::PushToRemote {
489                remote,
490                branch,
491                force,
492                commit_sha,
493            } => Ok(Self::handle_push_to_remote(
494                ctx, remote, branch, force, commit_sha,
495            )),
496
497            Effect::CreatePullRequest {
498                base_branch,
499                head_branch,
500                title,
501                body,
502            } => Ok(Self::handle_create_pull_request(
503                ctx,
504                &base_branch,
505                &head_branch,
506                &title,
507                &body,
508            )),
509        }
510    }
511}