Skip to main content

git_internal/internal/object/
decision.rs

1//! AI Decision Definition
2//!
3//! A `Decision` is the **terminal verdict** of a [`Run`](super::run::Run).
4//! After the agent finishes generating and validating PatchSets, the
5//! orchestrator (or the agent itself) creates a Decision to record what
6//! should happen next.
7//!
8//! # Position in Lifecycle
9//!
10//! ```text
11//! Task ──runs──▶ Run ──patchsets──▶ [PatchSet₀, PatchSet₁, ...]
12//!                  │
13//!                  └──(terminal)──▶ Decision
14//!                                     ├── chosen_patchset ──▶ PatchSet
15//!                                     └── evidence (via Evidence.decision)
16//! ```
17//!
18//! A Decision is created **once per Run**, at the end of execution.
19//! It selects which PatchSet (if any) to apply and records the
20//! resulting commit hash. [`Evidence`](super::evidence::Evidence)
21//! objects may reference the Decision to provide supporting data
22//! (test results, lint reports) that justified the verdict.
23//!
24//! # Decision Types
25//!
26//! - **`Commit`**: Accept the chosen PatchSet and apply it to the
27//!   repository. `chosen_patchset` and `result_commit` should be set.
28//! - **`Checkpoint`**: Save intermediate progress without finishing.
29//!   The Run may continue or be resumed later. `checkpoint_id`
30//!   identifies the saved state.
31//! - **`Abandon`**: Give up on the Task. The goal is deemed impossible
32//!   or not worth pursuing. No PatchSet is applied.
33//! - **`Retry`**: The current attempt failed but the Task is still
34//!   viable. The orchestrator should create a new Run to try again,
35//!   potentially with different parameters or prompts.
36//! - **`Rollback`**: Revert previously applied changes. Used when a
37//!   committed PatchSet is later found to be incorrect.
38//!
39//! # Flow
40//!
41//! ```text
42//!   Run completes
43//!        │
44//!        ▼
45//!   Orchestrator creates Decision
46//!        │
47//!        ├─ Commit ──▶ apply PatchSet, record result_commit
48//!        ├─ Checkpoint ──▶ save state, record checkpoint_id
49//!        ├─ Abandon ──▶ mark Task as Failed
50//!        ├─ Retry ──▶ create new Run for same Task
51//!        └─ Rollback ──▶ revert applied PatchSet
52//! ```
53
54use std::fmt;
55
56use serde::{Deserialize, Serialize};
57use uuid::Uuid;
58
59use crate::{
60    errors::GitError,
61    hash::ObjectHash,
62    internal::object::{
63        ObjectTrait,
64        integrity::IntegrityHash,
65        types::{ActorRef, Header, ObjectType},
66    },
67};
68
69/// Type of decision.
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71#[serde(rename_all = "snake_case")]
72pub enum DecisionType {
73    /// Approve and commit changes.
74    Commit,
75    /// Save intermediate progress.
76    Checkpoint,
77    /// Give up on the task.
78    Abandon,
79    /// Try again (re-run).
80    Retry,
81    /// Revert applied changes.
82    Rollback,
83    #[serde(untagged)]
84    Other(String),
85}
86
87impl fmt::Display for DecisionType {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            DecisionType::Commit => write!(f, "commit"),
91            DecisionType::Checkpoint => write!(f, "checkpoint"),
92            DecisionType::Abandon => write!(f, "abandon"),
93            DecisionType::Retry => write!(f, "retry"),
94            DecisionType::Rollback => write!(f, "rollback"),
95            DecisionType::Other(s) => write!(f, "{}", s),
96        }
97    }
98}
99
100impl From<String> for DecisionType {
101    fn from(s: String) -> Self {
102        match s.as_str() {
103            "commit" => DecisionType::Commit,
104            "checkpoint" => DecisionType::Checkpoint,
105            "abandon" => DecisionType::Abandon,
106            "retry" => DecisionType::Retry,
107            "rollback" => DecisionType::Rollback,
108            _ => DecisionType::Other(s),
109        }
110    }
111}
112
113impl From<&str> for DecisionType {
114    fn from(s: &str) -> Self {
115        match s {
116            "commit" => DecisionType::Commit,
117            "checkpoint" => DecisionType::Checkpoint,
118            "abandon" => DecisionType::Abandon,
119            "retry" => DecisionType::Retry,
120            "rollback" => DecisionType::Rollback,
121            _ => DecisionType::Other(s.to_string()),
122        }
123    }
124}
125
126/// Terminal verdict of a [`Run`](super::run::Run).
127///
128/// Created once per Run at the end of execution. See module
129/// documentation for lifecycle position and decision type semantics.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct Decision {
132    /// Common header (object ID, type, timestamps, creator, etc.).
133    #[serde(flatten)]
134    header: Header,
135    /// The [`Run`](super::run::Run) this Decision concludes.
136    ///
137    /// Every Decision belongs to exactly one Run. The Run does not
138    /// store a back-reference; lookup is done by scanning or indexing.
139    run_id: Uuid,
140    /// The verdict: what should happen as a result of this Run.
141    ///
142    /// See [`DecisionType`] variants for semantics. The orchestrator
143    /// inspects this field to determine the next action (apply patch,
144    /// retry, abandon, etc.).
145    decision_type: DecisionType,
146    /// The [`PatchSet`](super::patchset::PatchSet) selected for
147    /// application.
148    ///
149    /// Set when `decision_type` is `Commit` — identifies which
150    /// PatchSet from `Run.patchsets` was chosen. `None` for
151    /// `Abandon`, `Retry`, `Rollback`, or when no suitable PatchSet
152    /// exists.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    chosen_patchset_id: Option<Uuid>,
155    /// Git commit hash produced after applying the chosen PatchSet.
156    ///
157    /// Set by the orchestrator after a successful `git commit`.
158    /// `None` until the PatchSet is actually committed, or when the
159    /// decision does not involve applying changes.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    result_commit_sha: Option<IntegrityHash>,
162    /// Opaque identifier for a saved checkpoint.
163    ///
164    /// Set when `decision_type` is `Checkpoint`. The format and
165    /// resolution of the ID are defined by the orchestrator (e.g.
166    /// a snapshot name, a storage key). `None` for all other
167    /// decision types.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    checkpoint_id: Option<String>,
170    /// Human-readable explanation of why this decision was made.
171    ///
172    /// Written by the agent or orchestrator to justify the verdict.
173    /// For `Commit`: summarises why the chosen PatchSet is correct.
174    /// For `Abandon`/`Retry`: explains what went wrong.
175    /// For `Rollback`: describes the defect that triggered reversion.
176    /// `None` if no explanation was provided.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    rationale: Option<String>,
179}
180
181impl Decision {
182    /// Create a new decision object
183    pub fn new(
184        created_by: ActorRef,
185        run_id: Uuid,
186        decision_type: impl Into<DecisionType>,
187    ) -> Result<Self, String> {
188        Ok(Self {
189            header: Header::new(ObjectType::Decision, created_by)?,
190            run_id,
191            decision_type: decision_type.into(),
192            chosen_patchset_id: None,
193            result_commit_sha: None,
194            checkpoint_id: None,
195            rationale: None,
196        })
197    }
198
199    pub fn header(&self) -> &Header {
200        &self.header
201    }
202
203    pub fn run_id(&self) -> Uuid {
204        self.run_id
205    }
206
207    pub fn decision_type(&self) -> &DecisionType {
208        &self.decision_type
209    }
210
211    pub fn chosen_patchset_id(&self) -> Option<Uuid> {
212        self.chosen_patchset_id
213    }
214
215    pub fn result_commit_sha(&self) -> Option<&IntegrityHash> {
216        self.result_commit_sha.as_ref()
217    }
218
219    pub fn checkpoint_id(&self) -> Option<&str> {
220        self.checkpoint_id.as_deref()
221    }
222
223    pub fn rationale(&self) -> Option<&str> {
224        self.rationale.as_deref()
225    }
226
227    pub fn set_chosen_patchset_id(&mut self, chosen_patchset_id: Option<Uuid>) {
228        self.chosen_patchset_id = chosen_patchset_id;
229    }
230
231    pub fn set_result_commit_sha(&mut self, result_commit_sha: Option<IntegrityHash>) {
232        self.result_commit_sha = result_commit_sha;
233    }
234
235    pub fn set_checkpoint_id(&mut self, checkpoint_id: Option<String>) {
236        self.checkpoint_id = checkpoint_id;
237    }
238
239    pub fn set_rationale(&mut self, rationale: Option<String>) {
240        self.rationale = rationale;
241    }
242}
243
244impl fmt::Display for Decision {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        write!(f, "Decision: {}", self.header.object_id())
247    }
248}
249
250impl ObjectTrait for Decision {
251    fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
252    where
253        Self: Sized,
254    {
255        serde_json::from_slice(data).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
256    }
257
258    fn get_type(&self) -> ObjectType {
259        ObjectType::Decision
260    }
261
262    fn get_size(&self) -> usize {
263        match serde_json::to_vec(self) {
264            Ok(v) => v.len(),
265            Err(e) => {
266                tracing::warn!("failed to compute Decision size: {}", e);
267                0
268            }
269        }
270    }
271
272    fn to_data(&self) -> Result<Vec<u8>, GitError> {
273        serde_json::to_vec(self).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn test_decision_fields() {
283        let actor = ActorRef::agent("test-agent").expect("actor");
284        let run_id = Uuid::from_u128(0x1);
285        let patchset_id = Uuid::from_u128(0x2);
286        let expected_hash = IntegrityHash::compute(b"decision-hash");
287
288        let mut decision = Decision::new(actor, run_id, "commit").expect("decision");
289        decision.set_chosen_patchset_id(Some(patchset_id));
290        decision.set_result_commit_sha(Some(expected_hash));
291        decision.set_rationale(Some("tests passed".to_string()));
292
293        assert_eq!(decision.chosen_patchset_id(), Some(patchset_id));
294        assert_eq!(decision.result_commit_sha(), Some(&expected_hash));
295        assert_eq!(decision.rationale(), Some("tests passed"));
296        assert_eq!(decision.decision_type(), &DecisionType::Commit);
297    }
298}