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}