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
260#[cfg(test)]
261mod progress_tests {
262 use super::*;
263
264 fn sample_ledger() -> ProgressLedger {
265 let mut l = ProgressLedger::new("s1", "ship the feature");
266 l.set_milestones(vec![
267 Milestone {
268 id: "1".into(),
269 description: "design".into(),
270 status: MilestoneStatus::Done,
271 },
272 Milestone {
273 id: "2".into(),
274 description: "implement".into(),
275 status: MilestoneStatus::InProgress,
276 },
277 Milestone {
278 id: "3".into(),
279 description: "verify".into(),
280 status: MilestoneStatus::Pending,
281 },
282 ]);
283 l
284 }
285
286 #[test]
287 fn completion_ratio_reflects_terminal_milestones() {
288 let l = sample_ledger();
289 assert!((l.completion_ratio() - 1.0 / 3.0).abs() < f32::EPSILON);
290 assert!(!l.is_complete());
291 }
292
293 #[test]
294 fn empty_ledger_is_complete() {
295 let l = ProgressLedger::new("s", "goal");
296 assert!(l.is_complete());
297 assert!((l.completion_ratio() - 1.0).abs() < f32::EPSILON);
298 }
299
300 #[test]
301 fn advance_clears_stall_and_bumps_confidence() {
302 let mut l = sample_ledger();
303 l.note_stall();
304 assert!(l.is_stalled());
305 let before = l.confidence;
306 l.note_advance();
307 assert!(!l.is_stalled());
308 assert!(l.confidence >= before);
309 }
310
311 #[test]
312 fn persistence_round_trips() {
313 let tmp = std::env::temp_dir().join(format!("vtcode-prog-{}", std::process::id()));
314 let ws = tmp.join("ws");
315 std::fs::create_dir_all(&ws).unwrap();
316 let mut l = sample_ledger();
317 l.note_stall();
318 save_progress(&ws, "s1", &l).unwrap();
319 let loaded = load_progress(&ws, "s1").unwrap().expect("ledger present");
320 assert_eq!(loaded, l);
321 assert!(loaded.is_stalled());
322 assert!(load_progress(&ws, "absent").unwrap().is_none());
323 let _ = std::fs::remove_dir_all(&tmp);
324 }
325
326 #[test]
327 fn handoff_metadata_defaults_to_none() {
328 let l = ProgressLedger::new("s1", "goal");
329 assert!(l.previous_session_id.is_none());
330 assert!(l.handoff_summary.is_none());
331 assert!(l.known_issues.is_empty());
332 assert!(l.git_checkpoint.is_none());
333 }
334
335 #[test]
336 fn set_handoff_records_metadata() {
337 let mut l = ProgressLedger::new("s2", "goal");
338 l.set_handoff("s1", "implemented login", Some("abc123".to_string()));
339 assert_eq!(l.previous_session_id.as_deref(), Some("s1"));
340 assert_eq!(l.handoff_summary.as_deref(), Some("implemented login"));
341 assert_eq!(l.git_checkpoint.as_deref(), Some("abc123"));
342 }
343
344 #[test]
345 fn add_known_issue_accumulates() {
346 let mut l = ProgressLedger::new("s3", "goal");
347 l.add_known_issue("rate limiting missing");
348 l.add_known_issue("no error handling for timeouts");
349 assert_eq!(l.known_issues.len(), 2);
350 assert_eq!(l.known_issues[0], "rate limiting missing");
351 }
352
353 #[test]
354 fn handoff_metadata_survives_persistence() {
355 let tmp = std::env::temp_dir().join(format!("vtcode-prog-{}", std::process::id()));
356 let ws = tmp.join("ws");
357 std::fs::create_dir_all(&ws).unwrap();
358
359 let mut l = sample_ledger();
360 l.set_handoff("prev-session", "built auth", Some("def456".to_string()));
361 l.add_known_issue("tests are flaky");
362
363 save_progress(&ws, "s4", &l).unwrap();
364 let loaded = load_progress(&ws, "s4").unwrap().expect("present");
365 assert_eq!(loaded.previous_session_id.as_deref(), Some("prev-session"));
366 assert_eq!(loaded.handoff_summary.as_deref(), Some("built auth"));
367 assert_eq!(loaded.git_checkpoint.as_deref(), Some("def456"));
368 assert_eq!(loaded.known_issues, vec!["tests are flaky"]);
369
370 let _ = std::fs::remove_dir_all(&tmp);
371 }
372
373 #[test]
374 fn to_markdown_includes_handoff_metadata() {
375 let mut l = ProgressLedger::new("s5", "build feature");
376 l.set_handoff("s4", "implemented core", Some("abc123".to_string()));
377 l.add_known_issue("missing error handling");
378
379 let md = l.to_markdown();
380 assert!(md.contains("Handed off from:** s4"));
381 assert!(md.contains("Handoff summary:** implemented core"));
382 assert!(md.contains("Git checkpoint:** `abc123`"));
383 assert!(md.contains("- missing error handling"));
384 }
385
386 #[test]
387 fn to_markdown_omits_handoff_when_absent() {
388 let l = ProgressLedger::new("s6", "goal");
389 let md = l.to_markdown();
390 assert!(!md.contains("Handed off from"));
391 assert!(!md.contains("Handoff summary"));
392 assert!(!md.contains("Git checkpoint"));
393 assert!(!md.contains("Known Issues"));
394 }
395}
396
397use std::time::Instant;
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(rename_all = "snake_case")]
406pub enum GoalPhase {
407 Idle,
409 Planning,
411 Executing,
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
429#[serde(rename_all = "snake_case")]
430pub enum GoalStatus {
431 #[serde(alias = "Active")]
433 Active,
434 #[serde(alias = "Paused")]
436 UserPaused,
437 BackOffPaused,
439 NoProgressPaused,
441 InfraPaused,
443 Blocked,
445 #[serde(alias = "BudgetLimited")]
447 BudgetLimited,
448 #[serde(alias = "Complete")]
450 Complete,
451}
452
453impl<'de> Deserialize<'de> for GoalStatus {
454 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
455 where
456 D: serde::Deserializer<'de>,
457 {
458 let s = String::deserialize(deserializer)?;
459 Ok(Self::from_wire_str(&s))
460 }
461}
462
463impl GoalStatus {
464 fn from_wire_str(s: &str) -> Self {
468 match s {
469 "active" | "Active" => Self::Active,
470 "user_paused" | "paused" | "Paused" => Self::UserPaused,
471 "doom_loop_paused" => Self::UserPaused,
472 "back_off_paused" => Self::BackOffPaused,
473 "no_progress_paused" => Self::NoProgressPaused,
474 "infra_paused" => Self::InfraPaused,
475 "blocked" => Self::Blocked,
476 "budget_limited" | "BudgetLimited" => Self::BudgetLimited,
477 "complete" | "Complete" => Self::Complete,
478 _ => Self::UserPaused,
479 }
480 }
481
482 fn is_paused(&self) -> bool {
484 matches!(
485 self,
486 Self::UserPaused | Self::BackOffPaused | Self::NoProgressPaused | Self::InfraPaused | Self::Blocked
487 )
488 }
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
494pub enum GoalPauseReason {
495 User,
497 BackOff,
499 NoProgress,
501 Verification,
503 Infra,
505}
506
507impl GoalPauseReason {
508 fn to_status(self) -> GoalStatus {
509 match self {
510 Self::User => GoalStatus::UserPaused,
511 Self::BackOff => GoalStatus::BackOffPaused,
512 Self::NoProgress => GoalStatus::NoProgressPaused,
513 Self::Verification => GoalStatus::Blocked,
514 Self::Infra => GoalStatus::InfraPaused,
515 }
516 }
517
518 fn history_detail(self) -> &'static str {
519 match self {
520 Self::User => "user",
521 Self::BackOff => "back_off",
522 Self::NoProgress => "no_progress",
523 Self::Verification => "blocked",
524 Self::Infra => "infra",
525 }
526 }
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
531#[serde(rename_all = "snake_case")]
532pub enum GoalClassifierVerdict {
533 Achieved,
535 NotAchieved,
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize)]
541#[serde(rename_all = "snake_case")]
542pub enum GoalEvent {
543 GoalCreated,
545 PlanningStarted,
547 PlanningCompleted,
549 PlanningFailed,
551 WorkerStarted,
553 WorkerCompleted,
555 WorkerFailed,
557 ContextRotated,
559 GoalPaused,
561 GoalResumed,
563 GoalCompleted,
565 GoalCleared,
567 BudgetExceeded,
569 PrematureStopDetected,
571 #[serde(other)]
573 Unknown,
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
578pub struct GoalHistoryEntry {
579 timestamp: String,
581 event: GoalEvent,
583 #[serde(default, skip_serializing_if = "Option::is_none")]
585 detail: Option<String>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
588 round: Option<u32>,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
591 tokens_used: Option<i64>,
592 #[serde(default, skip_serializing_if = "Vec::is_empty")]
594 unmet: Vec<String>,
595}
596
597impl GoalHistoryEntry {
598 fn now(event: GoalEvent, detail: Option<String>) -> Self {
599 Self {
600 timestamp: Utc::now().to_rfc3339(),
601 event,
602 detail,
603 round: None,
604 tokens_used: None,
605 unmet: Vec::new(),
606 }
607 }
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize)]
612pub struct GoalOrchestration {
613 goal_id: String,
615 objective: String,
617 status: GoalStatus,
619 phase: GoalPhase,
621 token_budget: Option<i64>,
623 elapsed_ms: u64,
625 created_at: String,
627 current_subagent_id: Option<String>,
629 current_subagent_role: Option<String>,
631 #[serde(default)]
633 total_worker_rounds: u32,
634 #[serde(default)]
636 total_verify_rounds: u32,
637 #[serde(skip)]
639 budget_limit_reported: bool,
640 #[serde(default)]
642 token_baseline: i64,
643 #[serde(default)]
645 tokens_used_high_water: i64,
646 #[serde(default)]
648 parent_tokens_spent: i64,
649 #[serde(default)]
651 last_session_tokens_seen: Option<i64>,
652 history: Vec<GoalHistoryEntry>,
654 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pause_message: Option<String>,
657 #[serde(default)]
659 classifier_stall_count: u32,
660 #[serde(default)]
662 classifier_runs_attempted: u32,
663 #[serde(default)]
665 rounds_since_verify: u32,
666 #[serde(default)]
668 consecutive_not_achieved: u32,
669 #[serde(default)]
671 last_strategist_fired_at: u32,
672 #[serde(default)]
674 strategist_cap_bonus: u32,
675 #[serde(default, skip_serializing_if = "Option::is_none")]
677 last_strategy_path: Option<String>,
678 #[serde(default, skip_serializing_if = "Option::is_none")]
680 last_strategy_recommendation: Option<String>,
681 #[serde(default, skip_serializing_if = "Option::is_none")]
683 changes_baseline_commit: Option<String>,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
686 last_gap_fingerprint: Option<String>,
687 #[serde(skip)]
689 live_subagent_tokens: u64,
690 #[serde(skip)]
692 live_tokens_by_model: Vec<(String, u64)>,
693 #[serde(skip)]
695 live_context_window: u64,
696 #[serde(skip)]
698 live_context_pct: u8,
699 #[serde(skip)]
701 live_turn_count: u32,
702 #[serde(skip)]
704 live_tool_call_count: u32,
705 #[serde(skip)]
707 planning_in_flight: bool,
708 #[serde(skip)]
710 verifying_in_flight: bool,
711}
712
713impl GoalOrchestration {
714 fn reset_strategist_fields(&mut self) {
715 self.consecutive_not_achieved = 0;
716 self.last_strategist_fired_at = 0;
717 self.strategist_cap_bonus = 0;
718 self.last_strategy_path = None;
719 self.last_strategy_recommendation = None;
720 }
721
722 fn reset_classifier_stall_fields(&mut self) {
723 self.classifier_stall_count = 0;
724 self.last_gap_fingerprint = None;
725 }
726}
727
728#[derive(Debug)]
730pub struct GoalTracker {
731 orchestration: Option<GoalOrchestration>,
733 session_dir: PathBuf,
735 active_since: Option<Instant>,
737}
738
739impl GoalTracker {
740 fn new(session_dir: PathBuf) -> Self {
742 Self {
743 orchestration: None,
744 session_dir,
745 active_since: None,
746 }
747 }
748
749 pub fn from_snapshot(session_dir: PathBuf, mut snapshot: GoalOrchestration) -> Self {
751 match snapshot.phase {
752 GoalPhase::Planning | GoalPhase::Executing => {
753 snapshot.phase = GoalPhase::Idle;
754 if snapshot.status == GoalStatus::Active {
755 snapshot.status = GoalStatus::UserPaused;
756 }
757 snapshot.current_subagent_id = None;
758 snapshot.current_subagent_role = None;
759 }
760 GoalPhase::Idle => {}
761 }
762 snapshot.planning_in_flight = false;
763 snapshot.verifying_in_flight = false;
764 let active_since = if snapshot.status == GoalStatus::Active {
765 Some(Instant::now())
766 } else {
767 None
768 };
769 Self {
770 orchestration: Some(snapshot),
771 session_dir,
772 active_since,
773 }
774 }
775
776 fn snapshot(&self) -> Option<&GoalOrchestration> {
778 self.orchestration.as_ref()
779 }
780
781 pub fn snapshot_mut(&mut self) -> Option<&mut GoalOrchestration> {
783 self.orchestration.as_mut()
784 }
785
786 fn is_active(&self) -> bool {
788 self.orchestration.as_ref().is_some_and(|o| o.status == GoalStatus::Active)
789 }
790
791 fn phase(&self) -> Option<GoalPhase> {
793 self.orchestration.as_ref().map(|o| o.phase)
794 }
795
796 fn status(&self) -> Option<GoalStatus> {
798 self.orchestration.as_ref().map(|o| o.status)
799 }
800
801 fn current_subagent_id(&self) -> Option<&str> {
803 self.orchestration.as_ref().and_then(|o| o.current_subagent_id.as_deref())
804 }
805
806 fn objective(&self) -> Option<&str> {
808 self.orchestration.as_ref().map(|o| o.objective.as_str())
809 }
810
811 fn token_budget(&self) -> Option<i64> {
813 self.orchestration.as_ref().and_then(|o| o.token_budget)
814 }
815
816 fn create_goal(
818 &mut self,
819 goal_id: String,
820 objective: String,
821 token_budget: Option<i64>,
822 token_baseline: i64,
823 created_at: String,
824 baseline_commit: Option<String>,
825 ) {
826 let _ = std::fs::create_dir_all(self.goal_dir());
827 if self.orchestration.is_some() {
828 self.remove_scratch_root();
829 }
830 self.orchestration = Some(GoalOrchestration {
831 goal_id,
832 objective,
833 status: GoalStatus::Active,
834 phase: GoalPhase::Executing,
835 token_budget,
836 elapsed_ms: 0,
837 created_at,
838 current_subagent_id: None,
839 current_subagent_role: None,
840 total_worker_rounds: 0,
841 total_verify_rounds: 0,
842 budget_limit_reported: false,
843 token_baseline,
844 tokens_used_high_water: 0,
845 parent_tokens_spent: 0,
846 last_session_tokens_seen: Some(token_baseline),
847 history: Vec::new(),
848 pause_message: None,
849 classifier_stall_count: 0,
850 classifier_runs_attempted: 0,
851 rounds_since_verify: 0,
852 consecutive_not_achieved: 0,
853 last_strategist_fired_at: 0,
854 strategist_cap_bonus: 0,
855 last_strategy_path: None,
856 last_strategy_recommendation: None,
857 changes_baseline_commit: baseline_commit,
858 last_gap_fingerprint: None,
859 live_subagent_tokens: 0,
860 live_tokens_by_model: Vec::new(),
861 live_context_window: 0,
862 live_context_pct: 0,
863 live_turn_count: 0,
864 live_tool_call_count: 0,
865 planning_in_flight: false,
866 verifying_in_flight: false,
867 });
868 self.active_since = Some(Instant::now());
869 self.record_event(GoalEvent::GoalCreated, None);
870 }
871
872 fn set_phase(&mut self, phase: GoalPhase) {
874 if let Some(o) = &mut self.orchestration {
875 o.phase = phase;
876 }
877 }
878
879 fn set_current_subagent(&mut self, id: Option<String>, role: Option<String>) {
881 if let Some(o) = &mut self.orchestration {
882 o.current_subagent_id = id;
883 o.current_subagent_role = role;
884 }
885 }
886
887 fn pause(&mut self, reason: GoalPauseReason) -> bool {
889 self.pause_inner(reason, None)
890 }
891
892 fn pause_with_message(&mut self, reason: GoalPauseReason, message: String) -> bool {
894 self.pause_inner(reason, Some(message))
895 }
896
897 fn pause_inner(&mut self, reason: GoalPauseReason, message: Option<String>) -> bool {
898 let applied = if let Some(o) = &mut self.orchestration
899 && o.status == GoalStatus::Active
900 {
901 if let Some(since) = self.active_since.take() {
902 o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
903 }
904 o.status = reason.to_status();
905 if message.is_some() {
906 o.pause_message = message;
907 }
908 true
909 } else {
910 false
911 };
912 if applied {
913 self.record_event(GoalEvent::GoalPaused, Some(reason.history_detail().to_owned()));
914 }
915 applied
916 }
917
918 fn resume(&mut self) -> bool {
920 if let Some(o) = &mut self.orchestration
921 && o.status.is_paused()
922 {
923 o.status = GoalStatus::Active;
924 o.pause_message = None;
925 o.classifier_runs_attempted = 0;
926 o.rounds_since_verify = 0;
927 o.reset_strategist_fields();
928 o.reset_classifier_stall_fields();
929 self.active_since = Some(Instant::now());
930 self.record_event(GoalEvent::GoalResumed, None);
931 return true;
932 }
933 false
934 }
935
936 fn complete(&mut self) -> bool {
938 if let Some(o) = &mut self.orchestration
939 && (o.status == GoalStatus::Active || o.status.is_paused())
940 {
941 if let Some(since) = self.active_since.take() {
942 o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
943 }
944 o.status = GoalStatus::Complete;
945 o.phase = GoalPhase::Idle;
946 o.current_subagent_id = None;
947 o.current_subagent_role = None;
948 o.pause_message = None;
949 o.reset_strategist_fields();
950 self.record_event(GoalEvent::GoalCompleted, None);
951 return true;
952 }
953 false
954 }
955
956 fn budget_limit(&mut self) -> bool {
958 if let Some(o) = &mut self.orchestration
959 && (o.status == GoalStatus::Active || o.status.is_paused())
960 {
961 if let Some(since) = self.active_since.take() {
962 o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
963 }
964 o.status = GoalStatus::BudgetLimited;
965 o.phase = GoalPhase::Idle;
966 o.current_subagent_id = None;
967 o.current_subagent_role = None;
968 o.pause_message = None;
969 o.reset_strategist_fields();
970 self.record_event(GoalEvent::BudgetExceeded, None);
971 return true;
972 }
973 false
974 }
975
976 pub fn clear(&mut self) {
978 self.orchestration = None;
979 self.active_since = None;
980 }
981
982 fn goal_dir(&self) -> PathBuf {
983 self.session_dir.join("goal")
984 }
985
986 fn remove_scratch_root(&self) {
987 let _ = std::fs::remove_dir_all(self.session_dir.join("goal"));
988 }
989
990 pub fn account_elapsed(&mut self) {
992 if let Some(o) = &mut self.orchestration
993 && let Some(since) = self.active_since
994 {
995 o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
996 self.active_since = Some(since);
997 }
998 }
999
1000 fn record_classifier_stall(&mut self, fingerprint: &str) -> bool {
1003 let Some(o) = self.orchestration.as_mut() else {
1004 return false;
1005 };
1006 if o.last_gap_fingerprint.as_deref() == Some(fingerprint) {
1007 o.classifier_stall_count = o.classifier_stall_count.saturating_add(1);
1008 } else {
1009 o.last_gap_fingerprint = Some(fingerprint.to_string());
1010 o.classifier_stall_count = 1;
1011 }
1012 o.classifier_stall_count >= 2
1013 }
1014
1015 pub fn rollback_classifier_attempt(&mut self) {
1017 if let Some(o) = self.orchestration.as_mut() {
1018 o.classifier_runs_attempted = o.classifier_runs_attempted.saturating_sub(1);
1019 }
1020 }
1021
1022 fn reset_classifier_stall(&mut self) {
1024 if let Some(o) = self.orchestration.as_mut() {
1025 o.reset_classifier_stall_fields();
1026 }
1027 }
1028
1029 fn record_not_achieved_streak(&mut self) -> u32 {
1031 match self.orchestration.as_mut() {
1032 Some(o) => {
1033 o.consecutive_not_achieved = o.consecutive_not_achieved.saturating_add(1);
1034 o.consecutive_not_achieved
1035 }
1036 None => 0,
1037 }
1038 }
1039
1040 pub fn claim_strategist_fire(&mut self, should_fire: impl Fn(u32, u32) -> bool) -> Option<u32> {
1042 let o = self.orchestration.as_mut()?;
1043 if should_fire(o.consecutive_not_achieved, o.last_strategist_fired_at) {
1044 o.last_strategist_fired_at = o.consecutive_not_achieved;
1045 o.strategist_cap_bonus = 3;
1046 o.reset_classifier_stall_fields();
1047 Some(o.consecutive_not_achieved)
1048 } else {
1049 None
1050 }
1051 }
1052
1053 pub fn revoke_strategist_cap_bonus(&mut self) {
1055 if let Some(o) = self.orchestration.as_mut() {
1056 o.strategist_cap_bonus = 0;
1057 }
1058 }
1059
1060 fn reset_strategist_state(&mut self) {
1062 if let Some(o) = self.orchestration.as_mut() {
1063 o.reset_strategist_fields();
1064 }
1065 }
1066
1067 fn record_strategy_recommendation(&mut self, path: String, recommendation: String) {
1069 if let Some(o) = self.orchestration.as_mut() {
1070 o.last_strategy_path = Some(path);
1071 o.last_strategy_recommendation = Some(recommendation);
1072 }
1073 }
1074
1075 fn append_history(&mut self, entry: GoalHistoryEntry) {
1077 if let Some(o) = &mut self.orchestration {
1078 o.history.push(entry);
1079 }
1080 }
1081
1082 fn record_event(&mut self, event: GoalEvent, detail: Option<String>) {
1083 self.append_history(GoalHistoryEntry::now(event, detail));
1084 }
1085}
1086
1087#[cfg(test)]
1088mod goal_tracker_tests {
1089 use super::*;
1090
1091 fn make_tracker() -> GoalTracker {
1092 GoalTracker::new(PathBuf::from("/tmp/test-goal-session"))
1093 }
1094
1095 fn activate_tracker(t: &mut GoalTracker) {
1096 t.create_goal("goal-1".into(), "Build a widget".into(), Some(100_000), 0, "2026-01-01T00:00:00Z".into(), None);
1097 }
1098
1099 #[test]
1100 fn create_goal_activates_and_starts_timer() {
1101 let mut t = make_tracker();
1102 activate_tracker(&mut t);
1103
1104 assert!(t.is_active());
1105 assert_eq!(t.phase(), Some(GoalPhase::Executing));
1106 assert_eq!(t.status(), Some(GoalStatus::Active));
1107 assert_eq!(t.objective(), Some("Build a widget"));
1108 assert_eq!(t.token_budget(), Some(100_000));
1109 assert!(t.active_since.is_some());
1110 }
1111
1112 #[test]
1113 fn lifecycle_transitions_record_history_events() {
1114 let mut t = make_tracker();
1115 activate_tracker(&mut t);
1116 assert!(
1117 matches!(t.snapshot().unwrap().history.last().map(|e| &e.event), Some(GoalEvent::GoalCreated)),
1118 "create_goal must record GoalCreated"
1119 );
1120
1121 assert!(t.pause(GoalPauseReason::User));
1122 {
1123 let last = t.snapshot().unwrap().history.last().unwrap();
1124 assert!(matches!(last.event, GoalEvent::GoalPaused));
1125 assert_eq!(last.detail.as_deref(), Some("user"), "pause records its cause as the history detail");
1126 }
1127
1128 assert!(t.resume());
1129 assert!(matches!(t.snapshot().unwrap().history.last().map(|e| &e.event), Some(GoalEvent::GoalResumed)));
1130
1131 assert!(t.complete());
1132 let o = t.snapshot().unwrap();
1133 assert!(matches!(o.history.last().map(|e| &e.event), Some(GoalEvent::GoalCompleted)));
1134 }
1135
1136 #[test]
1137 fn pause_only_from_active() {
1138 let mut t = make_tracker();
1139 activate_tracker(&mut t);
1140
1141 assert!(t.pause(GoalPauseReason::User));
1142 assert_eq!(t.status(), Some(GoalStatus::UserPaused));
1143 assert!(!t.is_active());
1144
1145 assert!(t.resume());
1146 assert_eq!(t.status(), Some(GoalStatus::Active));
1147 assert!(t.is_active());
1148 }
1149
1150 #[test]
1151 fn pause_from_complete_is_noop() {
1152 let mut t = make_tracker();
1153 activate_tracker(&mut t);
1154 t.complete();
1155
1156 assert!(!t.pause(GoalPauseReason::User));
1157 assert_eq!(t.status(), Some(GoalStatus::Complete));
1158 }
1159
1160 #[test]
1161 fn resume_only_from_paused_variants() {
1162 let mut t = make_tracker();
1163 activate_tracker(&mut t);
1164
1165 assert!(!t.resume());
1166 assert_eq!(t.status(), Some(GoalStatus::Active));
1167
1168 t.budget_limit();
1169 assert!(!t.resume());
1170 assert_eq!(t.status(), Some(GoalStatus::BudgetLimited));
1171 }
1172
1173 #[test]
1174 fn complete_from_active_succeeds() {
1175 let mut t = make_tracker();
1176 activate_tracker(&mut t);
1177 t.set_current_subagent(Some("sub-1".into()), Some("worker".into()));
1178
1179 assert!(t.complete());
1180 assert_eq!(t.status(), Some(GoalStatus::Complete));
1181 assert!(t.current_subagent_id().is_none());
1182 }
1183
1184 #[test]
1185 fn complete_from_paused_succeeds() {
1186 let mut t = make_tracker();
1187 activate_tracker(&mut t);
1188 t.pause(GoalPauseReason::User);
1189
1190 assert!(t.complete());
1191 assert_eq!(t.status(), Some(GoalStatus::Complete));
1192 }
1193
1194 #[test]
1195 fn complete_from_blocked_succeeds() {
1196 let mut t = make_tracker();
1197 activate_tracker(&mut t);
1198 t.pause(GoalPauseReason::Verification);
1199 assert!(t.complete());
1200 assert_eq!(t.status(), Some(GoalStatus::Complete));
1201 }
1202
1203 #[test]
1204 fn budget_limit_from_active_succeeds() {
1205 let mut t = make_tracker();
1206 activate_tracker(&mut t);
1207 t.set_phase(GoalPhase::Executing);
1208
1209 assert!(t.budget_limit());
1210 assert_eq!(t.status(), Some(GoalStatus::BudgetLimited));
1211 assert_eq!(t.phase(), Some(GoalPhase::Idle));
1212 }
1213
1214 #[test]
1215 fn pause_reason_maps_to_correct_status() {
1216 let mut t = make_tracker();
1217 activate_tracker(&mut t);
1218
1219 assert!(t.pause(GoalPauseReason::User));
1220 assert_eq!(t.status(), Some(GoalStatus::UserPaused));
1221
1222 t.resume();
1223 assert!(t.pause(GoalPauseReason::BackOff));
1224 assert_eq!(t.status(), Some(GoalStatus::BackOffPaused));
1225
1226 t.resume();
1227 assert!(t.pause(GoalPauseReason::NoProgress));
1228 assert_eq!(t.status(), Some(GoalStatus::NoProgressPaused));
1229
1230 t.resume();
1231 assert!(t.pause_with_message(GoalPauseReason::Infra, "Turn failed: rate limit".into()));
1232 assert_eq!(t.status(), Some(GoalStatus::InfraPaused));
1233 }
1234
1235 #[test]
1236 fn is_paused_matches_all_paused_variants() {
1237 assert!(GoalStatus::UserPaused.is_paused());
1238 assert!(GoalStatus::BackOffPaused.is_paused());
1239 assert!(GoalStatus::NoProgressPaused.is_paused());
1240 assert!(GoalStatus::InfraPaused.is_paused());
1241 assert!(GoalStatus::Blocked.is_paused());
1242 assert!(!GoalStatus::Active.is_paused());
1243 assert!(!GoalStatus::Complete.is_paused());
1244 assert!(!GoalStatus::BudgetLimited.is_paused());
1245 }
1246
1247 #[test]
1248 fn no_progress_paused_round_trips_distinctly_from_back_off() {
1249 assert_eq!(GoalStatus::from_wire_str("no_progress_paused"), GoalStatus::NoProgressPaused);
1250 let json = serde_json::to_string(&GoalStatus::NoProgressPaused).unwrap();
1251 assert_eq!(json, "\"no_progress_paused\"");
1252 let back: GoalStatus = serde_json::from_str(&json).unwrap();
1253 assert_eq!(back, GoalStatus::NoProgressPaused);
1254 assert_eq!(GoalStatus::from_wire_str("back_off_paused"), GoalStatus::BackOffPaused);
1255 assert_ne!(GoalStatus::NoProgressPaused, GoalStatus::BackOffPaused);
1256 }
1257
1258 #[test]
1259 fn resume_from_user_paused() {
1260 let mut t = make_tracker();
1261 activate_tracker(&mut t);
1262 t.pause(GoalPauseReason::User);
1263 assert!(t.resume());
1264 assert_eq!(t.status(), Some(GoalStatus::Active));
1265 }
1266
1267 #[test]
1268 fn resume_from_infra_paused() {
1269 let mut t = make_tracker();
1270 activate_tracker(&mut t);
1271 t.pause_with_message(GoalPauseReason::Infra, "Turn failed: auth".into());
1272 assert_eq!(t.snapshot().and_then(|o| o.pause_message.clone()), Some("Turn failed: auth".into()));
1273 assert!(t.resume());
1274 assert_eq!(t.status(), Some(GoalStatus::Active));
1275 assert!(t.snapshot().unwrap().pause_message.is_none());
1276 }
1277
1278 #[test]
1279 fn pause_with_verification_reason_transitions_to_blocked() {
1280 let mut t = make_tracker();
1281 activate_tracker(&mut t);
1282
1283 assert!(t.pause(GoalPauseReason::Verification));
1284 assert_eq!(t.status(), Some(GoalStatus::Blocked));
1285 assert!(t.status().unwrap().is_paused());
1286 }
1287
1288 #[test]
1289 fn resume_from_blocked_transitions_to_active() {
1290 let mut t = make_tracker();
1291 activate_tracker(&mut t);
1292 t.pause(GoalPauseReason::Verification);
1293 assert!(t.resume());
1294 assert_eq!(t.status(), Some(GoalStatus::Active));
1295 }
1296
1297 #[test]
1298 fn unknown_future_paused_status_deserializes_to_user_paused() {
1299 let parsed: GoalStatus = serde_json::from_str(r#""error_paused""#).unwrap();
1300 assert_eq!(parsed, GoalStatus::UserPaused);
1301 }
1302
1303 #[test]
1304 fn unknown_non_paused_status_deserializes_to_user_paused_not_active() {
1305 for wire in [r#""quarantined""#, r#""v9_super_active""#, r#""""#] {
1306 let parsed: GoalStatus = serde_json::from_str(wire).unwrap();
1307 assert_eq!(parsed, GoalStatus::UserPaused, "wire {wire}");
1308 }
1309 assert_eq!(GoalStatus::from_wire_str("not-a-status"), GoalStatus::UserPaused,);
1310 }
1311
1312 #[test]
1313 fn legacy_pascal_case_paused_deserializes_to_user_paused() {
1314 let legacy = r#""Paused""#;
1315 let parsed: GoalStatus = serde_json::from_str(legacy).unwrap();
1316 assert_eq!(parsed, GoalStatus::UserPaused);
1317 }
1318
1319 #[test]
1320 fn legacy_pascal_case_other_variants_deserialize() {
1321 for (legacy, expected) in [
1322 (r#""Active""#, GoalStatus::Active),
1323 (r#""BudgetLimited""#, GoalStatus::BudgetLimited),
1324 (r#""Complete""#, GoalStatus::Complete),
1325 ] {
1326 let parsed: GoalStatus = serde_json::from_str(legacy).unwrap();
1327 assert_eq!(parsed, expected, "legacy {legacy} must parse");
1328 }
1329 }
1330
1331 #[test]
1332 fn legacy_infra_paused_deserializes() {
1333 let parsed: GoalStatus = serde_json::from_str(r#""infra_paused""#).unwrap();
1334 assert_eq!(parsed, GoalStatus::InfraPaused);
1335 }
1336
1337 #[test]
1338 fn goal_event_unknown_string_deserializes_to_unknown() {
1339 let unknown: GoalEvent = serde_json::from_str("\"some_future_event\"").unwrap();
1340 assert!(matches!(unknown, GoalEvent::Unknown));
1341 let known: GoalEvent = serde_json::from_str("\"goal_paused\"").unwrap();
1342 assert!(matches!(known, GoalEvent::GoalPaused));
1343 }
1344
1345 #[test]
1346 fn record_classifier_stall_trips_on_two_consecutive_identical_fingerprints() {
1347 let mut t = make_tracker();
1348 activate_tracker(&mut t);
1349 assert!(!t.record_classifier_stall("fp-a"), "first occurrence of a fingerprint is not a stall");
1350 assert!(t.record_classifier_stall("fp-a"), "the same fingerprint twice running trips the stall early-exit");
1351 assert_eq!(t.snapshot().unwrap().classifier_stall_count, 2);
1352 }
1353
1354 #[test]
1355 fn record_classifier_stall_resets_when_fingerprint_changes() {
1356 let mut t = make_tracker();
1357 activate_tracker(&mut t);
1358 assert!(!t.record_classifier_stall("fp-a"));
1359 assert!(t.record_classifier_stall("fp-a"));
1360 assert!(
1361 !t.record_classifier_stall("fp-b"),
1362 "a different fingerprint resets the streak to its first occurrence"
1363 );
1364 assert_eq!(t.snapshot().unwrap().classifier_stall_count, 1);
1365 assert!(t.record_classifier_stall("fp-b"), "the new fingerprint then trips on its own second occurrence");
1366 }
1367
1368 #[test]
1369 fn reset_classifier_stall_clears_streak_so_next_occurrence_is_first() {
1370 let mut t = make_tracker();
1371 activate_tracker(&mut t);
1372 assert!(!t.record_classifier_stall("fp-a"));
1373 assert!(t.record_classifier_stall("fp-a"));
1374 t.reset_classifier_stall();
1375 {
1376 let o = t.snapshot().unwrap();
1377 assert_eq!(o.classifier_stall_count, 0);
1378 assert!(o.last_gap_fingerprint.is_none());
1379 }
1380 assert!(!t.record_classifier_stall("fp-a"), "after reset, a repeat of the old fingerprint must not re-stall");
1381 }
1382
1383 #[test]
1384 fn full_lifecycle_create_to_complete() {
1385 let mut t = make_tracker();
1386 activate_tracker(&mut t);
1387
1388 t.set_phase(GoalPhase::Executing);
1389 assert_eq!(t.phase(), Some(GoalPhase::Executing));
1390
1391 t.set_current_subagent(Some("sub-1".into()), Some("worker".into()));
1392 assert_eq!(t.current_subagent_id(), Some("sub-1"));
1393
1394 assert!(t.complete());
1395 assert_eq!(t.status(), Some(GoalStatus::Complete));
1396 assert_eq!(t.phase(), Some(GoalPhase::Idle));
1397 assert!(t.current_subagent_id().is_none());
1398 assert!(t.active_since.is_none());
1399 }
1400
1401 #[test]
1402 fn serde_round_trip_preserves_data() {
1403 let mut t = make_tracker();
1404 activate_tracker(&mut t);
1405 t.set_phase(GoalPhase::Executing);
1406
1407 let original = t.snapshot().unwrap().clone();
1408 let json = serde_json::to_string(&original).unwrap();
1409 let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();
1410
1411 assert_eq!(restored.goal_id, original.goal_id);
1412 assert_eq!(restored.objective, original.objective);
1413 assert_eq!(restored.status, original.status);
1414 assert_eq!(restored.phase, original.phase);
1415 }
1416
1417 #[test]
1418 fn record_not_achieved_streak_increments_and_returns_new_count() {
1419 let mut t = make_tracker();
1420 activate_tracker(&mut t);
1421 assert_eq!(t.record_not_achieved_streak(), 1);
1422 assert_eq!(t.record_not_achieved_streak(), 2);
1423 assert_eq!(t.record_not_achieved_streak(), 3);
1424 assert_eq!(t.snapshot().unwrap().consecutive_not_achieved, 3);
1425 }
1426
1427 #[test]
1428 fn claim_strategist_fire_marks_current_streak() {
1429 let mut t = make_tracker();
1430 activate_tracker(&mut t);
1431 let _ = t.record_not_achieved_streak();
1432 let _ = t.record_not_achieved_streak();
1433 assert_eq!(t.claim_strategist_fire(|_, _| true), Some(2));
1434 let o = t.snapshot().unwrap();
1435 assert_eq!((o.consecutive_not_achieved, o.last_strategist_fired_at), (2, 2));
1436 }
1437
1438 #[test]
1439 fn claim_strategist_fire_skips_and_preserves_state_when_predicate_false() {
1440 let mut t = make_tracker();
1441 activate_tracker(&mut t);
1442 let _ = t.record_not_achieved_streak();
1443 assert_eq!(t.claim_strategist_fire(|_, _| false), None);
1444 let o = t.snapshot().unwrap();
1445 assert_eq!(o.last_strategist_fired_at, 0, "no fire => marker untouched");
1446 assert_eq!(o.strategist_cap_bonus, 0, "no fire => no cap bonus");
1447 }
1448
1449 #[test]
1450 fn strategist_fire_grants_cap_bonus_then_reset_clears_it() {
1451 let mut t = make_tracker();
1452 activate_tracker(&mut t);
1453 assert_eq!(t.snapshot().unwrap().strategist_cap_bonus, 0);
1454 let _ = t.record_not_achieved_streak();
1455 let _ = t.record_not_achieved_streak();
1456 let _ = t.claim_strategist_fire(|_, _| true);
1457 assert_eq!(t.snapshot().unwrap().strategist_cap_bonus, 3,);
1458 t.reset_strategist_state();
1459 assert_eq!(t.snapshot().unwrap().strategist_cap_bonus, 0);
1460 }
1461
1462 #[test]
1463 fn reset_strategist_state_clears_streak_marker_and_recommendation() {
1464 let mut t = make_tracker();
1465 activate_tracker(&mut t);
1466 let _ = t.record_not_achieved_streak();
1467 let _ = t.record_not_achieved_streak();
1468 let _ = t.claim_strategist_fire(|_, _| true);
1469 t.record_strategy_recommendation("/tmp/goal/strategy.md".into(), "split it".into());
1470
1471 t.reset_strategist_state();
1472
1473 let o = t.snapshot().unwrap();
1474 assert_eq!(o.consecutive_not_achieved, 0);
1475 assert_eq!(o.last_strategist_fired_at, 0);
1476 assert!(o.last_strategy_path.is_none());
1477 assert!(o.last_strategy_recommendation.is_none());
1478 }
1479}