1use 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#[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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub enum GateResult {
60 Passed { evidence_ids: Vec<Uuid> },
62 Failed { reason: String },
64 Pending,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct TaskSnapshot {
75 pub task_id: TaskId,
76 pub name: String,
77 pub state: TaskState,
78 pub blocked_by: Vec<TaskId>,
80 pub gates: Vec<(GateType, GateResult)>,
82 pub last_transition_at: Option<DateTime<Utc>>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub resource_usage: Option<CommandResourceUsage>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub token_usage: Option<TokenUsage>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub budget_breach_reason: Option<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct RunSnapshot {
98 pub run_id: RunId,
99 pub state: RunState,
100 pub tasks: Vec<TaskSnapshot>,
101 pub gates: Vec<(GateType, GateResult)>,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
112pub enum RunStatus {
113 Done,
115 Active,
117 Blocked,
119 Failed,
121 Cancelled,
123 Pending,
125}
126
127#[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 pub reason: String,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct GateFailure {
140 pub gate_type: GateType,
141 pub entity_id: String,
143 pub reason: String,
145 pub evidence_ids: Vec<Uuid>,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct BlockerChainLink {
152 pub entity_name: String,
153 pub relation: BlockerRelation,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum BlockerRelation {
160 BlockedBy,
162 Failed,
164 GateFailed,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct SuggestedAction {
171 pub description: String,
173 pub command: Option<String>,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct BudgetBreachSummary {
180 pub task_name: String,
181 pub reason: String,
182}
183
184#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct DeteriorationFactor {
196 pub name: String,
197 pub impact: f64,
198 pub detail: String,
199}
200
201#[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#[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 pub budget_breaches: Vec<BudgetBreachSummary>,
223}
224
225pub 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
252pub 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
317fn 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 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 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
417fn 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 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(¤t_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; }
476 current_id = next_id;
477 }
478
479 if path.len() > 1 {
480 return path; }
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#[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}