Skip to main content

yarli_cli/yarli-core/src/
explain.rs

1//! "Why Not Done?" explain engine.
2//!
3//! Core domain function that computes structured [`ExplainResult`] from
4//! run/task/gate state. Every rendering mode (stream, CLI, dashboard)
5//! consumes this same structure.
6//!
7//! Answerable at every level:
8//! - **Run level**: "2 tasks remain — task/test FAILED, task/gate-verify blocked by test"
9//! - **Task level**: "gate.tests_passed FAILED — 3/47 tests failing"
10//! - **Gate level**: checklist of pass/fail per gate with evidence links
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::yarli_core::domain::{RunId, TaskId};
17use crate::yarli_core::entities::command_execution::{CommandResourceUsage, TokenUsage};
18use crate::yarli_core::fsm::run::RunState;
19use crate::yarli_core::fsm::task::TaskState;
20
21// ---------------------------------------------------------------------------
22// Gate types (Section 11.1)
23// ---------------------------------------------------------------------------
24
25/// Gate type identifiers as defined in Section 11.1 of the spec.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum GateType {
29    RequiredTasksClosed,
30    RequiredEvidencePresent,
31    TestsPassed,
32    NoUnapprovedGitOps,
33    NoUnresolvedConflicts,
34    WorktreeConsistent,
35    PolicyClean,
36}
37
38impl GateType {
39    /// Human-readable label for display.
40    pub fn label(self) -> &'static str {
41        match self {
42            GateType::RequiredTasksClosed => "gate.required_tasks_closed",
43            GateType::RequiredEvidencePresent => "gate.required_evidence_present",
44            GateType::TestsPassed => "gate.tests_passed",
45            GateType::NoUnapprovedGitOps => "gate.no_unapproved_git_ops",
46            GateType::NoUnresolvedConflicts => "gate.no_unresolved_conflicts",
47            GateType::WorktreeConsistent => "gate.worktree_consistent",
48            GateType::PolicyClean => "gate.policy_clean",
49        }
50    }
51}
52
53// ---------------------------------------------------------------------------
54// Gate status for explain input
55// ---------------------------------------------------------------------------
56
57/// Result of a single gate evaluation.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub enum GateResult {
60    /// Gate passed with optional evidence references.
61    Passed { evidence_ids: Vec<Uuid> },
62    /// Gate failed with a reason.
63    Failed { reason: String },
64    /// Gate has not been evaluated yet.
65    Pending,
66}
67
68// ---------------------------------------------------------------------------
69// Input types — callers build these from persisted state
70// ---------------------------------------------------------------------------
71
72/// Snapshot of a single task's state for explain computation.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct TaskSnapshot {
75    pub task_id: TaskId,
76    pub name: String,
77    pub state: TaskState,
78    /// Task IDs this task is blocked by (dependency graph edges).
79    pub blocked_by: Vec<TaskId>,
80    /// Gate results for this specific task.
81    pub gates: Vec<(GateType, GateResult)>,
82    /// When the task last changed state.
83    pub last_transition_at: Option<DateTime<Utc>>,
84    /// Resource usage from the last command execution (if available).
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub resource_usage: Option<CommandResourceUsage>,
87    /// Token usage from the last command execution (if available).
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub token_usage: Option<TokenUsage>,
90    /// If the task failed due to budget breach, the failure reason.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub budget_breach_reason: Option<String>,
93}
94
95/// Snapshot of a run's state for explain computation.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct RunSnapshot {
98    pub run_id: RunId,
99    pub state: RunState,
100    pub tasks: Vec<TaskSnapshot>,
101    /// Run-level gates (e.g. required_tasks_closed applies at run level).
102    pub gates: Vec<(GateType, GateResult)>,
103}
104
105// ---------------------------------------------------------------------------
106// Output types — the ExplainResult tree
107// ---------------------------------------------------------------------------
108
109/// High-level run status for display.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
112pub enum RunStatus {
113    /// All tasks complete, all gates passed.
114    Done,
115    /// Actively executing tasks.
116    Active,
117    /// Blocked by task failures or gate failures.
118    Blocked,
119    /// At least one task has failed.
120    Failed,
121    /// Run was cancelled.
122    Cancelled,
123    /// Run just opened, no work started.
124    Pending,
125}
126
127/// Information about a blocking task.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct BlockerInfo {
130    pub task_id: TaskId,
131    pub task_name: String,
132    pub state: TaskState,
133    /// Why this task is a blocker (human-readable).
134    pub reason: String,
135}
136
137/// A failed gate with details.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct GateFailure {
140    pub gate_type: GateType,
141    /// Which entity (run or task) owns this gate.
142    pub entity_id: String,
143    /// Failure reason from the gate evaluation.
144    pub reason: String,
145    /// Evidence IDs that were evaluated (if any).
146    pub evidence_ids: Vec<Uuid>,
147}
148
149/// A link in the blocker chain (transitive dependency path).
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct BlockerChainLink {
152    pub entity_name: String,
153    pub relation: BlockerRelation,
154}
155
156/// Type of blocking relationship.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum BlockerRelation {
160    /// Blocked by another task.
161    BlockedBy,
162    /// Failed directly.
163    Failed,
164    /// Gate failure on this entity.
165    GateFailed,
166}
167
168/// A suggested remediation action.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct SuggestedAction {
171    /// Human-readable description.
172    pub description: String,
173    /// CLI command to perform this action (if applicable).
174    pub command: Option<String>,
175}
176
177/// Summary of a budget breach for operator display.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct BudgetBreachSummary {
180    pub task_name: String,
181    pub reason: String,
182}
183
184/// Direction of sequence deterioration in a rolling window.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "snake_case")]
187pub enum DeteriorationTrend {
188    Improving,
189    Stable,
190    Deteriorating,
191}
192
193/// Top contributing factor to a deterioration score.
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct DeteriorationFactor {
196    pub name: String,
197    pub impact: f64,
198    pub detail: String,
199}
200
201/// Structured observer output for sequence deterioration.
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203pub struct DeteriorationReport {
204    pub score: f64,
205    pub window_size: usize,
206    pub factors: Vec<DeteriorationFactor>,
207    pub trend: DeteriorationTrend,
208}
209
210/// The full explain result — answer to "Why Not Done?"
211///
212/// Computed purely from run/task/gate state snapshots.
213/// Every rendering mode (stream, CLI, dashboard) consumes this.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct ExplainResult {
216    pub status: RunStatus,
217    pub blocking_tasks: Vec<BlockerInfo>,
218    pub failed_gates: Vec<GateFailure>,
219    pub blocker_chain: Vec<BlockerChainLink>,
220    pub suggested_actions: Vec<SuggestedAction>,
221    /// Budget breaches detected across tasks.
222    pub budget_breaches: Vec<BudgetBreachSummary>,
223}
224
225// ---------------------------------------------------------------------------
226// Explain engine — pure computation
227// ---------------------------------------------------------------------------
228
229/// Compute the explain result for a run.
230///
231/// This is the core "Why Not Done?" function. It examines the run state,
232/// task states, and gate results to produce a structured explanation of
233/// what's blocking completion.
234pub fn explain_run(snapshot: &RunSnapshot) -> ExplainResult {
235    let status = compute_run_status(snapshot);
236    let blocking_tasks = find_blocking_tasks(&snapshot.tasks);
237    let failed_gates = find_failed_gates(snapshot);
238    let blocker_chain = compute_blocker_chain(&snapshot.tasks);
239    let budget_breaches = find_budget_breaches(&snapshot.tasks);
240    let suggested_actions = suggest_actions(&blocking_tasks, &failed_gates);
241
242    ExplainResult {
243        status,
244        blocking_tasks,
245        failed_gates,
246        blocker_chain,
247        suggested_actions,
248        budget_breaches,
249    }
250}
251
252/// Compute explain result scoped to a single task.
253pub fn explain_task(task: &TaskSnapshot) -> ExplainResult {
254    let status = if task.state == TaskState::TaskComplete {
255        RunStatus::Done
256    } else if task.state == TaskState::TaskFailed {
257        RunStatus::Failed
258    } else if task.state == TaskState::TaskCancelled {
259        RunStatus::Cancelled
260    } else if task.state == TaskState::TaskBlocked {
261        RunStatus::Blocked
262    } else {
263        RunStatus::Active
264    };
265
266    let failed_gates: Vec<GateFailure> = task
267        .gates
268        .iter()
269        .filter_map(|(gate_type, result)| match result {
270            GateResult::Failed { reason } => Some(GateFailure {
271                gate_type: *gate_type,
272                entity_id: task.name.clone(),
273                reason: reason.clone(),
274                evidence_ids: Vec::new(),
275            }),
276            _ => None,
277        })
278        .collect();
279
280    let mut suggested_actions = Vec::new();
281    if task.state == TaskState::TaskFailed {
282        suggested_actions.push(SuggestedAction {
283            description: format!("Retry the failed task: {}", task.name),
284            command: Some(format!("yarli task retry {}", task.name)),
285        });
286    }
287    for failure in &failed_gates {
288        suggested_actions.push(SuggestedAction {
289            description: format!(
290                "Fix {} failure: {}",
291                failure.gate_type.label(),
292                failure.reason
293            ),
294            command: None,
295        });
296    }
297
298    let budget_breaches = if let Some(ref reason) = task.budget_breach_reason {
299        vec![BudgetBreachSummary {
300            task_name: task.name.clone(),
301            reason: reason.clone(),
302        }]
303    } else {
304        Vec::new()
305    };
306
307    ExplainResult {
308        status,
309        blocking_tasks: Vec::new(),
310        failed_gates,
311        blocker_chain: Vec::new(),
312        suggested_actions,
313        budget_breaches,
314    }
315}
316
317// ---------------------------------------------------------------------------
318// Internal helpers
319// ---------------------------------------------------------------------------
320
321fn compute_run_status(snapshot: &RunSnapshot) -> RunStatus {
322    match snapshot.state {
323        RunState::RunCompleted => RunStatus::Done,
324        RunState::RunCancelled => RunStatus::Cancelled,
325        RunState::RunDrained => RunStatus::Cancelled,
326        RunState::RunFailed => RunStatus::Failed,
327        RunState::RunOpen => RunStatus::Pending,
328        RunState::RunBlocked => {
329            if snapshot
330                .tasks
331                .iter()
332                .any(|t| t.state == TaskState::TaskFailed)
333            {
334                RunStatus::Failed
335            } else {
336                RunStatus::Blocked
337            }
338        }
339        RunState::RunActive | RunState::RunVerifying => RunStatus::Active,
340    }
341}
342
343fn find_blocking_tasks(tasks: &[TaskSnapshot]) -> Vec<BlockerInfo> {
344    tasks
345        .iter()
346        .filter_map(|t| {
347            let reason = match t.state {
348                TaskState::TaskFailed => Some(format!("task/{} FAILED", t.name)),
349                TaskState::TaskBlocked => {
350                    let blockers: Vec<&str> = tasks
351                        .iter()
352                        .filter(|other| t.blocked_by.contains(&other.task_id))
353                        .map(|other| other.name.as_str())
354                        .collect();
355                    if blockers.is_empty() {
356                        Some(format!("task/{} BLOCKED (unknown blocker)", t.name))
357                    } else {
358                        Some(format!(
359                            "task/{} blocked by: {}",
360                            t.name,
361                            blockers.join(", ")
362                        ))
363                    }
364                }
365                TaskState::TaskOpen
366                | TaskState::TaskReady
367                | TaskState::TaskExecuting
368                | TaskState::TaskWaiting
369                | TaskState::TaskVerifying => {
370                    Some(format!("task/{} not yet complete ({:?})", t.name, t.state))
371                }
372                TaskState::TaskComplete | TaskState::TaskCancelled => None,
373            };
374
375            reason.map(|r| BlockerInfo {
376                task_id: t.task_id,
377                task_name: t.name.clone(),
378                state: t.state,
379                reason: r,
380            })
381        })
382        .collect()
383}
384
385fn find_failed_gates(snapshot: &RunSnapshot) -> Vec<GateFailure> {
386    let mut failures = Vec::new();
387
388    // Run-level gates
389    for (gate_type, result) in &snapshot.gates {
390        if let GateResult::Failed { reason } = result {
391            failures.push(GateFailure {
392                gate_type: *gate_type,
393                entity_id: snapshot.run_id.to_string(),
394                reason: reason.clone(),
395                evidence_ids: Vec::new(),
396            });
397        }
398    }
399
400    // Task-level gates
401    for task in &snapshot.tasks {
402        for (gate_type, result) in &task.gates {
403            if let GateResult::Failed { reason } = result {
404                failures.push(GateFailure {
405                    gate_type: *gate_type,
406                    entity_id: task.name.clone(),
407                    reason: reason.clone(),
408                    evidence_ids: Vec::new(),
409                });
410            }
411        }
412    }
413
414    failures
415}
416
417/// Build the blocker chain by following blocked_by edges to root causes.
418///
419/// Produces a flattened path from the first blocked task to the root cause.
420/// Example: gate-verify --blocked-by--> merge-prep --blocked-by--> test --FAILED-->
421fn compute_blocker_chain(tasks: &[TaskSnapshot]) -> Vec<BlockerChainLink> {
422    let task_map: std::collections::HashMap<TaskId, &TaskSnapshot> =
423        tasks.iter().map(|t| (t.task_id, t)).collect();
424
425    // Find blocked tasks and trace each back to root cause
426    for task in tasks {
427        if task.state != TaskState::TaskBlocked {
428            continue;
429        }
430
431        let mut path = vec![BlockerChainLink {
432            entity_name: task.name.clone(),
433            relation: BlockerRelation::BlockedBy,
434        }];
435
436        let mut visited = std::collections::HashSet::new();
437        visited.insert(task.task_id);
438        let mut current_id = task.task_id;
439
440        while let Some(current) = task_map.get(&current_id) {
441            let next = current
442                .blocked_by
443                .iter()
444                .find(|id| !visited.contains(id))
445                .copied();
446
447            let Some(next_id) = next else {
448                break;
449            };
450
451            visited.insert(next_id);
452            let Some(next_task) = task_map.get(&next_id) else {
453                break;
454            };
455
456            let relation = if next_task.state == TaskState::TaskFailed {
457                BlockerRelation::Failed
458            } else if next_task
459                .gates
460                .iter()
461                .any(|(_, r)| matches!(r, GateResult::Failed { .. }))
462            {
463                BlockerRelation::GateFailed
464            } else {
465                BlockerRelation::BlockedBy
466            };
467
468            path.push(BlockerChainLink {
469                entity_name: next_task.name.clone(),
470                relation,
471            });
472
473            if relation == BlockerRelation::Failed || relation == BlockerRelation::GateFailed {
474                break; // reached root cause
475            }
476            current_id = next_id;
477        }
478
479        if path.len() > 1 {
480            return path; // return the first complete chain found
481        }
482    }
483
484    Vec::new()
485}
486
487fn find_budget_breaches(tasks: &[TaskSnapshot]) -> Vec<BudgetBreachSummary> {
488    tasks
489        .iter()
490        .filter_map(|t| {
491            t.budget_breach_reason
492                .as_ref()
493                .map(|reason| BudgetBreachSummary {
494                    task_name: t.name.clone(),
495                    reason: reason.clone(),
496                })
497        })
498        .collect()
499}
500
501fn suggest_actions(
502    blocking_tasks: &[BlockerInfo],
503    failed_gates: &[GateFailure],
504) -> Vec<SuggestedAction> {
505    let mut actions = Vec::new();
506
507    for blocker in blocking_tasks {
508        if blocker.state == TaskState::TaskFailed {
509            actions.push(SuggestedAction {
510                description: format!("Fix failures and re-run: {}", blocker.task_name),
511                command: Some(format!("yarli task retry {}", blocker.task_name)),
512            });
513        } else if blocker.state == TaskState::TaskBlocked {
514            actions.push(SuggestedAction {
515                description: format!("Unblock task: {}", blocker.task_name),
516                command: Some(format!(
517                    "yarli task unblock {} --reason \"manual override\"",
518                    blocker.task_name
519                )),
520            });
521        }
522    }
523
524    for gate in failed_gates {
525        actions.push(SuggestedAction {
526            description: format!(
527                "Resolve {} on {}: {}",
528                gate.gate_type.label(),
529                gate.entity_id,
530                gate.reason
531            ),
532            command: None,
533        });
534    }
535
536    actions
537}
538
539// ---------------------------------------------------------------------------
540// Tests
541// ---------------------------------------------------------------------------
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    fn make_task(name: &str, state: TaskState) -> TaskSnapshot {
548        TaskSnapshot {
549            task_id: Uuid::new_v4(),
550            name: name.to_string(),
551            state,
552            blocked_by: Vec::new(),
553            gates: Vec::new(),
554            last_transition_at: None,
555            resource_usage: None,
556            token_usage: None,
557            budget_breach_reason: None,
558        }
559    }
560
561    #[test]
562    fn completed_run_reports_done() {
563        let snapshot = RunSnapshot {
564            run_id: Uuid::new_v4(),
565            state: RunState::RunCompleted,
566            tasks: vec![make_task("build", TaskState::TaskComplete)],
567            gates: vec![(
568                GateType::RequiredTasksClosed,
569                GateResult::Passed {
570                    evidence_ids: vec![],
571                },
572            )],
573        };
574
575        let result = explain_run(&snapshot);
576        assert_eq!(result.status, RunStatus::Done);
577        assert!(result.blocking_tasks.is_empty());
578        assert!(result.failed_gates.is_empty());
579        assert!(result.suggested_actions.is_empty());
580    }
581
582    #[test]
583    fn failed_task_shows_as_blocker() {
584        let snapshot = RunSnapshot {
585            run_id: Uuid::new_v4(),
586            state: RunState::RunBlocked,
587            tasks: vec![
588                make_task("lint", TaskState::TaskComplete),
589                make_task("test", TaskState::TaskFailed),
590            ],
591            gates: vec![],
592        };
593
594        let result = explain_run(&snapshot);
595        assert_eq!(result.status, RunStatus::Failed);
596        assert_eq!(result.blocking_tasks.len(), 1);
597        assert_eq!(result.blocking_tasks[0].task_name, "test");
598        assert!(result.blocking_tasks[0].reason.contains("FAILED"));
599        assert!(!result.suggested_actions.is_empty());
600        assert!(result.suggested_actions[0]
601            .command
602            .as_ref()
603            .unwrap()
604            .contains("retry"));
605    }
606
607    #[test]
608    fn blocked_task_chain() {
609        let test_id = Uuid::new_v4();
610        let merge_id = Uuid::new_v4();
611        let gate_id = Uuid::new_v4();
612
613        let mut test_task = make_task("test", TaskState::TaskFailed);
614        test_task.task_id = test_id;
615
616        let mut merge_task = make_task("merge-prep", TaskState::TaskBlocked);
617        merge_task.task_id = merge_id;
618        merge_task.blocked_by = vec![test_id];
619
620        let mut gate_task = make_task("gate-verify", TaskState::TaskBlocked);
621        gate_task.task_id = gate_id;
622        gate_task.blocked_by = vec![merge_id];
623
624        let snapshot = RunSnapshot {
625            run_id: Uuid::new_v4(),
626            state: RunState::RunBlocked,
627            tasks: vec![gate_task, merge_task, test_task],
628            gates: vec![],
629        };
630
631        let result = explain_run(&snapshot);
632        assert_eq!(result.status, RunStatus::Failed);
633        assert!(result.blocker_chain.len() >= 2);
634        assert_eq!(result.blocker_chain[0].entity_name, "gate-verify");
635        assert_eq!(result.blocker_chain[0].relation, BlockerRelation::BlockedBy);
636
637        let last = result.blocker_chain.last().unwrap();
638        assert_eq!(last.entity_name, "test");
639        assert_eq!(last.relation, BlockerRelation::Failed);
640    }
641
642    #[test]
643    fn gate_failure_surfaced() {
644        let snapshot = RunSnapshot {
645            run_id: Uuid::new_v4(),
646            state: RunState::RunActive,
647            tasks: vec![{
648                let mut t = make_task("build", TaskState::TaskVerifying);
649                t.gates = vec![(
650                    GateType::TestsPassed,
651                    GateResult::Failed {
652                        reason: "3/47 tests failing".to_string(),
653                    },
654                )];
655                t
656            }],
657            gates: vec![],
658        };
659
660        let result = explain_run(&snapshot);
661        assert_eq!(result.failed_gates.len(), 1);
662        assert_eq!(result.failed_gates[0].gate_type, GateType::TestsPassed);
663        assert!(result.failed_gates[0].reason.contains("3/47"));
664    }
665
666    #[test]
667    fn run_level_gate_failure() {
668        let snapshot = RunSnapshot {
669            run_id: Uuid::new_v4(),
670            state: RunState::RunVerifying,
671            tasks: vec![make_task("build", TaskState::TaskComplete)],
672            gates: vec![(
673                GateType::PolicyClean,
674                GateResult::Failed {
675                    reason: "unapproved policy override".to_string(),
676                },
677            )],
678        };
679
680        let result = explain_run(&snapshot);
681        assert_eq!(result.failed_gates.len(), 1);
682        assert_eq!(result.failed_gates[0].gate_type, GateType::PolicyClean);
683    }
684
685    #[test]
686    fn pending_run() {
687        let snapshot = RunSnapshot {
688            run_id: Uuid::new_v4(),
689            state: RunState::RunOpen,
690            tasks: vec![make_task("init", TaskState::TaskOpen)],
691            gates: vec![],
692        };
693
694        let result = explain_run(&snapshot);
695        assert_eq!(result.status, RunStatus::Pending);
696        assert_eq!(result.blocking_tasks.len(), 1);
697    }
698
699    #[test]
700    fn explain_task_failed() {
701        let mut task = make_task("test", TaskState::TaskFailed);
702        task.gates = vec![(
703            GateType::TestsPassed,
704            GateResult::Failed {
705                reason: "exit code 1".to_string(),
706            },
707        )];
708
709        let result = explain_task(&task);
710        assert_eq!(result.status, RunStatus::Failed);
711        assert_eq!(result.failed_gates.len(), 1);
712        assert!(!result.suggested_actions.is_empty());
713    }
714
715    #[test]
716    fn explain_task_complete() {
717        let task = make_task("build", TaskState::TaskComplete);
718        let result = explain_task(&task);
719        assert_eq!(result.status, RunStatus::Done);
720        assert!(result.failed_gates.is_empty());
721        assert!(result.suggested_actions.is_empty());
722    }
723
724    #[test]
725    fn gate_type_labels() {
726        assert_eq!(
727            GateType::RequiredTasksClosed.label(),
728            "gate.required_tasks_closed"
729        );
730        assert_eq!(GateType::TestsPassed.label(), "gate.tests_passed");
731        assert_eq!(GateType::PolicyClean.label(), "gate.policy_clean");
732    }
733
734    #[test]
735    fn cancelled_run() {
736        let snapshot = RunSnapshot {
737            run_id: Uuid::new_v4(),
738            state: RunState::RunCancelled,
739            tasks: vec![make_task("build", TaskState::TaskCancelled)],
740            gates: vec![],
741        };
742
743        let result = explain_run(&snapshot);
744        assert_eq!(result.status, RunStatus::Cancelled);
745    }
746
747    #[test]
748    fn active_run_with_executing_tasks() {
749        let snapshot = RunSnapshot {
750            run_id: Uuid::new_v4(),
751            state: RunState::RunActive,
752            tasks: vec![
753                make_task("lint", TaskState::TaskComplete),
754                make_task("build", TaskState::TaskExecuting),
755                make_task("test", TaskState::TaskReady),
756            ],
757            gates: vec![],
758        };
759
760        let result = explain_run(&snapshot);
761        assert_eq!(result.status, RunStatus::Active);
762        assert_eq!(result.blocking_tasks.len(), 2);
763    }
764
765    #[test]
766    fn blocker_chain_with_gate_failure_root() {
767        let build_id = Uuid::new_v4();
768        let deploy_id = Uuid::new_v4();
769
770        let mut build_task = make_task("build", TaskState::TaskVerifying);
771        build_task.task_id = build_id;
772        build_task.gates = vec![(
773            GateType::TestsPassed,
774            GateResult::Failed {
775                reason: "2 tests failing".to_string(),
776            },
777        )];
778
779        let mut deploy_task = make_task("deploy", TaskState::TaskBlocked);
780        deploy_task.task_id = deploy_id;
781        deploy_task.blocked_by = vec![build_id];
782
783        let snapshot = RunSnapshot {
784            run_id: Uuid::new_v4(),
785            state: RunState::RunActive,
786            tasks: vec![deploy_task, build_task],
787            gates: vec![],
788        };
789
790        let result = explain_run(&snapshot);
791        assert!(result.blocker_chain.len() >= 2);
792        assert_eq!(result.blocker_chain[0].entity_name, "deploy");
793        assert_eq!(result.blocker_chain[1].entity_name, "build");
794        assert_eq!(
795            result.blocker_chain[1].relation,
796            BlockerRelation::GateFailed
797        );
798    }
799
800    #[test]
801    fn budget_exceeded_task_surfaces_breach_in_run_explain() {
802        let mut task = make_task("compute", TaskState::TaskFailed);
803        task.budget_breach_reason =
804            Some("budget_exceeded: task max_task_total_tokens observed=5000 limit=1".to_string());
805        task.token_usage = Some(TokenUsage {
806            prompt_tokens: 2500,
807            completion_tokens: 2500,
808            total_tokens: 5000,
809            rehydration_tokens: None,
810            source: "char_count_div4_estimate_v1".to_string(),
811        });
812
813        let snapshot = RunSnapshot {
814            run_id: Uuid::new_v4(),
815            state: RunState::RunBlocked,
816            tasks: vec![task],
817            gates: vec![],
818        };
819
820        let result = explain_run(&snapshot);
821        assert_eq!(result.status, RunStatus::Failed);
822        assert_eq!(result.budget_breaches.len(), 1);
823        assert_eq!(result.budget_breaches[0].task_name, "compute");
824        assert!(result.budget_breaches[0].reason.contains("budget_exceeded"));
825    }
826
827    #[test]
828    fn budget_exceeded_task_explain_surfaces_breach() {
829        let mut task = make_task("compute", TaskState::TaskFailed);
830        task.budget_breach_reason =
831            Some("budget_exceeded: task max_task_total_tokens observed=5000 limit=1".to_string());
832        task.resource_usage = Some(CommandResourceUsage {
833            max_rss_bytes: Some(1024 * 1024),
834            cpu_user_ticks: Some(100),
835            cpu_system_ticks: Some(50),
836            io_read_bytes: Some(4096),
837            io_write_bytes: Some(2048),
838        });
839
840        let result = explain_task(&task);
841        assert_eq!(result.status, RunStatus::Failed);
842        assert_eq!(result.budget_breaches.len(), 1);
843        assert!(result.budget_breaches[0].reason.contains("budget_exceeded"));
844    }
845
846    #[test]
847    fn no_breach_when_task_succeeds() {
848        let task = make_task("build", TaskState::TaskComplete);
849        let result = explain_task(&task);
850        assert!(result.budget_breaches.is_empty());
851    }
852
853    #[test]
854    fn token_usage_accessible_on_task_snapshot() {
855        let mut task = make_task("llm-call", TaskState::TaskComplete);
856        task.token_usage = Some(TokenUsage {
857            prompt_tokens: 1000,
858            completion_tokens: 500,
859            total_tokens: 1500,
860            rehydration_tokens: None,
861            source: "char_count_div4_estimate_v1".to_string(),
862        });
863        assert_eq!(task.token_usage.as_ref().unwrap().prompt_tokens, 1000);
864        assert_eq!(task.token_usage.as_ref().unwrap().completion_tokens, 500);
865        assert_eq!(task.token_usage.as_ref().unwrap().total_tokens, 1500);
866    }
867
868    #[test]
869    fn resource_usage_accessible_on_task_snapshot() {
870        let mut task = make_task("heavy-compute", TaskState::TaskComplete);
871        task.resource_usage = Some(CommandResourceUsage {
872            max_rss_bytes: Some(2 * 1024 * 1024 * 1024),
873            cpu_user_ticks: Some(500),
874            cpu_system_ticks: Some(200),
875            io_read_bytes: Some(1024 * 1024),
876            io_write_bytes: Some(512 * 1024),
877        });
878        assert_eq!(
879            task.resource_usage.as_ref().unwrap().max_rss_bytes,
880            Some(2 * 1024 * 1024 * 1024)
881        );
882    }
883}