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