ralph_workflow/reducer/event/error.rs
1//! Error events for recoverable and unrecoverable failures.
2//!
3//! This module implements the error event pattern where ALL errors from effect handlers
4//! are represented as typed events that flow through the reducer, enabling the reducer
5//! to decide recovery strategy based on semantic meaning.
6//!
7//! # Error Handling Architecture
8//!
9//! ## The Pattern
10//!
11//! 1. **Effect handler encounters error**
12//! ```rust,ignore
13//! return Err(ErrorEvent::ReviewInputsNotMaterialized { pass }.into());
14//! ```
15//!
16//! 2. **Event loop extracts error event**
17//! The event loop catches `Err()`, downcasts to `ErrorEvent`, and re-emits it as
18//! `PipelineEvent::PromptInput(PromptInputEvent::HandlerError { ... })` so the
19//! reducer can decide recovery strategy without adding new top-level `PipelineEvent`
20//! variants.
21//!
22//! 3. **Reducer decides recovery strategy**
23//! The reducer processes the error identically to other events (it is still routed
24//! through the main `reduce` function), deciding whether to retry, fallback, skip,
25//! or terminate based on the specific error variant.
26//!
27//! 4. **Event loop acts on reducer decision**
28//! If the reducer transitions to Interrupted phase, the event loop terminates.
29//! Otherwise, execution continues with the next effect (e.g., by clearing a
30//! "prepared" flag to force re-materialization after a checkpoint resume).
31//!
32//! ## Why Not String Errors?
33//!
34//! String errors (`Err(anyhow::anyhow!("..."))`) would bypass the reducer and prevent
35//! recovery logic. The reducer cannot distinguish between "missing optional file"
36//! (use fallback) and "permission denied" (abort pipeline) when errors are strings.
37//!
38//! ## Current Error Categories
39//!
40//! All current error events represent **invariant violations** (effect sequencing bugs,
41//! continuation mode misuse) or **terminal conditions** (agent chain exhaustion). These
42//! terminate the pipeline because they indicate bugs in the orchestration logic or
43//! exhaustion of all retry attempts.
44//!
45//! Future error events for recoverable conditions (network timeouts, transient file I/O)
46//! will implement retry/fallback strategies in the reducer.
47
48use serde::{Deserialize, Serialize};
49
50/// Serializable subset of `std::io::ErrorKind`.
51///
52/// `std::io::Error` / `ErrorKind` are not serde-serializable, but reducer error events
53/// must be persisted in checkpoints. This enum captures the subset of error kinds we
54/// need for recovery policy decisions.
55#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq)]
56pub enum WorkspaceIoErrorKind {
57 NotFound,
58 PermissionDenied,
59 AlreadyExists,
60 InvalidData,
61 Other,
62}
63
64impl WorkspaceIoErrorKind {
65 #[must_use]
66 pub const fn from_io_error_kind(kind: std::io::ErrorKind) -> Self {
67 match kind {
68 std::io::ErrorKind::NotFound => Self::NotFound,
69 std::io::ErrorKind::PermissionDenied => Self::PermissionDenied,
70 std::io::ErrorKind::AlreadyExists => Self::AlreadyExists,
71 std::io::ErrorKind::InvalidData => Self::InvalidData,
72 _ => Self::Other,
73 }
74 }
75}
76
77/// Error events for failures requiring reducer handling.
78///
79/// Effect handlers communicate failures by returning `Err()` containing error events
80/// from this namespace. The event loop extracts these error events and feeds them to
81/// the reducer for processing, just like success events.
82///
83/// # Usage
84///
85/// Effect handlers return error events through `Err()`:
86/// ```ignore
87/// return Err(ErrorEvent::ReviewInputsNotMaterialized { pass }.into());
88/// ```
89///
90/// The event loop extracts the error event and feeds it to the reducer.
91///
92/// # Principles
93///
94/// 1. **Errors are events**: All `Err()` returns from effect handlers MUST contain
95/// events from this namespace, NOT strings.
96/// 2. **`Err()` is a carrier**: The `Err()` mechanism just carries error events to the
97/// event loop; it doesn't bypass the reducer.
98/// 3. **Reducer owns recovery**: The reducer processes error events identically to
99/// success events and decides recovery strategy.
100/// 4. **Typed, not strings**: String errors prevent the reducer from handling different
101/// failure modes appropriately.
102#[derive(Clone, Serialize, Deserialize, Debug)]
103pub enum ErrorEvent {
104 /// User requested interruption (Ctrl+C / SIGINT).
105 ///
106 /// This is an external termination request, not an internal pipeline failure.
107 /// The reducer transitions to `PipelinePhase::Interrupted` and sets
108 /// `interrupted_by_user=true` so orchestration can run termination effects
109 /// deterministically (`RestorePromptPermissions`, `SaveCheckpoint`) while skipping
110 /// the pre-termination commit safety check.
111 UserInterruptRequested,
112 /// Review inputs not materialized before `prepare_review_prompt`.
113 ///
114 /// This indicates an effect sequencing bug where `prepare_review_prompt` was called
115 /// without first materializing the review inputs via `materialize_review_inputs`.
116 ReviewInputsNotMaterialized {
117 /// The review pass number.
118 pass: u32,
119 },
120 /// Planning does not support continuation prompts.
121 ///
122 /// This is an invariant violation - continuation mode should not be passed to
123 /// the planning phase.
124 PlanningContinuationNotSupported,
125 /// Review does not support continuation prompts.
126 ///
127 /// This is an invariant violation - continuation mode should not be passed to
128 /// the review phase.
129 ReviewContinuationNotSupported,
130 /// Fix does not support continuation prompts.
131 ///
132 /// This is an invariant violation - continuation mode should not be passed to
133 /// the fix flow.
134 FixContinuationNotSupported,
135 /// Commit message generation does not support continuation prompts.
136 ///
137 /// This is an invariant violation - continuation mode should not be passed to
138 /// the commit phase.
139 CommitContinuationNotSupported,
140 /// Missing fix prompt file when invoking fix agent.
141 ///
142 /// This indicates an effect sequencing bug where `invoke_fix` was called without
143 /// first preparing the fix prompt file at .`agent/tmp/fix_prompt.txt`.
144 FixPromptMissing,
145
146 /// Agent chain exhausted for a phase.
147 ///
148 /// This indicates that all retry attempts have been exhausted for an agent chain
149 /// in a specific phase. The reducer decides whether to terminate the pipeline or
150 /// attempt recovery based on whether progress has been made.
151 AgentChainExhausted {
152 /// The role of the agent chain that was exhausted.
153 role: crate::agents::AgentRole,
154 /// The phase where exhaustion occurred.
155 phase: super::PipelinePhase,
156 /// The retry cycle number when exhaustion occurred.
157 cycle: u32,
158 },
159
160 /// Workspace read failure that must be handled by the reducer.
161 WorkspaceReadFailed {
162 /// Workspace-relative path.
163 path: String,
164 kind: WorkspaceIoErrorKind,
165 },
166 /// Workspace write failure that must be handled by the reducer.
167 WorkspaceWriteFailed {
168 /// Workspace-relative path.
169 path: String,
170 kind: WorkspaceIoErrorKind,
171 },
172 /// Workspace directory creation failure that must be handled by the reducer.
173 WorkspaceCreateDirAllFailed {
174 /// Workspace-relative path.
175 path: String,
176 kind: WorkspaceIoErrorKind,
177 },
178 /// Workspace remove failure that must be handled by the reducer.
179 WorkspaceRemoveFailed {
180 /// Workspace-relative path.
181 path: String,
182 kind: WorkspaceIoErrorKind,
183 },
184
185 /// Failed to stage changes before creating a commit.
186 ///
187 /// Commit creation requires staging (equivalent to `git add -A`). When this fails,
188 /// the error must flow through the reducer as a typed event so the reducer can
189 /// decide whether to retry, fallback, or terminate.
190 GitAddAllFailed { kind: WorkspaceIoErrorKind },
191
192 /// Failed to stage specific files before creating a commit.
193 ///
194 /// This corresponds to selective staging (equivalent to `git add <files>`).
195 /// When this fails, the error must flow through the reducer as a typed event so
196 /// the reducer can decide recovery strategy.
197 GitAddSpecificFailed { kind: WorkspaceIoErrorKind },
198
199 /// Failed to get git status (for pre-termination check).
200 ///
201 /// When checking for uncommitted changes before termination, if `git status` fails,
202 /// this error is raised so the reducer can decide how to handle it.
203 GitStatusFailed { kind: WorkspaceIoErrorKind },
204
205 /// Agent registry lookup failed (unknown agent).
206 AgentNotFound { agent: String },
207
208 /// Planning inputs not materialized before preparing/invoking planning prompt.
209 PlanningInputsNotMaterialized { iteration: u32 },
210 /// Development inputs not materialized before preparing/invoking development prompt.
211 DevelopmentInputsNotMaterialized { iteration: u32 },
212 /// Commit inputs not materialized before preparing commit prompt.
213 CommitInputsNotMaterialized { attempt: u32 },
214
215 /// Prepared planning prompt file missing/unreadable when invoking planning agent.
216 PlanningPromptMissing { iteration: u32 },
217 /// Prepared development prompt file missing/unreadable when invoking development agent.
218 DevelopmentPromptMissing { iteration: u32 },
219 /// Prepared review prompt file missing/unreadable when invoking review agent.
220 ReviewPromptMissing { pass: u32 },
221 /// Prepared commit prompt file missing/unreadable when invoking commit agent.
222 CommitPromptMissing { attempt: u32 },
223
224 /// Commit agent chain not initialized when invoking commit agent.
225 ///
226 /// This is an invariant violation: `InitializeAgentChain` must run before invoking.
227 /// Effect handlers must surface this as a typed error event (never panic) so the
228 /// reducer can deterministically interrupt/checkpoint.
229 CommitAgentNotInitialized { attempt: u32 },
230
231 /// Missing validated planning markdown when writing `.agent/PLAN.md`.
232 ValidatedPlanningMarkdownMissing { iteration: u32 },
233 /// Missing validated development outcome when applying/writing results.
234 ValidatedDevelopmentOutcomeMissing { iteration: u32 },
235 /// Missing validated review outcome when applying/writing results.
236 ValidatedReviewOutcomeMissing { pass: u32 },
237 /// Missing validated fix outcome when applying fixes.
238 ValidatedFixOutcomeMissing { pass: u32 },
239 /// Missing validated commit outcome when applying commit message outcome.
240 ValidatedCommitOutcomeMissing { attempt: u32 },
241}
242
243impl std::fmt::Display for ErrorEvent {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self {
246 Self::UserInterruptRequested => {
247 write!(f, "User interrupt requested (SIGINT / Ctrl+C)")
248 }
249 Self::ReviewInputsNotMaterialized { pass } => {
250 write!(
251 f,
252 "Review inputs not materialized for pass {pass} (expected materialize_review_inputs before prepare_review_prompt)"
253 )
254 }
255 Self::PlanningContinuationNotSupported => {
256 write!(f, "Planning does not support continuation prompts")
257 }
258 Self::ReviewContinuationNotSupported => {
259 write!(f, "Review does not support continuation prompts")
260 }
261 Self::FixContinuationNotSupported => {
262 write!(f, "Fix does not support continuation prompts")
263 }
264 Self::CommitContinuationNotSupported => {
265 write!(
266 f,
267 "Commit message generation does not support continuation prompts"
268 )
269 }
270 Self::FixPromptMissing => {
271 write!(f, "Missing fix prompt at .agent/tmp/fix_prompt.txt")
272 }
273 Self::AgentChainExhausted { role, phase, cycle } => {
274 write!(
275 f,
276 "Agent chain exhausted for role {role:?} in phase {phase:?} (cycle {cycle})"
277 )
278 }
279 Self::WorkspaceReadFailed { path, kind } => {
280 write!(f, "Workspace read failed at {path} ({kind:?})")
281 }
282 Self::WorkspaceWriteFailed { path, kind } => {
283 write!(f, "Workspace write failed at {path} ({kind:?})")
284 }
285 Self::WorkspaceCreateDirAllFailed { path, kind } => {
286 write!(f, "Workspace create_dir_all failed at {path} ({kind:?})")
287 }
288 Self::WorkspaceRemoveFailed { path, kind } => {
289 write!(f, "Workspace remove failed at {path} ({kind:?})")
290 }
291 Self::GitAddAllFailed { kind } => {
292 write!(f, "git add -A (stage all changes) failed ({kind:?})")
293 }
294 Self::GitAddSpecificFailed { kind } => {
295 write!(
296 f,
297 "git add <files> (stage specific paths) failed ({kind:?})"
298 )
299 }
300 Self::GitStatusFailed { kind } => {
301 write!(f, "git status (pre-termination check) failed ({kind:?})")
302 }
303 Self::AgentNotFound { agent } => {
304 write!(f, "Agent not found: {agent}")
305 }
306 Self::PlanningInputsNotMaterialized { iteration } => {
307 write!(
308 f,
309 "Planning inputs not materialized for iteration {iteration} (expected materialize_planning_inputs before prepare/invoke)"
310 )
311 }
312 Self::DevelopmentInputsNotMaterialized { iteration } => {
313 write!(
314 f,
315 "Development inputs not materialized for iteration {iteration} (expected materialize_development_inputs before prepare/invoke)"
316 )
317 }
318 Self::CommitInputsNotMaterialized { attempt } => {
319 write!(
320 f,
321 "Commit inputs not materialized for attempt {attempt} (expected materialize_commit_inputs before prepare)"
322 )
323 }
324 Self::PlanningPromptMissing { iteration } => {
325 write!(
326 f,
327 "Missing planning prompt at .agent/tmp/planning_prompt.txt for iteration {iteration}"
328 )
329 }
330 Self::DevelopmentPromptMissing { iteration } => {
331 write!(
332 f,
333 "Missing development prompt at .agent/tmp/development_prompt.txt for iteration {iteration}"
334 )
335 }
336 Self::ReviewPromptMissing { pass } => {
337 write!(
338 f,
339 "Missing review prompt at .agent/tmp/review_prompt.txt for pass {pass}"
340 )
341 }
342 Self::CommitPromptMissing { attempt } => {
343 write!(
344 f,
345 "Missing commit prompt at .agent/tmp/commit_prompt.txt for attempt {attempt}"
346 )
347 }
348 Self::CommitAgentNotInitialized { attempt } => {
349 write!(
350 f,
351 "Commit agent not initialized for attempt {attempt} (expected InitializeAgentChain before invoke_commit_agent)"
352 )
353 }
354 Self::ValidatedPlanningMarkdownMissing { iteration } => {
355 write!(
356 f,
357 "Missing validated planning markdown for iteration {iteration}"
358 )
359 }
360 Self::ValidatedDevelopmentOutcomeMissing { iteration } => {
361 write!(
362 f,
363 "Missing validated development outcome for iteration {iteration}"
364 )
365 }
366 Self::ValidatedReviewOutcomeMissing { pass } => {
367 write!(f, "Missing validated review outcome for pass {pass}")
368 }
369 Self::ValidatedFixOutcomeMissing { pass } => {
370 write!(f, "Missing validated fix outcome for pass {pass}")
371 }
372 Self::ValidatedCommitOutcomeMissing { attempt } => {
373 write!(f, "Missing validated commit outcome for attempt {attempt}")
374 }
375 }
376 }
377}
378
379impl std::error::Error for ErrorEvent {}
380
381// Note: From<ErrorEvent> for anyhow::Error is provided by anyhow's blanket implementation
382// for all types that implement std::error::Error + Send + Sync + 'static.
383// This automatically preserves ErrorEvent as the error source for downcasting.