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