Skip to main content

yarli_cli/yarli-core/src/entities/
task.rs

1//! Task entity — smallest schedulable unit with dependencies and evidence.
2//!
3//! A `Task` belongs to a `Run`, tracks its own lifecycle via the Task FSM
4//! (Section 7.2), carries dependency edges, evidence requirements, and
5//! an attempt counter for retry semantics.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use crate::yarli_core::domain::{CommandClass, CorrelationId, EventId, RunId, TaskId};
12use crate::yarli_core::error::TransitionError;
13use crate::yarli_core::fsm::task::TaskState;
14
15use super::transition::Transition;
16
17/// A blocker code explaining why a task is in `TaskBlocked`.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum BlockerCode {
21    /// Blocked by another task that hasn't completed.
22    DependencyPending,
23    /// Blocked by a merge conflict.
24    MergeConflict,
25    /// Blocked by a policy denial.
26    PolicyDenial,
27    /// Blocked by a gate failure.
28    GateFailure,
29    /// Blocked by manual hold from operator.
30    ManualHold,
31    /// Custom blocker with description.
32    Custom(String),
33}
34
35/// A task within a run.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Task {
38    /// Unique task identifier (UUIDv7 for time-ordering).
39    pub id: TaskId,
40    /// The run this task belongs to.
41    pub run_id: RunId,
42    /// Human-readable key for deduplication (unique per run).
43    pub task_key: String,
44    /// Description of what this task should accomplish.
45    pub description: String,
46    /// Current FSM state.
47    pub state: TaskState,
48    /// Command class for concurrency caps.
49    pub command_class: CommandClass,
50    /// Tasks this task depends on (must be `TaskComplete` before this can run).
51    pub depends_on: Vec<TaskId>,
52    /// Evidence IDs attached to this task.
53    pub evidence_ids: Vec<Uuid>,
54    /// Current attempt number (starts at 1, incremented on retry).
55    pub attempt_no: u32,
56    /// Maximum allowed attempts before permanent failure.
57    pub max_attempts: u32,
58    /// Blocker code when in `TaskBlocked` state.
59    pub blocker: Option<BlockerCode>,
60    /// Root-cause error message preserved across state changes.
61    /// Set on first failure; not overwritten by subsequent kills or timeouts.
62    pub last_error: Option<String>,
63    /// Free-form annotation for blocker context (e.g. "see blocker-001.md").
64    /// Set by external agents or the `task annotate` CLI command.
65    pub blocker_detail: Option<String>,
66    /// Correlation ID (inherited from parent run).
67    pub correlation_id: CorrelationId,
68    /// When the task was created.
69    pub created_at: DateTime<Utc>,
70    /// When the task last changed state.
71    pub updated_at: DateTime<Utc>,
72    /// Priority for queue ordering (0-100, higher is more urgent).
73    pub priority: u32,
74}
75
76fn clamp_task_priority(priority: u32) -> u32 {
77    priority.min(100)
78}
79
80impl Task {
81    /// Create a new task in `TaskOpen` state.
82    pub fn new(
83        run_id: RunId,
84        task_key: impl Into<String>,
85        description: impl Into<String>,
86        command_class: CommandClass,
87        correlation_id: CorrelationId,
88    ) -> Self {
89        let now = Utc::now();
90        Self {
91            id: Uuid::now_v7(),
92            run_id,
93            task_key: task_key.into(),
94            description: description.into(),
95            state: TaskState::TaskOpen,
96            command_class,
97            depends_on: Vec::new(),
98            evidence_ids: Vec::new(),
99            attempt_no: 1,
100            max_attempts: 3,
101            blocker: None,
102            last_error: None,
103            blocker_detail: None,
104            correlation_id,
105            created_at: now,
106            updated_at: now,
107            priority: 3,
108        }
109    }
110
111    /// Add a dependency on another task.
112    pub fn depends_on(&mut self, task_id: TaskId) {
113        if !self.depends_on.contains(&task_id) {
114            self.depends_on.push(task_id);
115        }
116    }
117
118    /// Set the task priority (0-100, higher is more urgent).
119    pub fn with_priority(mut self, priority: u32) -> Self {
120        self.priority = clamp_task_priority(priority);
121        self
122    }
123
124    /// Set the maximum number of attempts.
125    pub fn with_max_attempts(mut self, max: u32) -> Self {
126        self.max_attempts = max;
127        self
128    }
129
130    /// Set the root-cause error message. Only sets if not already populated,
131    /// preserving the original failure reason across subsequent state changes.
132    pub fn set_last_error(&mut self, error: impl Into<String>) {
133        if self.last_error.is_none() {
134            self.last_error = Some(error.into());
135        }
136    }
137
138    /// Set the blocker detail annotation.
139    pub fn set_blocker_detail(&mut self, detail: impl Into<String>) {
140        self.blocker_detail = Some(detail.into());
141    }
142
143    /// Clear the blocker detail annotation.
144    pub fn clear_blocker_detail(&mut self) {
145        self.blocker_detail = None;
146    }
147
148    /// Attach evidence to this task.
149    pub fn attach_evidence(&mut self, evidence_id: Uuid) {
150        if !self.evidence_ids.contains(&evidence_id) {
151            self.evidence_ids.push(evidence_id);
152        }
153    }
154
155    /// Attempt a state transition. Returns a `Transition` event on success.
156    ///
157    /// Enforces Section 7.2 rules:
158    /// - Terminal states are immutable.
159    /// - Only valid transitions are allowed.
160    /// - `TaskBlocked` requires a blocker code.
161    /// - Retry from `TaskFailed` to `TaskReady` increments attempt_no.
162    pub fn transition(
163        &mut self,
164        to: TaskState,
165        reason: impl Into<String>,
166        actor: impl Into<String>,
167        causation_id: Option<EventId>,
168    ) -> Result<Transition, TransitionError> {
169        let from = self.state;
170
171        if from.is_terminal() {
172            // Allow retry: TaskFailed -> TaskReady
173            if from == TaskState::TaskFailed && to == TaskState::TaskReady {
174                if self.attempt_no >= self.max_attempts {
175                    return Err(TransitionError::TerminalState(format!(
176                        "{from:?} (max attempts {} reached)",
177                        self.max_attempts
178                    )));
179                }
180                // Retry: increment attempt_no
181                self.attempt_no += 1;
182            } else {
183                return Err(TransitionError::TerminalState(format!("{from:?}")));
184            }
185        }
186
187        if !from.can_transition_to(to) {
188            return Err(TransitionError::InvalidTaskTransition { from, to });
189        }
190
191        let reason_str = reason.into();
192        let actor_str = actor.into();
193
194        self.state = to;
195        self.updated_at = Utc::now();
196
197        // Clear blocker when leaving blocked state.
198        if from == TaskState::TaskBlocked && to != TaskState::TaskBlocked {
199            self.blocker = None;
200        }
201
202        Ok(Transition::new(
203            "task",
204            self.id,
205            format!("{from:?}"),
206            format!("{to:?}"),
207            reason_str,
208            actor_str,
209            self.correlation_id,
210            causation_id,
211        ))
212    }
213
214    /// Transition to `TaskBlocked` with a mandatory blocker code.
215    pub fn block(
216        &mut self,
217        blocker: BlockerCode,
218        reason: impl Into<String>,
219        actor: impl Into<String>,
220        causation_id: Option<EventId>,
221    ) -> Result<Transition, TransitionError> {
222        self.blocker = Some(blocker);
223        self.transition(TaskState::TaskBlocked, reason, actor, causation_id)
224    }
225
226    /// Check if all dependencies are satisfied given a predicate.
227    pub fn dependencies_satisfied<F>(&self, is_complete: F) -> bool
228    where
229        F: Fn(&TaskId) -> bool,
230    {
231        self.depends_on.iter().all(is_complete)
232    }
233}