Skip to main content

enact_core/kernel/
reducer.rs

1//! Reducer - Event → State transitions
2//!
3//! The reducer is the ONLY place where ExecutionState can change.
4//! It's a pure function: (Execution, Event) → Execution
5//!
6//! This ensures:
7//! - Deterministic state transitions
8//! - Replayable executions
9//! - Auditable state changes
10//!
11//! ## ⚠️ CODE OWNERSHIP & FORBIDDEN PATTERNS
12//!
13//! **This module is the SINGLE SOURCE OF TRUTH for state transitions.**
14//!
15//! ### Code Ownership
16//! - Only `kernel::reducer` may mutate `Execution` or `Step` state
17//! - All state transitions MUST go through `reduce()`
18//! - Reducer must have unit + property tests (CI gate)
19//!
20//! ### Explicitly Forbidden Patterns
21//!
22//! These patterns are **forbidden forever**. If any of these happen, Enact loses its "Now" guarantee.
23//!
24//! 1. **Non-deterministic reducer logic** – Reducer must be pure and deterministic.
25//!    - Same actions → same state
26//!    - No randomness, no external calls, no side effects
27//!    - No provider calls from within reducer
28//!    - No policy checks embedded in reducer (policy belongs in `kernel::enforcement`)
29//!
30//! 2. **External dependencies** – Reducer must not:
31//!    - Call providers
32//!    - Make HTTP requests
33//!    - Access file system
34//!    - Make database queries
35//!    - Generate random values (use deterministic inputs)
36//!
37//! 3. **State mutation outside reducer** – No other module may:
38//!    - Directly mutate `Execution.state`
39//!    - Directly mutate `Step.state`
40//!    - Bypass the reducer for state changes
41//!
42//! ### Invariants Enforced
43//!
44//! - **Deterministic state evolution**: `State_{n+1} = reducer(State_n, Event_n)` must be pure and versioned
45//! - **Append-only event log**: Every state transition is persisted as an `ExecutionEvent` (immutable, timestamped)
46//! - **Replayability**: Every `Execution` can be replayed from its event stream
47//!
48//! @see docs/TECHNICAL/04-KERNEL_INVARIANTS.md
49//!
50//! ## Error Handling (feat-02)
51//!
52//! All failures use `ExecutionError` which provides:
53//! - Deterministic retry decisions
54//! - Structured error categories
55//! - Backoff hints
56//! - Idempotency tracking
57
58use super::error::ExecutionError;
59use super::execution_model::{Execution, Step};
60use super::execution_state::{ExecutionState, StepState, WaitReason};
61use super::ids::{StepId, StepSource, StepType};
62use std::time::Instant;
63
64/// Execution event - all possible events that can change execution state
65///
66/// Actions are the inputs to the reducer. They describe what happened,
67/// and the reducer determines how the state should change.
68#[derive(Debug, Clone)]
69pub enum ExecutionAction {
70    /// Start the execution
71    Start,
72    /// A step started
73    StepStarted {
74        step_id: StepId,
75        parent_step_id: Option<StepId>,
76        step_type: StepType,
77        name: String,
78        /// Source of the step (how it was created)
79        source: Option<StepSource>,
80    },
81    /// A step completed successfully
82    StepCompleted {
83        step_id: StepId,
84        output: Option<String>,
85        duration_ms: u64,
86    },
87    /// A step failed with a structured error (feat-02)
88    StepFailed {
89        step_id: StepId,
90        error: ExecutionError,
91    },
92    /// Execution paused
93    Pause { reason: String },
94    /// Execution resumed
95    Resume,
96    /// Waiting for external input
97    Wait { reason: WaitReason },
98    /// External input received
99    InputReceived,
100    /// Execution completed
101    Complete { output: Option<String> },
102    /// Execution failed with a structured error (feat-02)
103    Fail { error: ExecutionError },
104    /// Execution cancelled
105    Cancel { reason: String },
106}
107
108/// Apply an action to an execution, producing a new state
109///
110/// This is a pure function - it doesn't mutate the execution in place,
111/// but returns whether the transition was valid and applies it if so.
112pub fn reduce(execution: &mut Execution, action: ExecutionAction) -> Result<(), ReducerError> {
113    match action {
114        ExecutionAction::Start => {
115            if execution.state != ExecutionState::Created {
116                return Err(ReducerError::InvalidTransition {
117                    from: execution.state,
118                    action: "Start".to_string(),
119                });
120            }
121            execution.state = ExecutionState::Running;
122            execution.started_at = Some(Instant::now());
123            Ok(())
124        }
125
126        ExecutionAction::StepStarted {
127            step_id,
128            parent_step_id,
129            step_type,
130            name,
131            source,
132        } => {
133            if !matches!(execution.state, ExecutionState::Running) {
134                return Err(ReducerError::InvalidTransition {
135                    from: execution.state,
136                    action: "StepStarted".to_string(),
137                });
138            }
139            let mut step = Step::new(step_type, name);
140            step.id = step_id;
141            step.parent_step_id = parent_step_id;
142            step.state = StepState::Running;
143            step.started_at = Some(now_millis());
144            step.source = source;
145            execution.add_step(step);
146            Ok(())
147        }
148
149        ExecutionAction::StepCompleted {
150            step_id,
151            output,
152            duration_ms,
153        } => {
154            if let Some(step) = execution.get_step_mut(&step_id) {
155                step.state = StepState::Completed;
156                step.output = output;
157                step.duration_ms = Some(duration_ms);
158                step.ended_at = Some(now_millis());
159                Ok(())
160            } else {
161                Err(ReducerError::StepNotFound(step_id))
162            }
163        }
164
165        ExecutionAction::StepFailed { step_id, error } => {
166            if let Some(step) = execution.get_step_mut(&step_id) {
167                step.state = StepState::Failed;
168                step.error = Some(error);
169                step.ended_at = Some(now_millis());
170                Ok(())
171            } else {
172                Err(ReducerError::StepNotFound(step_id))
173            }
174        }
175
176        ExecutionAction::Pause { reason: _ } => {
177            if execution.state != ExecutionState::Running {
178                return Err(ReducerError::InvalidTransition {
179                    from: execution.state,
180                    action: "Pause".to_string(),
181                });
182            }
183            execution.state = ExecutionState::Paused;
184            Ok(())
185        }
186
187        ExecutionAction::Resume => {
188            if !execution.state.can_resume() {
189                return Err(ReducerError::InvalidTransition {
190                    from: execution.state,
191                    action: "Resume".to_string(),
192                });
193            }
194            execution.state = ExecutionState::Running;
195            Ok(())
196        }
197
198        ExecutionAction::Wait { reason } => {
199            if execution.state != ExecutionState::Running {
200                return Err(ReducerError::InvalidTransition {
201                    from: execution.state,
202                    action: "Wait".to_string(),
203                });
204            }
205            execution.state = ExecutionState::Waiting(reason);
206            Ok(())
207        }
208
209        ExecutionAction::InputReceived => {
210            if !matches!(execution.state, ExecutionState::Waiting(_)) {
211                return Err(ReducerError::InvalidTransition {
212                    from: execution.state,
213                    action: "InputReceived".to_string(),
214                });
215            }
216            execution.state = ExecutionState::Running;
217            Ok(())
218        }
219
220        ExecutionAction::Complete { output } => {
221            if execution.state != ExecutionState::Running {
222                return Err(ReducerError::InvalidTransition {
223                    from: execution.state,
224                    action: "Complete".to_string(),
225                });
226            }
227            execution.state = ExecutionState::Completed;
228            execution.output = output;
229            execution.ended_at = Some(Instant::now());
230            Ok(())
231        }
232
233        ExecutionAction::Fail { error } => {
234            // Can fail from any non-terminal state
235            if execution.state.is_terminal() {
236                return Err(ReducerError::InvalidTransition {
237                    from: execution.state,
238                    action: "Fail".to_string(),
239                });
240            }
241            execution.state = ExecutionState::Failed;
242            execution.error = Some(error);
243            execution.ended_at = Some(Instant::now());
244            Ok(())
245        }
246
247        ExecutionAction::Cancel { reason: _ } => {
248            // Can cancel from any non-terminal state
249            if execution.state.is_terminal() {
250                return Err(ReducerError::InvalidTransition {
251                    from: execution.state,
252                    action: "Cancel".to_string(),
253                });
254            }
255            execution.state = ExecutionState::Cancelled;
256            execution.ended_at = Some(Instant::now());
257            Ok(())
258        }
259    }
260}
261
262/// Reducer error
263#[derive(Debug, thiserror::Error)]
264pub enum ReducerError {
265    #[error("Invalid state transition from {from:?} via {action}")]
266    InvalidTransition {
267        from: ExecutionState,
268        action: String,
269    },
270    #[error("Step not found: {0}")]
271    StepNotFound(StepId),
272}
273
274/// Get current time in milliseconds
275fn now_millis() -> i64 {
276    std::time::SystemTime::now()
277        .duration_since(std::time::UNIX_EPOCH)
278        .unwrap_or_default()
279        .as_millis() as i64
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::kernel::error::ExecutionErrorCategory;
286
287    #[test]
288    fn test_valid_execution_lifecycle() {
289        let mut exec = Execution::new();
290
291        // Start
292        assert!(reduce(&mut exec, ExecutionAction::Start).is_ok());
293        assert_eq!(exec.state, ExecutionState::Running);
294
295        // Complete
296        assert!(reduce(
297            &mut exec,
298            ExecutionAction::Complete {
299                output: Some("done".into())
300            }
301        )
302        .is_ok());
303        assert_eq!(exec.state, ExecutionState::Completed);
304        assert_eq!(exec.output, Some("done".to_string()));
305    }
306
307    #[test]
308    fn test_invalid_start_from_running() {
309        let mut exec = Execution::new();
310        reduce(&mut exec, ExecutionAction::Start).unwrap();
311
312        // Can't start again
313        assert!(reduce(&mut exec, ExecutionAction::Start).is_err());
314    }
315
316    #[test]
317    fn test_pause_resume() {
318        let mut exec = Execution::new();
319        reduce(&mut exec, ExecutionAction::Start).unwrap();
320
321        // Pause
322        assert!(reduce(
323            &mut exec,
324            ExecutionAction::Pause {
325                reason: "test".into()
326            }
327        )
328        .is_ok());
329        assert_eq!(exec.state, ExecutionState::Paused);
330
331        // Resume
332        assert!(reduce(&mut exec, ExecutionAction::Resume).is_ok());
333        assert_eq!(exec.state, ExecutionState::Running);
334    }
335
336    #[test]
337    fn test_fail_with_structured_error() {
338        let mut exec = Execution::new();
339        reduce(&mut exec, ExecutionAction::Start).unwrap();
340
341        // Fail with a structured error
342        let error = ExecutionError::llm(
343            crate::kernel::error::LlmErrorCode::RateLimit,
344            "Too many requests",
345        )
346        .with_http_status(429);
347
348        assert!(reduce(
349            &mut exec,
350            ExecutionAction::Fail {
351                error: error.clone()
352            }
353        )
354        .is_ok());
355        assert_eq!(exec.state, ExecutionState::Failed);
356        assert!(exec.error.is_some());
357
358        let stored_error = exec.error.unwrap();
359        assert_eq!(stored_error.category, ExecutionErrorCategory::LlmError);
360        assert!(stored_error.is_retryable());
361        assert_eq!(stored_error.http_status, Some(429));
362    }
363
364    #[test]
365    fn test_step_fail_with_structured_error() {
366        let mut exec = Execution::new();
367        reduce(&mut exec, ExecutionAction::Start).unwrap();
368
369        let step_id = StepId::new();
370        reduce(
371            &mut exec,
372            ExecutionAction::StepStarted {
373                step_id: step_id.clone(),
374                parent_step_id: None,
375                step_type: StepType::ToolNode,
376                name: "test_tool".into(),
377                source: None,
378            },
379        )
380        .unwrap();
381
382        // Fail the step with a structured error
383        let error = ExecutionError::tool(
384            crate::kernel::error::ToolErrorCode::ExecutionFailed,
385            "Tool crashed",
386        );
387
388        assert!(reduce(
389            &mut exec,
390            ExecutionAction::StepFailed {
391                step_id: step_id.clone(),
392                error: error.clone(),
393            }
394        )
395        .is_ok());
396
397        let step = exec.get_step(&step_id).unwrap();
398        assert_eq!(step.state, StepState::Failed);
399        assert!(step.error.is_some());
400
401        let stored_error = step.error.as_ref().unwrap();
402        assert_eq!(stored_error.category, ExecutionErrorCategory::ToolError);
403        assert!(stored_error.is_retryable());
404    }
405
406    #[test]
407    fn test_fatal_error_not_retryable() {
408        let error = ExecutionError::policy_violation("Content blocked");
409        assert!(!error.is_retryable());
410        assert!(error.is_fatal());
411        assert!(!error.should_retry());
412    }
413
414    // =========================================================================
415    // State Transition Tests
416    // =========================================================================
417
418    #[test]
419    fn test_start_sets_started_at() {
420        let mut exec = Execution::new();
421        assert!(exec.started_at.is_none());
422
423        reduce(&mut exec, ExecutionAction::Start).unwrap();
424        assert!(exec.started_at.is_some());
425    }
426
427    #[test]
428    fn test_complete_sets_ended_at() {
429        let mut exec = Execution::new();
430        reduce(&mut exec, ExecutionAction::Start).unwrap();
431        assert!(exec.ended_at.is_none());
432
433        reduce(&mut exec, ExecutionAction::Complete { output: None }).unwrap();
434        assert!(exec.ended_at.is_some());
435    }
436
437    #[test]
438    fn test_fail_sets_ended_at() {
439        let mut exec = Execution::new();
440        reduce(&mut exec, ExecutionAction::Start).unwrap();
441
442        let error = ExecutionError::kernel_internal("Test failure");
443        reduce(&mut exec, ExecutionAction::Fail { error }).unwrap();
444        assert!(exec.ended_at.is_some());
445    }
446
447    #[test]
448    fn test_cancel_sets_ended_at() {
449        let mut exec = Execution::new();
450        reduce(&mut exec, ExecutionAction::Start).unwrap();
451
452        reduce(
453            &mut exec,
454            ExecutionAction::Cancel {
455                reason: "User cancelled".into(),
456            },
457        )
458        .unwrap();
459        assert!(exec.ended_at.is_some());
460    }
461
462    // =========================================================================
463    // Wait/Resume Tests
464    // =========================================================================
465
466    #[test]
467    fn test_wait_for_approval() {
468        let mut exec = Execution::new();
469        reduce(&mut exec, ExecutionAction::Start).unwrap();
470
471        reduce(
472            &mut exec,
473            ExecutionAction::Wait {
474                reason: WaitReason::Approval,
475            },
476        )
477        .unwrap();
478        assert!(matches!(
479            exec.state,
480            ExecutionState::Waiting(WaitReason::Approval)
481        ));
482    }
483
484    #[test]
485    fn test_wait_for_tool_result() {
486        let mut exec = Execution::new();
487        reduce(&mut exec, ExecutionAction::Start).unwrap();
488
489        reduce(
490            &mut exec,
491            ExecutionAction::Wait {
492                reason: WaitReason::External,
493            },
494        )
495        .unwrap();
496        assert!(matches!(
497            exec.state,
498            ExecutionState::Waiting(WaitReason::External)
499        ));
500    }
501
502    #[test]
503    fn test_wait_for_user_input() {
504        let mut exec = Execution::new();
505        reduce(&mut exec, ExecutionAction::Start).unwrap();
506
507        reduce(
508            &mut exec,
509            ExecutionAction::Wait {
510                reason: WaitReason::UserInput,
511            },
512        )
513        .unwrap();
514        assert!(matches!(
515            exec.state,
516            ExecutionState::Waiting(WaitReason::UserInput)
517        ));
518    }
519
520    #[test]
521    fn test_input_received_resumes_from_waiting() {
522        let mut exec = Execution::new();
523        reduce(&mut exec, ExecutionAction::Start).unwrap();
524        reduce(
525            &mut exec,
526            ExecutionAction::Wait {
527                reason: WaitReason::Approval,
528            },
529        )
530        .unwrap();
531
532        reduce(&mut exec, ExecutionAction::InputReceived).unwrap();
533        assert_eq!(exec.state, ExecutionState::Running);
534    }
535
536    #[test]
537    fn test_resume_from_waiting() {
538        let mut exec = Execution::new();
539        reduce(&mut exec, ExecutionAction::Start).unwrap();
540        reduce(
541            &mut exec,
542            ExecutionAction::Wait {
543                reason: WaitReason::Approval,
544            },
545        )
546        .unwrap();
547
548        reduce(&mut exec, ExecutionAction::Resume).unwrap();
549        assert_eq!(exec.state, ExecutionState::Running);
550    }
551
552    // =========================================================================
553    // Step Lifecycle Tests
554    // =========================================================================
555
556    #[test]
557    fn test_step_started_adds_step() {
558        let mut exec = Execution::new();
559        reduce(&mut exec, ExecutionAction::Start).unwrap();
560
561        let step_id = StepId::from_string("step_test");
562        reduce(
563            &mut exec,
564            ExecutionAction::StepStarted {
565                step_id: step_id.clone(),
566                parent_step_id: None,
567                step_type: StepType::LlmNode,
568                name: "test_step".into(),
569                source: None,
570            },
571        )
572        .unwrap();
573
574        assert_eq!(exec.steps.len(), 1);
575        let step = exec.get_step(&step_id).unwrap();
576        assert_eq!(step.state, StepState::Running);
577        assert_eq!(step.name, "test_step");
578        assert!(step.started_at.is_some());
579    }
580
581    #[test]
582    fn test_step_completed_sets_output() {
583        let mut exec = Execution::new();
584        reduce(&mut exec, ExecutionAction::Start).unwrap();
585
586        let step_id = StepId::from_string("step_complete");
587        reduce(
588            &mut exec,
589            ExecutionAction::StepStarted {
590                step_id: step_id.clone(),
591                parent_step_id: None,
592                step_type: StepType::LlmNode,
593                name: "complete_step".into(),
594                source: None,
595            },
596        )
597        .unwrap();
598
599        reduce(
600            &mut exec,
601            ExecutionAction::StepCompleted {
602                step_id: step_id.clone(),
603                output: Some("Result".into()),
604                duration_ms: 1000,
605            },
606        )
607        .unwrap();
608
609        let step = exec.get_step(&step_id).unwrap();
610        assert_eq!(step.state, StepState::Completed);
611        assert_eq!(step.output, Some("Result".to_string()));
612        assert_eq!(step.duration_ms, Some(1000));
613        assert!(step.ended_at.is_some());
614    }
615
616    #[test]
617    fn test_nested_step_with_parent() {
618        let mut exec = Execution::new();
619        reduce(&mut exec, ExecutionAction::Start).unwrap();
620
621        let parent_id = StepId::from_string("step_parent");
622        let child_id = StepId::from_string("step_child");
623
624        reduce(
625            &mut exec,
626            ExecutionAction::StepStarted {
627                step_id: parent_id.clone(),
628                parent_step_id: None,
629                step_type: StepType::GraphNode,
630                name: "parent".into(),
631                source: None,
632            },
633        )
634        .unwrap();
635
636        reduce(
637            &mut exec,
638            ExecutionAction::StepStarted {
639                step_id: child_id.clone(),
640                parent_step_id: Some(parent_id.clone()),
641                step_type: StepType::LlmNode,
642                name: "child".into(),
643                source: None,
644            },
645        )
646        .unwrap();
647
648        let child = exec.get_step(&child_id).unwrap();
649        assert_eq!(child.parent_step_id, Some(parent_id));
650    }
651
652    #[test]
653    fn test_multiple_steps_preserved_order() {
654        let mut exec = Execution::new();
655        reduce(&mut exec, ExecutionAction::Start).unwrap();
656
657        let step1 = StepId::from_string("step_1");
658        let step2 = StepId::from_string("step_2");
659        let step3 = StepId::from_string("step_3");
660
661        for (step_id, name) in [(&step1, "first"), (&step2, "second"), (&step3, "third")] {
662            reduce(
663                &mut exec,
664                ExecutionAction::StepStarted {
665                    step_id: step_id.clone(),
666                    parent_step_id: None,
667                    step_type: StepType::FunctionNode,
668                    name: name.into(),
669                    source: None,
670                },
671            )
672            .unwrap();
673        }
674
675        assert_eq!(exec.step_order.len(), 3);
676        assert_eq!(exec.step_order[0], step1);
677        assert_eq!(exec.step_order[1], step2);
678        assert_eq!(exec.step_order[2], step3);
679    }
680
681    // =========================================================================
682    // Invalid Transition Tests
683    // =========================================================================
684
685    #[test]
686    fn test_cannot_complete_from_created() {
687        let mut exec = Execution::new();
688        let result = reduce(&mut exec, ExecutionAction::Complete { output: None });
689        assert!(result.is_err());
690    }
691
692    #[test]
693    fn test_cannot_pause_from_created() {
694        let mut exec = Execution::new();
695        let result = reduce(
696            &mut exec,
697            ExecutionAction::Pause {
698                reason: "test".into(),
699            },
700        );
701        assert!(result.is_err());
702    }
703
704    #[test]
705    fn test_cannot_resume_from_created() {
706        let mut exec = Execution::new();
707        let result = reduce(&mut exec, ExecutionAction::Resume);
708        assert!(result.is_err());
709    }
710
711    #[test]
712    fn test_cannot_resume_from_running() {
713        let mut exec = Execution::new();
714        reduce(&mut exec, ExecutionAction::Start).unwrap();
715        let result = reduce(&mut exec, ExecutionAction::Resume);
716        assert!(result.is_err());
717    }
718
719    #[test]
720    fn test_cannot_wait_from_paused() {
721        let mut exec = Execution::new();
722        reduce(&mut exec, ExecutionAction::Start).unwrap();
723        reduce(
724            &mut exec,
725            ExecutionAction::Pause {
726                reason: "test".into(),
727            },
728        )
729        .unwrap();
730        let result = reduce(
731            &mut exec,
732            ExecutionAction::Wait {
733                reason: WaitReason::Approval,
734            },
735        );
736        assert!(result.is_err());
737    }
738
739    #[test]
740    fn test_cannot_input_received_when_not_waiting() {
741        let mut exec = Execution::new();
742        reduce(&mut exec, ExecutionAction::Start).unwrap();
743        let result = reduce(&mut exec, ExecutionAction::InputReceived);
744        assert!(result.is_err());
745    }
746
747    #[test]
748    fn test_cannot_fail_from_completed() {
749        let mut exec = Execution::new();
750        reduce(&mut exec, ExecutionAction::Start).unwrap();
751        reduce(&mut exec, ExecutionAction::Complete { output: None }).unwrap();
752
753        let error = ExecutionError::kernel_internal("Too late");
754        let result = reduce(&mut exec, ExecutionAction::Fail { error });
755        assert!(result.is_err());
756    }
757
758    #[test]
759    fn test_cannot_cancel_from_completed() {
760        let mut exec = Execution::new();
761        reduce(&mut exec, ExecutionAction::Start).unwrap();
762        reduce(&mut exec, ExecutionAction::Complete { output: None }).unwrap();
763
764        let result = reduce(
765            &mut exec,
766            ExecutionAction::Cancel {
767                reason: "test".into(),
768            },
769        );
770        assert!(result.is_err());
771    }
772
773    #[test]
774    fn test_cannot_cancel_from_failed() {
775        let mut exec = Execution::new();
776        reduce(&mut exec, ExecutionAction::Start).unwrap();
777        let error = ExecutionError::kernel_internal("Failed");
778        reduce(&mut exec, ExecutionAction::Fail { error }).unwrap();
779
780        let result = reduce(
781            &mut exec,
782            ExecutionAction::Cancel {
783                reason: "test".into(),
784            },
785        );
786        assert!(result.is_err());
787    }
788
789    #[test]
790    fn test_cannot_start_step_when_not_running() {
791        let mut exec = Execution::new();
792        reduce(&mut exec, ExecutionAction::Start).unwrap();
793        reduce(
794            &mut exec,
795            ExecutionAction::Pause {
796                reason: "test".into(),
797            },
798        )
799        .unwrap();
800
801        let result = reduce(
802            &mut exec,
803            ExecutionAction::StepStarted {
804                step_id: StepId::new(),
805                parent_step_id: None,
806                step_type: StepType::LlmNode,
807                name: "test".into(),
808                source: None,
809            },
810        );
811        assert!(result.is_err());
812    }
813
814    #[test]
815    fn test_step_not_found_error() {
816        let mut exec = Execution::new();
817        reduce(&mut exec, ExecutionAction::Start).unwrap();
818
819        let nonexistent = StepId::from_string("step_nonexistent");
820        let result = reduce(
821            &mut exec,
822            ExecutionAction::StepCompleted {
823                step_id: nonexistent,
824                output: None,
825                duration_ms: 0,
826            },
827        );
828        assert!(matches!(result, Err(ReducerError::StepNotFound(_))));
829    }
830
831    #[test]
832    fn test_step_failed_not_found() {
833        let mut exec = Execution::new();
834        reduce(&mut exec, ExecutionAction::Start).unwrap();
835
836        let nonexistent = StepId::from_string("step_missing");
837        let error = ExecutionError::kernel_internal("Test");
838        let result = reduce(
839            &mut exec,
840            ExecutionAction::StepFailed {
841                step_id: nonexistent,
842                error,
843            },
844        );
845        assert!(matches!(result, Err(ReducerError::StepNotFound(_))));
846    }
847
848    // =========================================================================
849    // Can Fail From Various States Tests
850    // =========================================================================
851
852    #[test]
853    fn test_can_fail_from_running() {
854        let mut exec = Execution::new();
855        reduce(&mut exec, ExecutionAction::Start).unwrap();
856
857        let error = ExecutionError::kernel_internal("Runtime error");
858        let result = reduce(&mut exec, ExecutionAction::Fail { error });
859        assert!(result.is_ok());
860        assert_eq!(exec.state, ExecutionState::Failed);
861    }
862
863    #[test]
864    fn test_can_fail_from_paused() {
865        let mut exec = Execution::new();
866        reduce(&mut exec, ExecutionAction::Start).unwrap();
867        reduce(
868            &mut exec,
869            ExecutionAction::Pause {
870                reason: "test".into(),
871            },
872        )
873        .unwrap();
874
875        let error = ExecutionError::kernel_internal("Error while paused");
876        let result = reduce(&mut exec, ExecutionAction::Fail { error });
877        assert!(result.is_ok());
878        assert_eq!(exec.state, ExecutionState::Failed);
879    }
880
881    #[test]
882    fn test_can_fail_from_waiting() {
883        let mut exec = Execution::new();
884        reduce(&mut exec, ExecutionAction::Start).unwrap();
885        reduce(
886            &mut exec,
887            ExecutionAction::Wait {
888                reason: WaitReason::Approval,
889            },
890        )
891        .unwrap();
892
893        let error = ExecutionError::timeout("Approval timed out");
894        let result = reduce(&mut exec, ExecutionAction::Fail { error });
895        assert!(result.is_ok());
896        assert_eq!(exec.state, ExecutionState::Failed);
897    }
898
899    // =========================================================================
900    // Can Cancel From Various States Tests
901    // =========================================================================
902
903    #[test]
904    fn test_can_cancel_from_running() {
905        let mut exec = Execution::new();
906        reduce(&mut exec, ExecutionAction::Start).unwrap();
907
908        let result = reduce(
909            &mut exec,
910            ExecutionAction::Cancel {
911                reason: "User request".into(),
912            },
913        );
914        assert!(result.is_ok());
915        assert_eq!(exec.state, ExecutionState::Cancelled);
916    }
917
918    #[test]
919    fn test_can_cancel_from_paused() {
920        let mut exec = Execution::new();
921        reduce(&mut exec, ExecutionAction::Start).unwrap();
922        reduce(
923            &mut exec,
924            ExecutionAction::Pause {
925                reason: "test".into(),
926            },
927        )
928        .unwrap();
929
930        let result = reduce(
931            &mut exec,
932            ExecutionAction::Cancel {
933                reason: "Cancelled".into(),
934            },
935        );
936        assert!(result.is_ok());
937        assert_eq!(exec.state, ExecutionState::Cancelled);
938    }
939
940    #[test]
941    fn test_can_cancel_from_waiting() {
942        let mut exec = Execution::new();
943        reduce(&mut exec, ExecutionAction::Start).unwrap();
944        reduce(
945            &mut exec,
946            ExecutionAction::Wait {
947                reason: WaitReason::Approval,
948            },
949        )
950        .unwrap();
951
952        let result = reduce(
953            &mut exec,
954            ExecutionAction::Cancel {
955                reason: "Timeout".into(),
956            },
957        );
958        assert!(result.is_ok());
959        assert_eq!(exec.state, ExecutionState::Cancelled);
960    }
961
962    // =========================================================================
963    // ReducerError Tests
964    // =========================================================================
965
966    #[test]
967    fn test_reducer_error_display_invalid_transition() {
968        let error = ReducerError::InvalidTransition {
969            from: ExecutionState::Paused,
970            action: "Start".to_string(),
971        };
972        let display = format!("{}", error);
973        assert!(display.contains("Paused"));
974        assert!(display.contains("Start"));
975    }
976
977    #[test]
978    fn test_reducer_error_display_step_not_found() {
979        let step_id = StepId::from_string("step_missing");
980        let error = ReducerError::StepNotFound(step_id);
981        let display = format!("{}", error);
982        assert!(display.contains("step_missing"));
983    }
984}