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}