1use chrono::Utc;
14use serde::{Deserialize, Serialize};
15use std::fmt::Write as _;
16use std::path::{Path, PathBuf};
17
18use crate::error::SessionStoreError;
19use crate::session_dir;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum MilestoneStatus {
25 Pending,
27 InProgress,
29 Done,
31 Blocked,
33}
34
35impl MilestoneStatus {
36 #[must_use]
38 fn is_terminal(&self) -> bool {
39 matches!(self, MilestoneStatus::Done)
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct Milestone {
46 pub id: String,
48 pub description: String,
50 pub status: MilestoneStatus,
52}
53
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct ProgressLedger {
62 pub session_id: String,
64 pub goal: String,
66 milestones: Vec<Milestone>,
68 pub confidence: f32,
70 stalled_since: Option<String>,
73 updated_at: String,
75 #[serde(default)]
78 previous_session_id: Option<String>,
79 #[serde(default)]
81 handoff_summary: Option<String>,
82 #[serde(default)]
84 known_issues: Vec<String>,
85 #[serde(default)]
87 git_checkpoint: Option<String>,
88}
89
90impl ProgressLedger {
91 #[must_use]
93 pub fn new(session_id: &str, goal: &str) -> Self {
94 let ts = Utc::now().to_rfc3339();
95 Self {
96 session_id: session_id.to_string(),
97 goal: goal.to_string(),
98 milestones: Vec::new(),
99 confidence: 1.0,
100 stalled_since: None,
101 updated_at: ts,
102 previous_session_id: None,
103 handoff_summary: None,
104 known_issues: Vec::new(),
105 git_checkpoint: None,
106 }
107 }
108
109 #[must_use]
112 pub fn completion_ratio(&self) -> f32 {
113 if self.milestones.is_empty() {
114 return 1.0;
115 }
116 let done = self.milestones.iter().filter(|m| m.status.is_terminal()).count() as f32;
117 done / self.milestones.len() as f32
118 }
119
120 #[must_use]
122 pub fn is_complete(&self) -> bool {
123 self.completion_ratio() >= 1.0
124 }
125
126 #[must_use]
128 pub fn is_stalled(&self) -> bool {
129 self.stalled_since.is_some()
130 }
131
132 pub fn note_advance(&mut self) {
135 self.stalled_since = None;
136 self.confidence = (self.confidence + 0.05).min(1.0);
137 self.updated_at = Utc::now().to_rfc3339();
138 }
139
140 pub fn note_stall(&mut self) {
143 if self.stalled_since.is_none() {
144 self.stalled_since = Some(Utc::now().to_rfc3339());
145 }
146 self.confidence = (self.confidence - 0.1).max(0.0);
147 self.updated_at = Utc::now().to_rfc3339();
148 }
149
150 pub fn set_milestones(&mut self, milestones: Vec<Milestone>) {
152 self.milestones = milestones;
153 self.updated_at = Utc::now().to_rfc3339();
154 }
155
156 pub fn set_goal(&mut self, goal: &str) {
158 self.goal = goal.to_string();
159 self.updated_at = Utc::now().to_rfc3339();
160 }
161
162 fn set_handoff(&mut self, previous_session_id: &str, summary: &str, git_checkpoint: Option<String>) {
164 self.previous_session_id = Some(previous_session_id.to_string());
165 self.handoff_summary = Some(summary.to_string());
166 self.git_checkpoint = git_checkpoint;
167 self.updated_at = Utc::now().to_rfc3339();
168 }
169
170 fn add_known_issue(&mut self, issue: &str) {
172 self.known_issues.push(issue.to_string());
173 self.updated_at = Utc::now().to_rfc3339();
174 }
175
176 #[must_use]
181 pub fn to_markdown(&self) -> String {
182 let mut out = String::new();
183 out.push_str("# Session Progress\n\n");
184 let _ = writeln!(out, "**Goal:** {}", self.goal);
185 let _ = writeln!(out, "**Completion:** {:.0}%", (self.completion_ratio() * 100.0).round());
186 let _ = writeln!(out, "**Confidence:** {:.2}", self.confidence);
187 if let Some(since) = &self.stalled_since {
188 let _ = writeln!(out, "**Stalled since:** {since}");
189 }
190 let _ = writeln!(out, "**Updated:** {}\n", self.updated_at);
191
192 if let Some(prev) = &self.previous_session_id {
193 let _ = writeln!(out, "**Handed off from:** {prev}");
194 }
195 if let Some(summary) = &self.handoff_summary {
196 let _ = writeln!(out, "**Handoff summary:** {summary}");
197 }
198 if let Some(checkpoint) = &self.git_checkpoint {
199 let _ = writeln!(out, "**Git checkpoint:** `{checkpoint}`");
200 }
201 if !self.known_issues.is_empty() {
202 out.push_str("\n## Known Issues\n\n");
203 for issue in &self.known_issues {
204 let _ = writeln!(out, "- {issue}");
205 }
206 }
207
208 if self.milestones.is_empty() {
209 out.push_str("\n_No tracked milestones yet._\n");
210 } else {
211 out.push_str("\n## Milestones\n\n");
212 for m in &self.milestones {
213 let mark = match m.status {
214 MilestoneStatus::Done => "[x]",
215 MilestoneStatus::InProgress => "[~]",
216 MilestoneStatus::Blocked => "[!]",
217 MilestoneStatus::Pending => "[ ]",
218 };
219 let _ = writeln!(out, "{} {} — {}", mark, m.id, m.description);
220 }
221 }
222 out
223 }
224}
225
226#[must_use]
228pub fn progress_path(workspace: &Path, session_id: &str) -> PathBuf {
229 session_dir(workspace, session_id)
230 .join(crate::DERIVED_DIR)
231 .join("progress.json")
232}
233
234pub fn load_progress(workspace: &Path, session_id: &str) -> Result<Option<ProgressLedger>, SessionStoreError> {
239 let path = progress_path(workspace, session_id);
240 if !path.exists() {
241 return Ok(None);
242 }
243 let bytes = std::fs::read(&path).map_err(|e| SessionStoreError::io(path.clone(), e))?;
244 let ledger: ProgressLedger = serde_json::from_slice(&bytes)?;
245 Ok(Some(ledger))
246}
247
248pub fn save_progress(workspace: &Path, session_id: &str, ledger: &ProgressLedger) -> Result<(), SessionStoreError> {
250 let path = progress_path(workspace, session_id);
251 if let Some(parent) = path.parent() {
252 std::fs::create_dir_all(parent)
253 .map_err(|e| SessionStoreError::CreateDir { path: parent.to_path_buf(), source: e })?;
254 }
255 let bytes = serde_json::to_vec(ledger)?;
256 std::fs::write(&path, bytes).map_err(|e| SessionStoreError::io(path, e))?;
257 Ok(())
258}
259
260use std::time::Instant;
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "snake_case")]
269pub enum GoalPhase {
270 Idle,
272 Planning,
274 Executing,
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
292#[serde(rename_all = "snake_case")]
293pub enum GoalStatus {
294 #[serde(alias = "Active")]
296 Active,
297 #[serde(alias = "Paused")]
299 UserPaused,
300 BackOffPaused,
302 NoProgressPaused,
304 InfraPaused,
306 Blocked,
308 #[serde(alias = "BudgetLimited")]
310 BudgetLimited,
311 #[serde(alias = "Complete")]
313 Complete,
314}
315
316impl<'de> Deserialize<'de> for GoalStatus {
317 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
318 where
319 D: serde::Deserializer<'de>,
320 {
321 let s = String::deserialize(deserializer)?;
322 Ok(Self::from_wire_str(&s))
323 }
324}
325
326impl GoalStatus {
327 fn from_wire_str(s: &str) -> Self {
331 match s {
332 "active" | "Active" => Self::Active,
333 "user_paused" | "paused" | "Paused" => Self::UserPaused,
334 "doom_loop_paused" => Self::UserPaused,
335 "back_off_paused" => Self::BackOffPaused,
336 "no_progress_paused" => Self::NoProgressPaused,
337 "infra_paused" => Self::InfraPaused,
338 "blocked" => Self::Blocked,
339 "budget_limited" | "BudgetLimited" => Self::BudgetLimited,
340 "complete" | "Complete" => Self::Complete,
341 _ => Self::UserPaused,
342 }
343 }
344
345 fn is_paused(&self) -> bool {
347 matches!(
348 self,
349 Self::UserPaused | Self::BackOffPaused | Self::NoProgressPaused | Self::InfraPaused | Self::Blocked
350 )
351 }
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum GoalPauseReason {
358 User,
360 BackOff,
362 NoProgress,
364 Verification,
366 Infra,
368}
369
370impl GoalPauseReason {
371 fn to_status(self) -> GoalStatus {
372 match self {
373 Self::User => GoalStatus::UserPaused,
374 Self::BackOff => GoalStatus::BackOffPaused,
375 Self::NoProgress => GoalStatus::NoProgressPaused,
376 Self::Verification => GoalStatus::Blocked,
377 Self::Infra => GoalStatus::InfraPaused,
378 }
379 }
380
381 fn history_detail(self) -> &'static str {
382 match self {
383 Self::User => "user",
384 Self::BackOff => "back_off",
385 Self::NoProgress => "no_progress",
386 Self::Verification => "blocked",
387 Self::Infra => "infra",
388 }
389 }
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "snake_case")]
395pub enum GoalClassifierVerdict {
396 Achieved,
398 NotAchieved,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
404#[serde(rename_all = "snake_case")]
405pub enum GoalEvent {
406 GoalCreated,
408 PlanningStarted,
410 PlanningCompleted,
412 PlanningFailed,
414 WorkerStarted,
416 WorkerCompleted,
418 WorkerFailed,
420 ContextRotated,
422 GoalPaused,
424 GoalResumed,
426 GoalCompleted,
428 GoalCleared,
430 BudgetExceeded,
432 PrematureStopDetected,
434 #[serde(other)]
436 Unknown,
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct GoalHistoryEntry {
442 timestamp: String,
444 event: GoalEvent,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
448 detail: Option<String>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 round: Option<u32>,
452 #[serde(default, skip_serializing_if = "Option::is_none")]
454 tokens_used: Option<i64>,
455 #[serde(default, skip_serializing_if = "Vec::is_empty")]
457 unmet: Vec<String>,
458}
459
460impl GoalHistoryEntry {
461 fn now(event: GoalEvent, detail: Option<String>) -> Self {
462 Self {
463 timestamp: Utc::now().to_rfc3339(),
464 event,
465 detail,
466 round: None,
467 tokens_used: None,
468 unmet: Vec::new(),
469 }
470 }
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct GoalOrchestration {
476 goal_id: String,
478 objective: String,
480 status: GoalStatus,
482 phase: GoalPhase,
484 token_budget: Option<i64>,
486 elapsed_ms: u64,
488 created_at: String,
490 current_subagent_id: Option<String>,
492 current_subagent_role: Option<String>,
494 #[serde(default)]
496 total_worker_rounds: u32,
497 #[serde(default)]
499 total_verify_rounds: u32,
500 #[serde(skip)]
502 budget_limit_reported: bool,
503 #[serde(default)]
505 token_baseline: i64,
506 #[serde(default)]
508 tokens_used_high_water: i64,
509 #[serde(default)]
511 parent_tokens_spent: i64,
512 #[serde(default)]
514 last_session_tokens_seen: Option<i64>,
515 history: Vec<GoalHistoryEntry>,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pause_message: Option<String>,
520 #[serde(default)]
522 classifier_stall_count: u32,
523 #[serde(default)]
525 classifier_runs_attempted: u32,
526 #[serde(default)]
528 rounds_since_verify: u32,
529 #[serde(default)]
531 consecutive_not_achieved: u32,
532 #[serde(default)]
534 last_strategist_fired_at: u32,
535 #[serde(default)]
537 strategist_cap_bonus: u32,
538 #[serde(default, skip_serializing_if = "Option::is_none")]
540 last_strategy_path: Option<String>,
541 #[serde(default, skip_serializing_if = "Option::is_none")]
543 last_strategy_recommendation: Option<String>,
544 #[serde(default, skip_serializing_if = "Option::is_none")]
546 changes_baseline_commit: Option<String>,
547 #[serde(default, skip_serializing_if = "Option::is_none")]
549 last_gap_fingerprint: Option<String>,
550 #[serde(skip)]
552 live_subagent_tokens: u64,
553 #[serde(skip)]
555 live_tokens_by_model: Vec<(String, u64)>,
556 #[serde(skip)]
558 live_context_window: u64,
559 #[serde(skip)]
561 live_context_pct: u8,
562 #[serde(skip)]
564 live_turn_count: u32,
565 #[serde(skip)]
567 live_tool_call_count: u32,
568 #[serde(skip)]
570 planning_in_flight: bool,
571 #[serde(skip)]
573 verifying_in_flight: bool,
574}
575
576impl GoalOrchestration {
577 fn reset_strategist_fields(&mut self) {
578 self.consecutive_not_achieved = 0;
579 self.last_strategist_fired_at = 0;
580 self.strategist_cap_bonus = 0;
581 self.last_strategy_path = None;
582 self.last_strategy_recommendation = None;
583 }
584
585 fn reset_classifier_stall_fields(&mut self) {
586 self.classifier_stall_count = 0;
587 self.last_gap_fingerprint = None;
588 }
589}
590
591#[derive(Debug)]
593pub struct GoalTracker {
594 orchestration: Option<GoalOrchestration>,
596 session_dir: PathBuf,
598 active_since: Option<Instant>,
600}
601
602impl GoalTracker {
603 fn new(session_dir: PathBuf) -> Self {
605 Self {
606 orchestration: None,
607 session_dir,
608 active_since: None,
609 }
610 }
611
612 pub fn from_snapshot(session_dir: PathBuf, mut snapshot: GoalOrchestration) -> Self {
614 match snapshot.phase {
615 GoalPhase::Planning | GoalPhase::Executing => {
616 snapshot.phase = GoalPhase::Idle;
617 if snapshot.status == GoalStatus::Active {
618 snapshot.status = GoalStatus::UserPaused;
619 }
620 snapshot.current_subagent_id = None;
621 snapshot.current_subagent_role = None;
622 }
623 GoalPhase::Idle => {}
624 }
625 snapshot.planning_in_flight = false;
626 snapshot.verifying_in_flight = false;
627 let active_since = if snapshot.status == GoalStatus::Active {
628 Some(Instant::now())
629 } else {
630 None
631 };
632 Self {
633 orchestration: Some(snapshot),
634 session_dir,
635 active_since,
636 }
637 }
638
639 fn snapshot(&self) -> Option<&GoalOrchestration> {
641 self.orchestration.as_ref()
642 }
643
644 pub fn snapshot_mut(&mut self) -> Option<&mut GoalOrchestration> {
646 self.orchestration.as_mut()
647 }
648
649 fn is_active(&self) -> bool {
651 self.orchestration.as_ref().is_some_and(|o| o.status == GoalStatus::Active)
652 }
653
654 fn phase(&self) -> Option<GoalPhase> {
656 self.orchestration.as_ref().map(|o| o.phase)
657 }
658
659 fn status(&self) -> Option<GoalStatus> {
661 self.orchestration.as_ref().map(|o| o.status)
662 }
663
664 fn current_subagent_id(&self) -> Option<&str> {
666 self.orchestration.as_ref().and_then(|o| o.current_subagent_id.as_deref())
667 }
668
669 fn objective(&self) -> Option<&str> {
671 self.orchestration.as_ref().map(|o| o.objective.as_str())
672 }
673
674 fn token_budget(&self) -> Option<i64> {
676 self.orchestration.as_ref().and_then(|o| o.token_budget)
677 }
678
679 fn create_goal(
681 &mut self,
682 goal_id: String,
683 objective: String,
684 token_budget: Option<i64>,
685 token_baseline: i64,
686 created_at: String,
687 baseline_commit: Option<String>,
688 ) {
689 let _ = std::fs::create_dir_all(self.goal_dir());
690 if self.orchestration.is_some() {
691 self.remove_scratch_root();
692 }
693 self.orchestration = Some(GoalOrchestration {
694 goal_id,
695 objective,
696 status: GoalStatus::Active,
697 phase: GoalPhase::Executing,
698 token_budget,
699 elapsed_ms: 0,
700 created_at,
701 current_subagent_id: None,
702 current_subagent_role: None,
703 total_worker_rounds: 0,
704 total_verify_rounds: 0,
705 budget_limit_reported: false,
706 token_baseline,
707 tokens_used_high_water: 0,
708 parent_tokens_spent: 0,
709 last_session_tokens_seen: Some(token_baseline),
710 history: Vec::new(),
711 pause_message: None,
712 classifier_stall_count: 0,
713 classifier_runs_attempted: 0,
714 rounds_since_verify: 0,
715 consecutive_not_achieved: 0,
716 last_strategist_fired_at: 0,
717 strategist_cap_bonus: 0,
718 last_strategy_path: None,
719 last_strategy_recommendation: None,
720 changes_baseline_commit: baseline_commit,
721 last_gap_fingerprint: None,
722 live_subagent_tokens: 0,
723 live_tokens_by_model: Vec::new(),
724 live_context_window: 0,
725 live_context_pct: 0,
726 live_turn_count: 0,
727 live_tool_call_count: 0,
728 planning_in_flight: false,
729 verifying_in_flight: false,
730 });
731 self.active_since = Some(Instant::now());
732 self.record_event(GoalEvent::GoalCreated, None);
733 }
734
735 fn set_phase(&mut self, phase: GoalPhase) {
737 if let Some(o) = &mut self.orchestration {
738 o.phase = phase;
739 }
740 }
741
742 fn set_current_subagent(&mut self, id: Option<String>, role: Option<String>) {
744 if let Some(o) = &mut self.orchestration {
745 o.current_subagent_id = id;
746 o.current_subagent_role = role;
747 }
748 }
749
750 fn pause(&mut self, reason: GoalPauseReason) -> bool {
752 self.pause_inner(reason, None)
753 }
754
755 fn pause_with_message(&mut self, reason: GoalPauseReason, message: String) -> bool {
757 self.pause_inner(reason, Some(message))
758 }
759
760 fn pause_inner(&mut self, reason: GoalPauseReason, message: Option<String>) -> bool {
761 let applied = if let Some(o) = &mut self.orchestration
762 && o.status == GoalStatus::Active
763 {
764 if let Some(since) = self.active_since.take() {
765 o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
766 }
767 o.status = reason.to_status();
768 if message.is_some() {
769 o.pause_message = message;
770 }
771 true
772 } else {
773 false
774 };
775 if applied {
776 self.record_event(GoalEvent::GoalPaused, Some(reason.history_detail().to_owned()));
777 }
778 applied
779 }
780
781 fn resume(&mut self) -> bool {
783 if let Some(o) = &mut self.orchestration
784 && o.status.is_paused()
785 {
786 o.status = GoalStatus::Active;
787 o.pause_message = None;
788 o.classifier_runs_attempted = 0;
789 o.rounds_since_verify = 0;
790 o.reset_strategist_fields();
791 o.reset_classifier_stall_fields();
792 self.active_since = Some(Instant::now());
793 self.record_event(GoalEvent::GoalResumed, None);
794 return true;
795 }
796 false
797 }
798
799 fn complete(&mut self) -> bool {
801 if let Some(o) = &mut self.orchestration
802 && (o.status == GoalStatus::Active || o.status.is_paused())
803 {
804 if let Some(since) = self.active_since.take() {
805 o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
806 }
807 o.status = GoalStatus::Complete;
808 o.phase = GoalPhase::Idle;
809 o.current_subagent_id = None;
810 o.current_subagent_role = None;
811 o.pause_message = None;
812 o.reset_strategist_fields();
813 self.record_event(GoalEvent::GoalCompleted, None);
814 return true;
815 }
816 false
817 }
818
819 fn budget_limit(&mut self) -> bool {
821 if let Some(o) = &mut self.orchestration
822 && (o.status == GoalStatus::Active || o.status.is_paused())
823 {
824 if let Some(since) = self.active_since.take() {
825 o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
826 }
827 o.status = GoalStatus::BudgetLimited;
828 o.phase = GoalPhase::Idle;
829 o.current_subagent_id = None;
830 o.current_subagent_role = None;
831 o.pause_message = None;
832 o.reset_strategist_fields();
833 self.record_event(GoalEvent::BudgetExceeded, None);
834 return true;
835 }
836 false
837 }
838
839 pub fn clear(&mut self) {
841 self.orchestration = None;
842 self.active_since = None;
843 }
844
845 fn goal_dir(&self) -> PathBuf {
846 self.session_dir.join("goal")
847 }
848
849 fn remove_scratch_root(&self) {
850 let _ = std::fs::remove_dir_all(self.session_dir.join("goal"));
851 }
852
853 pub fn account_elapsed(&mut self) {
855 if let Some(o) = &mut self.orchestration
856 && let Some(since) = self.active_since
857 {
858 o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
859 self.active_since = Some(since);
860 }
861 }
862
863 fn record_classifier_stall(&mut self, fingerprint: &str) -> bool {
866 let Some(o) = self.orchestration.as_mut() else {
867 return false;
868 };
869 if o.last_gap_fingerprint.as_deref() == Some(fingerprint) {
870 o.classifier_stall_count = o.classifier_stall_count.saturating_add(1);
871 } else {
872 o.last_gap_fingerprint = Some(fingerprint.to_string());
873 o.classifier_stall_count = 1;
874 }
875 o.classifier_stall_count >= 2
876 }
877
878 pub fn rollback_classifier_attempt(&mut self) {
880 if let Some(o) = self.orchestration.as_mut() {
881 o.classifier_runs_attempted = o.classifier_runs_attempted.saturating_sub(1);
882 }
883 }
884
885 fn reset_classifier_stall(&mut self) {
887 if let Some(o) = self.orchestration.as_mut() {
888 o.reset_classifier_stall_fields();
889 }
890 }
891
892 fn record_not_achieved_streak(&mut self) -> u32 {
894 match self.orchestration.as_mut() {
895 Some(o) => {
896 o.consecutive_not_achieved = o.consecutive_not_achieved.saturating_add(1);
897 o.consecutive_not_achieved
898 }
899 None => 0,
900 }
901 }
902
903 pub fn claim_strategist_fire(&mut self, should_fire: impl Fn(u32, u32) -> bool) -> Option<u32> {
905 let o = self.orchestration.as_mut()?;
906 if should_fire(o.consecutive_not_achieved, o.last_strategist_fired_at) {
907 o.last_strategist_fired_at = o.consecutive_not_achieved;
908 o.strategist_cap_bonus = 3;
909 o.reset_classifier_stall_fields();
910 Some(o.consecutive_not_achieved)
911 } else {
912 None
913 }
914 }
915
916 pub fn revoke_strategist_cap_bonus(&mut self) {
918 if let Some(o) = self.orchestration.as_mut() {
919 o.strategist_cap_bonus = 0;
920 }
921 }
922
923 fn reset_strategist_state(&mut self) {
925 if let Some(o) = self.orchestration.as_mut() {
926 o.reset_strategist_fields();
927 }
928 }
929
930 fn record_strategy_recommendation(&mut self, path: String, recommendation: String) {
932 if let Some(o) = self.orchestration.as_mut() {
933 o.last_strategy_path = Some(path);
934 o.last_strategy_recommendation = Some(recommendation);
935 }
936 }
937
938 fn append_history(&mut self, entry: GoalHistoryEntry) {
940 if let Some(o) = &mut self.orchestration {
941 o.history.push(entry);
942 }
943 }
944
945 fn record_event(&mut self, event: GoalEvent, detail: Option<String>) {
946 self.append_history(GoalHistoryEntry::now(event, detail));
947 }
948}
949
950#[cfg(test)]
951mod goal_tracker_tests;
952#[cfg(test)]
953mod progress_tests;