Skip to main content

git_internal/internal/object/
task.rs

1//! AI Task Definition
2//!
3//! A [`Task`] is a unit of work to be performed by an AI agent. It is
4//! step ④ in the end-to-end flow described in [`mod.rs`](super) — the
5//! stable identity for a piece of work, independent of how many times
6//! it is attempted (Runs) or how the strategy evolves (Plan revisions).
7//!
8//! # Position in Lifecycle
9//!
10//! ```text
11//!  ③  Plan ──steps──▶ [PlanStep₀, PlanStep₁, ...]
12//!                          │
13//!                          ├─ inline (no task)
14//!                          └─ task ──▶ ④ Task
15//!                                        │
16//!                                        ├──▶ Run₀ ──plan──▶ Plan_v1
17//!                                        ├──▶ Run₁ ──plan──▶ Plan_v2
18//!                                        │
19//!                                        ▼
20//!                                    ⑤  Run (execution)
21//! ```
22//!
23//! # Status Transitions
24//!
25//! ```text
26//! Draft ──▶ Running ──▶ Done
27//!   │          │
28//!   ├──────────┴──▶ Failed
29//!   └──────────────▶ Cancelled
30//! ```
31//!
32//! # Relationships
33//!
34//! | Field | Target | Cardinality | Notes |
35//! |-------|--------|-------------|-------|
36//! | `parent` | Task | 0..1 | Back-reference to parent Task for sub-Tasks |
37//! | `intent` | Intent | 0..1 | Originating user request |
38//! | `runs` | Run | 0..N | Chronological execution history |
39//! | `dependencies` | Task | 0..N | Must complete before this Task starts |
40//!
41//! Reverse references:
42//! - `PlanStep.task` → this Task (forward link from Plan)
43//! - `Run.task` → this Task (each Run knows its owner)
44//!
45//! # Replanning
46//!
47//! When a Run fails or the agent determines the plan needs revision,
48//! a new [`Plan`](super::plan::Plan) revision is created. The **Task
49//! stays the same** — it is the stable identity for the work. Only
50//! the strategy (Plan) evolves:
51//!
52//! ```text
53//! Task (constant)                Intent (constant, plan updated)
54//!   │                              └─ plan ──▶ Plan_v2 (latest)
55//!   └─ runs:
56//!        Run₀ ──plan──▶ Plan_v1   (snapshot: original plan)
57//!        Run₁ ──plan──▶ Plan_v2   (snapshot: revised plan)
58//! ```
59//!
60//! # Purpose
61//!
62//! - **Stable Identity**: The Task persists across retries and
63//!   replanning. All Runs, regardless of which Plan version they
64//!   executed, belong to the same Task.
65//! - **Scope Definition**: `constraints` and `acceptance_criteria`
66//!   define what the agent must and must not do, and how success is
67//!   measured.
68//! - **Hierarchy**: `parent` enables recursive decomposition — a
69//!   PlanStep can spawn a sub-Task, which in turn has its own Plan
70//!   and Runs.
71//! - **Dependency Management**: `dependencies` enables ordering
72//!   between sibling Tasks (e.g. "implement API before writing
73//!   tests").
74
75use std::{fmt, str::FromStr};
76
77use serde::{Deserialize, Serialize};
78use uuid::Uuid;
79
80use crate::{
81    errors::GitError,
82    hash::ObjectHash,
83    internal::object::{
84        ObjectTrait,
85        types::{ActorRef, Header, ObjectType},
86    },
87};
88
89/// Lifecycle status of a [`Task`].
90///
91/// See module docs for the status transition diagram.
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93#[serde(rename_all = "snake_case")]
94pub enum TaskStatus {
95    /// Initial state. Task definition is in progress — title,
96    /// constraints, and acceptance criteria may still be changing.
97    Draft,
98    /// An agent (via a [`Run`](super::run::Run)) is actively working
99    /// on this Task. At least one Run in `Task.runs` is active.
100    Running,
101    /// Task completed successfully. All acceptance criteria met and
102    /// the final PatchSet has been committed.
103    Done,
104    /// Task failed to complete after all retry attempts. The
105    /// [`Decision`](super::decision::Decision) of the last Run
106    /// explains the failure.
107    Failed,
108    /// Task was cancelled by the user or orchestrator before
109    /// completion (e.g. timeout, budget exceeded, user interrupt).
110    Cancelled,
111}
112
113impl TaskStatus {
114    pub fn as_str(&self) -> &'static str {
115        match self {
116            TaskStatus::Draft => "draft",
117            TaskStatus::Running => "running",
118            TaskStatus::Done => "done",
119            TaskStatus::Failed => "failed",
120            TaskStatus::Cancelled => "cancelled",
121        }
122    }
123}
124
125impl fmt::Display for TaskStatus {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        write!(f, "{}", self.as_str())
128    }
129}
130
131/// Classification of the work a [`Task`] aims to accomplish.
132///
133/// Helps agents choose appropriate strategies and tools. For example,
134/// a `Bugfix` task might prioritize reading test output, while a
135/// `Refactor` task might focus on code structure analysis.
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137#[serde(rename_all = "snake_case")]
138pub enum GoalType {
139    Feature,
140    Bugfix,
141    Refactor,
142    Docs,
143    Perf,
144    Test,
145    Chore,
146    Build,
147    Ci,
148    Style,
149    /// Catch-all for goal categories not covered by the predefined
150    /// variants. The inner string is the custom category name.
151    Other(String),
152}
153
154impl GoalType {
155    pub fn as_str(&self) -> &str {
156        match self {
157            GoalType::Feature => "feature",
158            GoalType::Bugfix => "bugfix",
159            GoalType::Refactor => "refactor",
160            GoalType::Docs => "docs",
161            GoalType::Perf => "perf",
162            GoalType::Test => "test",
163            GoalType::Chore => "chore",
164            GoalType::Build => "build",
165            GoalType::Ci => "ci",
166            GoalType::Style => "style",
167            GoalType::Other(s) => s.as_str(),
168        }
169    }
170}
171
172impl fmt::Display for GoalType {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        write!(f, "{}", self.as_str())
175    }
176}
177
178impl FromStr for GoalType {
179    type Err = String;
180
181    fn from_str(value: &str) -> Result<Self, Self::Err> {
182        match value {
183            "feature" => Ok(GoalType::Feature),
184            "bugfix" => Ok(GoalType::Bugfix),
185            "refactor" => Ok(GoalType::Refactor),
186            "docs" => Ok(GoalType::Docs),
187            "perf" => Ok(GoalType::Perf),
188            "test" => Ok(GoalType::Test),
189            "chore" => Ok(GoalType::Chore),
190            "build" => Ok(GoalType::Build),
191            "ci" => Ok(GoalType::Ci),
192            "style" => Ok(GoalType::Style),
193            _ => Ok(GoalType::Other(value.to_string())),
194        }
195    }
196}
197
198/// A unit of work with constraints and success criteria.
199///
200/// A Task can be **top-level** (created directly from a user request)
201/// or a **sub-Task** (spawned by a [`PlanStep`](super::plan::PlanStep)
202/// for recursive decomposition). It is step ④ in the end-to-end flow.
203/// See module documentation for lifecycle, relationships, and
204/// replanning semantics.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct Task {
207    /// Common header (object ID, type, timestamps, creator, etc.).
208    #[serde(flatten)]
209    header: Header,
210    /// Short human-readable summary of the work to be done.
211    ///
212    /// Analogous to a git commit subject line or a Jira ticket title.
213    /// Should be concise (under 100 characters) and describe the
214    /// desired outcome, not the method. Set once at creation.
215    title: String,
216    /// Extended description providing additional context.
217    ///
218    /// May include background information, links to relevant docs or
219    /// issues, and any details that don't fit in `title`. `None` when
220    /// the title is self-explanatory.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    description: Option<String>,
223    /// Classification of the work (Feature, Bugfix, Refactor, etc.).
224    ///
225    /// Helps agents choose appropriate strategies. For example, a
226    /// `Bugfix` task might prioritize reading test output, while a
227    /// `Docs` task focuses on documentation files. `None` when the
228    /// category is unclear or not relevant.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    goal: Option<GoalType>,
231    /// Hard constraints the solution must satisfy.
232    ///
233    /// Each entry is a natural-language rule (e.g. "Must use JWT",
234    /// "No breaking API changes", "Keep backward compatibility with
235    /// v2"). The agent must verify all constraints are met before
236    /// marking the Task as `Done`. Empty when there are no constraints.
237    #[serde(default, skip_serializing_if = "Vec::is_empty")]
238    constraints: Vec<String>,
239    /// Criteria that must be met for the Task to be considered done.
240    ///
241    /// Each entry is a testable condition (e.g. "All tests pass",
242    /// "Coverage >= 80%", "No clippy warnings"). The
243    /// [`Evidence`](super::evidence::Evidence) produced during a Run
244    /// should demonstrate that these criteria are satisfied. Empty
245    /// when success is implied (e.g. "just do it").
246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
247    acceptance_criteria: Vec<String>,
248    /// The actor who requested this work.
249    ///
250    /// May differ from `created_by` in the header when an agent
251    /// creates a Task on behalf of a user. For example, the
252    /// orchestrator (`created_by = system`) might create a Task
253    /// requested by a human (`requester = human`). `None` when the
254    /// requester is the same as the creator.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    requester: Option<ActorRef>,
257    /// Parent Task that spawned this sub-Task.
258    ///
259    /// Provides O(1) reverse navigation from a sub-Task back to its
260    /// parent. Set when a [`PlanStep`](super::plan::PlanStep) creates
261    /// a sub-Task via `PlanStep.task`. `None` for top-level Tasks.
262    ///
263    /// The forward direction is `PlanStep.task → sub-Task`; this field
264    /// is the corresponding back-reference.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    parent: Option<Uuid>,
267    /// Back-reference to the [`Intent`](super::intent::Intent) that
268    /// motivated this Task.
269    ///
270    /// Provides O(1) reverse navigation from work unit to the
271    /// originating user request:
272    /// - **Top-level Task**: points to the root Intent.
273    /// - **Sub-Task with own analysis**: points to a new sub-Intent.
274    /// - **Sub-Task (pure delegation)**: `None` — context is already
275    ///   captured in the parent PlanStep's `iframes`/`oframes`.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    intent: Option<Uuid>,
278    /// Chronological list of [`Run`](super::run::Run) IDs that have
279    /// executed (or are executing) this Task.
280    ///
281    /// Append-only — each new Run is pushed to the end. The last
282    /// entry is the most recent attempt. A Task may have multiple
283    /// Runs due to retries (after a `Decision::Retry`) or parallel
284    /// execution experiments. Empty when no Run has been created yet.
285    #[serde(default, skip_serializing_if = "Vec::is_empty")]
286    runs: Vec<Uuid>,
287    /// Other Tasks that must complete before this one can start.
288    ///
289    /// Used for ordering sibling Tasks within a Plan (e.g. "implement
290    /// API" must complete before "write integration tests"). Empty
291    /// when there are no ordering constraints.
292    #[serde(default, skip_serializing_if = "Vec::is_empty")]
293    dependencies: Vec<Uuid>,
294    /// Current lifecycle status.
295    ///
296    /// Updated by the orchestrator as the Task progresses. See
297    /// [`TaskStatus`] for valid transitions and semantics.
298    status: TaskStatus,
299}
300
301impl Task {
302    /// Create a new Task.
303    ///
304    /// # Arguments
305    /// * `created_by` - Actor creating the task
306    /// * `title` - Short summary of the task
307    /// * `goal` - Optional classification (Feature, Bugfix, etc.)
308    pub fn new(
309        created_by: ActorRef,
310        title: impl Into<String>,
311        goal: Option<GoalType>,
312    ) -> Result<Self, String> {
313        Ok(Self {
314            header: Header::new(ObjectType::Task, created_by)?,
315            title: title.into(),
316            description: None,
317            goal,
318            constraints: Vec::new(),
319            acceptance_criteria: Vec::new(),
320            requester: None,
321            parent: None,
322            intent: None,
323            runs: Vec::new(),
324            dependencies: Vec::new(),
325            status: TaskStatus::Draft,
326        })
327    }
328
329    pub fn header(&self) -> &Header {
330        &self.header
331    }
332
333    pub fn title(&self) -> &str {
334        &self.title
335    }
336
337    pub fn description(&self) -> Option<&str> {
338        self.description.as_deref()
339    }
340
341    pub fn goal(&self) -> Option<&GoalType> {
342        self.goal.as_ref()
343    }
344
345    pub fn constraints(&self) -> &[String] {
346        &self.constraints
347    }
348
349    pub fn acceptance_criteria(&self) -> &[String] {
350        &self.acceptance_criteria
351    }
352
353    pub fn requester(&self) -> Option<&ActorRef> {
354        self.requester.as_ref()
355    }
356
357    /// Returns the parent Task ID, if this is a sub-Task.
358    pub fn parent(&self) -> Option<Uuid> {
359        self.parent
360    }
361
362    pub fn intent(&self) -> Option<Uuid> {
363        self.intent
364    }
365
366    /// Returns the chronological list of Run IDs for this task.
367    pub fn runs(&self) -> &[Uuid] {
368        &self.runs
369    }
370
371    pub fn dependencies(&self) -> &[Uuid] {
372        &self.dependencies
373    }
374
375    pub fn status(&self) -> &TaskStatus {
376        &self.status
377    }
378
379    pub fn set_description(&mut self, description: Option<String>) {
380        self.description = description;
381    }
382
383    pub fn add_constraint(&mut self, constraint: impl Into<String>) {
384        self.constraints.push(constraint.into());
385    }
386
387    pub fn add_acceptance_criterion(&mut self, criterion: impl Into<String>) {
388        self.acceptance_criteria.push(criterion.into());
389    }
390
391    pub fn set_requester(&mut self, requester: Option<ActorRef>) {
392        self.requester = requester;
393    }
394
395    pub fn set_parent(&mut self, parent: Option<Uuid>) {
396        self.parent = parent;
397    }
398
399    pub fn set_intent(&mut self, intent: Option<Uuid>) {
400        self.intent = intent;
401    }
402
403    /// Appends a Run ID to the execution history.
404    pub fn add_run(&mut self, run_id: Uuid) {
405        self.runs.push(run_id);
406    }
407
408    pub fn add_dependency(&mut self, task_id: Uuid) {
409        self.dependencies.push(task_id);
410    }
411
412    pub fn set_status(&mut self, status: TaskStatus) {
413        self.status = status;
414    }
415}
416
417impl fmt::Display for Task {
418    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
419        write!(f, "Task: {}", self.header.object_id())
420    }
421}
422
423impl ObjectTrait for Task {
424    fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
425    where
426        Self: Sized,
427    {
428        serde_json::from_slice(data).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
429    }
430
431    fn get_type(&self) -> ObjectType {
432        ObjectType::Task
433    }
434
435    fn get_size(&self) -> usize {
436        match serde_json::to_vec(self) {
437            Ok(v) => v.len(),
438            Err(e) => {
439                tracing::warn!("failed to compute Task size: {}", e);
440                0
441            }
442        }
443    }
444
445    fn to_data(&self) -> Result<Vec<u8>, GitError> {
446        serde_json::to_vec(self).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::internal::object::types::ActorKind;
454
455    #[test]
456    fn test_task_creation() {
457        let actor = ActorRef::human("jackie").expect("actor");
458        let mut task = Task::new(actor, "Fix bug", Some(GoalType::Bugfix)).expect("task");
459
460        // Test dependencies
461        let dep_id = Uuid::from_u128(0x00000000000000000000000000000001);
462        task.add_dependency(dep_id);
463
464        assert_eq!(task.header().object_type(), &ObjectType::Task);
465        assert_eq!(task.status(), &TaskStatus::Draft);
466        assert_eq!(task.goal(), Some(&GoalType::Bugfix));
467        assert_eq!(task.dependencies().len(), 1);
468        assert_eq!(task.dependencies()[0], dep_id);
469        assert!(task.intent().is_none());
470    }
471
472    #[test]
473    fn test_task_goal_optional() {
474        let actor = ActorRef::human("jackie").expect("actor");
475        let task = Task::new(actor, "Write docs", None).expect("task");
476
477        assert!(task.goal().is_none());
478    }
479
480    #[test]
481    fn test_task_requester() {
482        let actor = ActorRef::human("jackie").expect("actor");
483        let mut task = Task::new(actor.clone(), "Fix bug", Some(GoalType::Bugfix)).expect("task");
484
485        task.set_requester(Some(ActorRef::mcp_client("vscode-client").expect("actor")));
486
487        assert!(task.requester().is_some());
488        assert_eq!(task.requester().unwrap().kind(), &ActorKind::McpClient);
489    }
490
491    #[test]
492    fn test_task_runs() {
493        let actor = ActorRef::human("jackie").expect("actor");
494        let mut task = Task::new(actor, "Fix bug", Some(GoalType::Bugfix)).expect("task");
495
496        assert!(task.runs().is_empty());
497
498        let run1 = Uuid::from_u128(0x10);
499        let run2 = Uuid::from_u128(0x20);
500        task.add_run(run1);
501        task.add_run(run2);
502
503        assert_eq!(task.runs(), &[run1, run2]);
504    }
505
506    #[test]
507    fn test_task_from_bytes_without_header_version() {
508        // Old format data without header_version — should still parse
509        let json = serde_json::json!({
510            "object_id": "01234567-89ab-cdef-0123-456789abcdef",
511            "object_type": "task",
512            "schema_version": 1,
513            "created_at": "2026-01-01T00:00:00Z",
514            "updated_at": "2026-01-01T00:00:00Z",
515            "created_by": {"kind": "human", "id": "jackie"},
516            "visibility": "private",
517            "title": "old task",
518            "status": "draft"
519        });
520        let bytes = serde_json::to_vec(&json).unwrap();
521        let task =
522            Task::from_bytes(&bytes, ObjectHash::default()).expect("should parse old format");
523        assert_eq!(task.title(), "old task");
524        assert_eq!(task.header().header_version(), 1);
525    }
526
527    #[test]
528    fn test_task_serialization_includes_header_version() {
529        let actor = ActorRef::human("jackie").expect("actor");
530        let task = Task::new(actor, "New task", None).expect("task");
531        let data = task.to_data().expect("serialize");
532        let value: serde_json::Value = serde_json::from_slice(&data).unwrap();
533        assert_eq!(
534            value["header_version"],
535            crate::internal::object::types::CURRENT_HEADER_VERSION
536        );
537    }
538}