Skip to main content

devflow_core/
outcome_policy.rs

1//! Pure outcome -> action policy table (D-08/D-11/D-12, 17-01).
2//!
3//! [`decide_action`] is the single exhaustive policy surface `advance()`
4//! (Plan 04) dispatches on. It has no I/O, no `CliError`, no filesystem, and
5//! no process spawn — deterministic pure function of `(Stage, AgentStatus)`.
6//! The `match` has NO wildcard arm: adding a future [`crate::agent_result::AgentStatus`]
7//! variant without extending this match is a compile error, which is the
8//! mechanism that prevents the D-01 regression class (a new/unhandled
9//! outcome silently advancing).
10
11use crate::agent_result::AgentStatus;
12use crate::stage::Stage;
13
14/// The action to take in response to an agent outcome at a given stage.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Action {
17    /// Advance to the next stage.
18    Advance,
19    /// Automatically resume/retry (e.g. rate limit — wait and retry).
20    AutoResume,
21    /// Gate for a human due to an infrastructure-class fault (OOM, agent
22    /// binary unavailable) — not the agent's fault.
23    GateInfra,
24    /// Gate for a human due to a review-worthy outcome (agent-reported
25    /// failure, or an indeterminate/unknown result that must never
26    /// silently advance).
27    GateReview,
28}
29
30/// Decide what to do given the outcome of a stage's agent run.
31///
32/// `stage` is part of the signature for Plan 04's dispatch even though the
33/// current mapping is stage-independent — kept for forward compatibility,
34/// not used in the match itself.
35///
36/// The match is exhaustive over every [`AgentStatus`] variant with NO
37/// wildcard arm — see the module doc comment.
38pub fn decide_action(_stage: Stage, outcome: AgentStatus) -> Action {
39    match outcome {
40        AgentStatus::Success => Action::Advance,
41        AgentStatus::RateLimited => Action::AutoResume,
42        AgentStatus::ResourceKilled => Action::GateInfra,
43        AgentStatus::AgentUnavailable => Action::GateInfra,
44        // DEFERRED (Plan 01 MEDIUM, OpenCode): Failed and Unknown map
45        // identically to GateReview. Intentional — both are non-advance
46        // outcomes today and the current phase needs no behavioral
47        // distinction between them. The distinction is NOT lost:
48        // AgentResult.decided_by_layer plus the underlying AgentStatus
49        // variant both survive into events.jsonl, so Phase 18's 18d
50        // reconciliation can differentiate a reported failure from a
51        // vanished process without a new Action variant. Revisit if 18d
52        // requires divergent routing.
53        AgentStatus::Failed => Action::GateReview,
54        AgentStatus::Unknown => Action::GateReview,
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn success_advances() {
64        assert_eq!(
65            decide_action(Stage::Code, AgentStatus::Success),
66            Action::Advance
67        );
68    }
69
70    #[test]
71    fn rate_limited_auto_resumes() {
72        assert_eq!(
73            decide_action(Stage::Code, AgentStatus::RateLimited),
74            Action::AutoResume
75        );
76    }
77
78    #[test]
79    fn resource_killed_gates_infra() {
80        assert_eq!(
81            decide_action(Stage::Code, AgentStatus::ResourceKilled),
82            Action::GateInfra
83        );
84    }
85
86    #[test]
87    fn agent_unavailable_gates_infra() {
88        assert_eq!(
89            decide_action(Stage::Code, AgentStatus::AgentUnavailable),
90            Action::GateInfra
91        );
92    }
93
94    #[test]
95    fn failed_gates_review() {
96        assert_eq!(
97            decide_action(Stage::Code, AgentStatus::Failed),
98            Action::GateReview
99        );
100    }
101
102    /// D-01: Unknown must NEVER map to Advance.
103    #[test]
104    fn unknown_gates_review_never_advances() {
105        assert_eq!(
106            decide_action(Stage::Code, AgentStatus::Unknown),
107            Action::GateReview
108        );
109        assert_ne!(
110            decide_action(Stage::Code, AgentStatus::Unknown),
111            Action::Advance
112        );
113    }
114
115    /// Determinism: repeated calls with identical inputs return identical
116    /// results (D-11/D-12 — pure function, no hidden state).
117    #[test]
118    fn decide_action_is_deterministic() {
119        for stage in [
120            Stage::Define,
121            Stage::Plan,
122            Stage::Code,
123            Stage::Validate,
124            Stage::Ship,
125        ] {
126            for outcome in [
127                AgentStatus::Success,
128                AgentStatus::Failed,
129                AgentStatus::RateLimited,
130                AgentStatus::Unknown,
131                AgentStatus::ResourceKilled,
132                AgentStatus::AgentUnavailable,
133            ] {
134                assert_eq!(decide_action(stage, outcome), decide_action(stage, outcome));
135            }
136        }
137    }
138}