1use chrono::Utc;
14use serde::{Deserialize, Serialize};
15use std::path::{Path, PathBuf};
16
17use crate::error::SessionStoreError;
18use crate::session_dir;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum MilestoneStatus {
24 Pending,
26 InProgress,
28 Done,
30 Blocked,
32}
33
34impl MilestoneStatus {
35 #[must_use]
37 pub fn is_terminal(&self) -> bool {
38 matches!(self, MilestoneStatus::Done)
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct Milestone {
45 pub id: String,
47 pub description: String,
49 pub status: MilestoneStatus,
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct ProgressLedger {
61 pub session_id: String,
63 pub goal: String,
65 pub milestones: Vec<Milestone>,
67 pub confidence: f32,
69 pub stalled_since: Option<String>,
72 pub updated_at: String,
74 #[serde(default)]
77 pub previous_session_id: Option<String>,
78 #[serde(default)]
80 pub handoff_summary: Option<String>,
81 #[serde(default)]
83 pub known_issues: Vec<String>,
84 #[serde(default)]
86 pub git_checkpoint: Option<String>,
87}
88
89impl ProgressLedger {
90 #[must_use]
92 pub fn new(session_id: &str, goal: &str) -> Self {
93 let ts = Utc::now().to_rfc3339();
94 Self {
95 session_id: session_id.to_string(),
96 goal: goal.to_string(),
97 milestones: Vec::new(),
98 confidence: 1.0,
99 stalled_since: None,
100 updated_at: ts,
101 previous_session_id: None,
102 handoff_summary: None,
103 known_issues: Vec::new(),
104 git_checkpoint: None,
105 }
106 }
107
108 #[must_use]
111 pub fn completion_ratio(&self) -> f32 {
112 if self.milestones.is_empty() {
113 return 1.0;
114 }
115 let done = self.milestones.iter().filter(|m| m.status.is_terminal()).count() as f32;
116 done / self.milestones.len() as f32
117 }
118
119 #[must_use]
121 pub fn is_complete(&self) -> bool {
122 self.completion_ratio() >= 1.0
123 }
124
125 #[must_use]
127 pub fn is_stalled(&self) -> bool {
128 self.stalled_since.is_some()
129 }
130
131 pub fn note_advance(&mut self) {
134 self.stalled_since = None;
135 self.confidence = (self.confidence + 0.05).min(1.0);
136 self.updated_at = Utc::now().to_rfc3339();
137 }
138
139 pub fn note_stall(&mut self) {
142 if self.stalled_since.is_none() {
143 self.stalled_since = Some(Utc::now().to_rfc3339());
144 }
145 self.confidence = (self.confidence - 0.1).max(0.0);
146 self.updated_at = Utc::now().to_rfc3339();
147 }
148
149 pub fn set_milestones(&mut self, milestones: Vec<Milestone>) {
151 self.milestones = milestones;
152 self.updated_at = Utc::now().to_rfc3339();
153 }
154
155 pub fn set_goal(&mut self, goal: &str) {
157 self.goal = goal.to_string();
158 self.updated_at = Utc::now().to_rfc3339();
159 }
160
161 pub fn set_handoff(&mut self, previous_session_id: &str, summary: &str, git_checkpoint: Option<String>) {
163 self.previous_session_id = Some(previous_session_id.to_string());
164 self.handoff_summary = Some(summary.to_string());
165 self.git_checkpoint = git_checkpoint;
166 self.updated_at = Utc::now().to_rfc3339();
167 }
168
169 pub fn add_known_issue(&mut self, issue: &str) {
171 self.known_issues.push(issue.to_string());
172 self.updated_at = Utc::now().to_rfc3339();
173 }
174
175 #[must_use]
180 pub fn to_markdown(&self) -> String {
181 let mut out = String::new();
182 out.push_str("# Session Progress\n\n");
183 out.push_str(&format!("**Goal:** {}\n", self.goal));
184 out.push_str(&format!("**Completion:** {:.0}%\n", (self.completion_ratio() * 100.0).round()));
185 out.push_str(&format!("**Confidence:** {:.2}\n", self.confidence));
186 if let Some(since) = &self.stalled_since {
187 out.push_str(&format!("**Stalled since:** {since}\n"));
188 }
189 out.push_str(&format!("**Updated:** {}\n\n", self.updated_at));
190
191 if let Some(prev) = &self.previous_session_id {
192 out.push_str(&format!("**Handed off from:** {prev}\n"));
193 }
194 if let Some(summary) = &self.handoff_summary {
195 out.push_str(&format!("**Handoff summary:** {summary}\n"));
196 }
197 if let Some(checkpoint) = &self.git_checkpoint {
198 out.push_str(&format!("**Git checkpoint:** `{checkpoint}`\n"));
199 }
200 if !self.known_issues.is_empty() {
201 out.push_str("\n## Known Issues\n\n");
202 for issue in &self.known_issues {
203 out.push_str(&format!("- {issue}\n"));
204 }
205 }
206
207 if self.milestones.is_empty() {
208 out.push_str("\n_No tracked milestones yet._\n");
209 } else {
210 out.push_str("\n## Milestones\n\n");
211 for m in &self.milestones {
212 let mark = match m.status {
213 MilestoneStatus::Done => "[x]",
214 MilestoneStatus::InProgress => "[~]",
215 MilestoneStatus::Blocked => "[!]",
216 MilestoneStatus::Pending => "[ ]",
217 };
218 out.push_str(&format!("{} {} — {}\n", mark, m.id, m.description));
219 }
220 }
221 out
222 }
223}
224
225#[must_use]
227pub fn progress_path(workspace: &Path, session_id: &str) -> PathBuf {
228 session_dir(workspace, session_id)
229 .join(crate::DERIVED_DIR)
230 .join("progress.json")
231}
232
233pub fn load_progress(workspace: &Path, session_id: &str) -> Result<Option<ProgressLedger>, SessionStoreError> {
238 let path = progress_path(workspace, session_id);
239 if !path.exists() {
240 return Ok(None);
241 }
242 let bytes = std::fs::read(&path).map_err(|e| SessionStoreError::io(path.clone(), e))?;
243 let ledger: ProgressLedger = serde_json::from_slice(&bytes)?;
244 Ok(Some(ledger))
245}
246
247pub fn save_progress(workspace: &Path, session_id: &str, ledger: &ProgressLedger) -> Result<(), SessionStoreError> {
249 let path = progress_path(workspace, session_id);
250 if let Some(parent) = path.parent() {
251 std::fs::create_dir_all(parent)
252 .map_err(|e| SessionStoreError::CreateDir { path: parent.to_path_buf(), source: e })?;
253 }
254 let bytes = serde_json::to_string(ledger)?;
255 std::fs::write(&path, bytes).map_err(|e| SessionStoreError::io(path, e))?;
256 Ok(())
257}
258
259#[cfg(test)]
260mod progress_tests {
261 use super::*;
262
263 fn sample_ledger() -> ProgressLedger {
264 let mut l = ProgressLedger::new("s1", "ship the feature");
265 l.set_milestones(vec![
266 Milestone {
267 id: "1".into(),
268 description: "design".into(),
269 status: MilestoneStatus::Done,
270 },
271 Milestone {
272 id: "2".into(),
273 description: "implement".into(),
274 status: MilestoneStatus::InProgress,
275 },
276 Milestone {
277 id: "3".into(),
278 description: "verify".into(),
279 status: MilestoneStatus::Pending,
280 },
281 ]);
282 l
283 }
284
285 #[test]
286 fn completion_ratio_reflects_terminal_milestones() {
287 let l = sample_ledger();
288 assert!((l.completion_ratio() - 1.0 / 3.0).abs() < f32::EPSILON);
289 assert!(!l.is_complete());
290 }
291
292 #[test]
293 fn empty_ledger_is_complete() {
294 let l = ProgressLedger::new("s", "goal");
295 assert!(l.is_complete());
296 assert!((l.completion_ratio() - 1.0).abs() < f32::EPSILON);
297 }
298
299 #[test]
300 fn advance_clears_stall_and_bumps_confidence() {
301 let mut l = sample_ledger();
302 l.note_stall();
303 assert!(l.is_stalled());
304 let before = l.confidence;
305 l.note_advance();
306 assert!(!l.is_stalled());
307 assert!(l.confidence >= before);
308 }
309
310 #[test]
311 fn persistence_round_trips() {
312 let tmp = std::env::temp_dir().join(format!("vtcode-prog-{}", std::process::id()));
313 let ws = tmp.join("ws");
314 std::fs::create_dir_all(&ws).unwrap();
315 let mut l = sample_ledger();
316 l.note_stall();
317 save_progress(&ws, "s1", &l).unwrap();
318 let loaded = load_progress(&ws, "s1").unwrap().expect("ledger present");
319 assert_eq!(loaded, l);
320 assert!(loaded.is_stalled());
321 assert!(load_progress(&ws, "absent").unwrap().is_none());
322 let _ = std::fs::remove_dir_all(&tmp);
323 }
324
325 #[test]
326 fn handoff_metadata_defaults_to_none() {
327 let l = ProgressLedger::new("s1", "goal");
328 assert!(l.previous_session_id.is_none());
329 assert!(l.handoff_summary.is_none());
330 assert!(l.known_issues.is_empty());
331 assert!(l.git_checkpoint.is_none());
332 }
333
334 #[test]
335 fn set_handoff_records_metadata() {
336 let mut l = ProgressLedger::new("s2", "goal");
337 l.set_handoff("s1", "implemented login", Some("abc123".to_string()));
338 assert_eq!(l.previous_session_id.as_deref(), Some("s1"));
339 assert_eq!(l.handoff_summary.as_deref(), Some("implemented login"));
340 assert_eq!(l.git_checkpoint.as_deref(), Some("abc123"));
341 }
342
343 #[test]
344 fn add_known_issue_accumulates() {
345 let mut l = ProgressLedger::new("s3", "goal");
346 l.add_known_issue("rate limiting missing");
347 l.add_known_issue("no error handling for timeouts");
348 assert_eq!(l.known_issues.len(), 2);
349 assert_eq!(l.known_issues[0], "rate limiting missing");
350 }
351
352 #[test]
353 fn handoff_metadata_survives_persistence() {
354 let tmp = std::env::temp_dir().join(format!("vtcode-prog-{}", std::process::id()));
355 let ws = tmp.join("ws");
356 std::fs::create_dir_all(&ws).unwrap();
357
358 let mut l = sample_ledger();
359 l.set_handoff("prev-session", "built auth", Some("def456".to_string()));
360 l.add_known_issue("tests are flaky");
361
362 save_progress(&ws, "s4", &l).unwrap();
363 let loaded = load_progress(&ws, "s4").unwrap().expect("present");
364 assert_eq!(loaded.previous_session_id.as_deref(), Some("prev-session"));
365 assert_eq!(loaded.handoff_summary.as_deref(), Some("built auth"));
366 assert_eq!(loaded.git_checkpoint.as_deref(), Some("def456"));
367 assert_eq!(loaded.known_issues, vec!["tests are flaky"]);
368
369 let _ = std::fs::remove_dir_all(&tmp);
370 }
371
372 #[test]
373 fn to_markdown_includes_handoff_metadata() {
374 let mut l = ProgressLedger::new("s5", "build feature");
375 l.set_handoff("s4", "implemented core", Some("abc123".to_string()));
376 l.add_known_issue("missing error handling");
377
378 let md = l.to_markdown();
379 assert!(md.contains("Handed off from:** s4"));
380 assert!(md.contains("Handoff summary:** implemented core"));
381 assert!(md.contains("Git checkpoint:** `abc123`"));
382 assert!(md.contains("- missing error handling"));
383 }
384
385 #[test]
386 fn to_markdown_omits_handoff_when_absent() {
387 let l = ProgressLedger::new("s6", "goal");
388 let md = l.to_markdown();
389 assert!(!md.contains("Handed off from"));
390 assert!(!md.contains("Handoff summary"));
391 assert!(!md.contains("Git checkpoint"));
392 assert!(!md.contains("Known Issues"));
393 }
394}
395
396use std::time::Instant;
401
402#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
404#[serde(rename_all = "snake_case")]
405pub enum GoalPhase {
406 Idle,
408 Planning,
410 Executing,
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
428#[serde(rename_all = "snake_case")]
429pub enum GoalStatus {
430 #[serde(alias = "Active")]
432 Active,
433 #[serde(alias = "Paused")]
435 UserPaused,
436 BackOffPaused,
438 NoProgressPaused,
440 InfraPaused,
442 Blocked,
444 #[serde(alias = "BudgetLimited")]
446 BudgetLimited,
447 #[serde(alias = "Complete")]
449 Complete,
450}
451
452impl<'de> Deserialize<'de> for GoalStatus {
453 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
454 where
455 D: serde::Deserializer<'de>,
456 {
457 let s = String::deserialize(deserializer)?;
458 Ok(Self::from_wire_str(&s))
459 }
460}
461
462impl GoalStatus {
463 pub fn from_wire_str(s: &str) -> Self {
467 match s {
468 "active" | "Active" => Self::Active,
469 "user_paused" | "paused" | "Paused" => Self::UserPaused,
470 "doom_loop_paused" => Self::UserPaused,
471 "back_off_paused" => Self::BackOffPaused,
472 "no_progress_paused" => Self::NoProgressPaused,
473 "infra_paused" => Self::InfraPaused,
474 "blocked" => Self::Blocked,
475 "budget_limited" | "BudgetLimited" => Self::BudgetLimited,
476 "complete" | "Complete" => Self::Complete,
477 _ => Self::UserPaused,
478 }
479 }
480
481 pub fn is_paused(&self) -> bool {
483 matches!(
484 self,
485 Self::UserPaused | Self::BackOffPaused | Self::NoProgressPaused | Self::InfraPaused | Self::Blocked
486 )
487 }
488}
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
493pub enum GoalPauseReason {
494 User,
496 BackOff,
498 NoProgress,
500 Verification,
502 Infra,
504}
505
506impl GoalPauseReason {
507 fn to_status(self) -> GoalStatus {
508 match self {
509 Self::User => GoalStatus::UserPaused,
510 Self::BackOff => GoalStatus::BackOffPaused,
511 Self::NoProgress => GoalStatus::NoProgressPaused,
512 Self::Verification => GoalStatus::Blocked,
513 Self::Infra => GoalStatus::InfraPaused,
514 }
515 }
516
517 fn history_detail(self) -> &'static str {
518 match self {
519 Self::User => "user",
520 Self::BackOff => "back_off",
521 Self::NoProgress => "no_progress",
522 Self::Verification => "blocked",
523 Self::Infra => "infra",
524 }
525 }
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
530#[serde(rename_all = "snake_case")]
531pub enum GoalClassifierVerdict {
532 Achieved,
534 NotAchieved,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
540#[serde(rename_all = "snake_case")]
541pub enum GoalEvent {
542 GoalCreated,
544 PlanningStarted,
546 PlanningCompleted,
548 PlanningFailed,
550 WorkerStarted,
552 WorkerCompleted,
554 WorkerFailed,
556 ContextRotated,
558 GoalPaused,
560 GoalResumed,
562 GoalCompleted,
564 GoalCleared,
566 BudgetExceeded,
568 PrematureStopDetected,
570 #[serde(other)]
572 Unknown,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct GoalHistoryEntry {
578 pub timestamp: String,
580 pub event: GoalEvent,
582 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub detail: Option<String>,
585 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub round: Option<u32>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub tokens_used: Option<i64>,
591 #[serde(default, skip_serializing_if = "Vec::is_empty")]
593 pub unmet: Vec<String>,
594}
595
596impl GoalHistoryEntry {
597 fn now(event: GoalEvent, detail: Option<String>) -> Self {
598 Self {
599 timestamp: Utc::now().to_rfc3339(),
600 event,
601 detail,
602 round: None,
603 tokens_used: None,
604 unmet: Vec::new(),
605 }
606 }
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize)]
611pub struct GoalOrchestration {
612 pub goal_id: String,
614 pub objective: String,
616 pub status: GoalStatus,
618 pub phase: GoalPhase,
620 pub token_budget: Option<i64>,
622 pub elapsed_ms: u64,
624 pub created_at: String,
626 pub current_subagent_id: Option<String>,
628 pub current_subagent_role: Option<String>,
630 #[serde(default)]
632 pub total_worker_rounds: u32,
633 #[serde(default)]
635 pub total_verify_rounds: u32,
636 #[serde(skip)]
638 pub budget_limit_reported: bool,
639 #[serde(default)]
641 pub token_baseline: i64,
642 #[serde(default)]
644 pub tokens_used_high_water: i64,
645 #[serde(default)]
647 pub parent_tokens_spent: i64,
648 #[serde(default)]
650 pub last_session_tokens_seen: Option<i64>,
651 pub history: Vec<GoalHistoryEntry>,
653 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub pause_message: Option<String>,
656 #[serde(default)]
658 pub classifier_stall_count: u32,
659 #[serde(default)]
661 pub classifier_runs_attempted: u32,
662 #[serde(default)]
664 pub rounds_since_verify: u32,
665 #[serde(default)]
667 pub consecutive_not_achieved: u32,
668 #[serde(default)]
670 pub last_strategist_fired_at: u32,
671 #[serde(default)]
673 pub strategist_cap_bonus: u32,
674 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub last_strategy_path: Option<String>,
677 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub last_strategy_recommendation: Option<String>,
680 #[serde(default, skip_serializing_if = "Option::is_none")]
682 pub changes_baseline_commit: Option<String>,
683 #[serde(default, skip_serializing_if = "Option::is_none")]
685 pub last_gap_fingerprint: Option<String>,
686 #[serde(skip)]
688 pub live_subagent_tokens: u64,
689 #[serde(skip)]
691 pub live_tokens_by_model: Vec<(String, u64)>,
692 #[serde(skip)]
694 pub live_context_window: u64,
695 #[serde(skip)]
697 pub live_context_pct: u8,
698 #[serde(skip)]
700 pub live_turn_count: u32,
701 #[serde(skip)]
703 pub live_tool_call_count: u32,
704 #[serde(skip)]
706 pub planning_in_flight: bool,
707 #[serde(skip)]
709 pub verifying_in_flight: bool,
710}
711
712impl GoalOrchestration {
713 fn reset_strategist_fields(&mut self) {
714 self.consecutive_not_achieved = 0;
715 self.last_strategist_fired_at = 0;
716 self.strategist_cap_bonus = 0;
717 self.last_strategy_path = None;
718 self.last_strategy_recommendation = None;
719 }
720
721 fn reset_classifier_stall_fields(&mut self) {
722 self.classifier_stall_count = 0;
723 self.last_gap_fingerprint = None;
724 }
725}
726
727#[derive(Debug)]
729pub struct GoalTracker {
730 orchestration: Option<GoalOrchestration>,
732 session_dir: PathBuf,
734 active_since: Option<Instant>,
736}
737
738impl GoalTracker {
739 pub fn new(session_dir: PathBuf) -> Self {
741 Self {
742 orchestration: None,
743 session_dir,
744 active_since: None,
745 }
746 }
747
748 pub fn from_snapshot(session_dir: PathBuf, mut snapshot: GoalOrchestration) -> Self {
750 match snapshot.phase {
751 GoalPhase::Planning | GoalPhase::Executing => {
752 snapshot.phase = GoalPhase::Idle;
753 if snapshot.status == GoalStatus::Active {
754 snapshot.status = GoalStatus::UserPaused;
755 }
756 snapshot.current_subagent_id = None;
757 snapshot.current_subagent_role = None;
758 }
759 GoalPhase::Idle => {}
760 }
761 snapshot.planning_in_flight = false;
762 snapshot.verifying_in_flight = false;
763 let active_since = if snapshot.status == GoalStatus::Active {
764 Some(Instant::now())
765 } else {
766 None
767 };
768 Self {
769 orchestration: Some(snapshot),
770 session_dir,
771 active_since,
772 }
773 }
774
775 pub fn snapshot(&self) -> Option<&GoalOrchestration> {
777 self.orchestration.as_ref()
778 }
779
780 pub fn snapshot_mut(&mut self) -> Option<&mut GoalOrchestration> {
782 self.orchestration.as_mut()
783 }
784
785 pub fn is_active(&self) -> bool {
787 self.orchestration.as_ref().is_some_and(|o| o.status == GoalStatus::Active)
788 }
789
790 pub fn phase(&self) -> Option<GoalPhase> {
792 self.orchestration.as_ref().map(|o| o.phase)
793 }
794
795 pub fn status(&self) -> Option<GoalStatus> {
797 self.orchestration.as_ref().map(|o| o.status)
798 }
799
800 pub fn current_subagent_id(&self) -> Option<&str> {
802 self.orchestration.as_ref().and_then(|o| o.current_subagent_id.as_deref())
803 }
804
805 pub fn objective(&self) -> Option<&str> {
807 self.orchestration.as_ref().map(|o| o.objective.as_str())
808 }
809
810 pub fn token_budget(&self) -> Option<i64> {
812 self.orchestration.as_ref().and_then(|o| o.token_budget)
813 }
814
815 pub fn create_goal(
817 &mut self,
818 goal_id: String,
819 objective: String,
820 token_budget: Option<i64>,
821 token_baseline: i64,
822 created_at: String,
823 baseline_commit: Option<String>,
824 ) {
825 let _ = std::fs::create_dir_all(self.goal_dir());
826 if self.orchestration.is_some() {
827 self.remove_scratch_root();
828 }
829 self.orchestration = Some(GoalOrchestration {
830 goal_id,
831 objective,
832 status: GoalStatus::Active,
833 phase: GoalPhase::Executing,
834 token_budget,
835 elapsed_ms: 0,
836 created_at,
837 current_subagent_id: None,
838 current_subagent_role: None,
839 total_worker_rounds: 0,
840 total_verify_rounds: 0,
841 budget_limit_reported: false,
842 token_baseline,
843 tokens_used_high_water: 0,
844 parent_tokens_spent: 0,
845 last_session_tokens_seen: Some(token_baseline),
846 history: Vec::new(),
847 pause_message: None,
848 classifier_stall_count: 0,
849 classifier_runs_attempted: 0,
850 rounds_since_verify: 0,
851 consecutive_not_achieved: 0,
852 last_strategist_fired_at: 0,
853 strategist_cap_bonus: 0,
854 last_strategy_path: None,
855 last_strategy_recommendation: None,
856 changes_baseline_commit: baseline_commit,
857 last_gap_fingerprint: None,
858 live_subagent_tokens: 0,
859 live_tokens_by_model: Vec::new(),
860 live_context_window: 0,
861 live_context_pct: 0,
862 live_turn_count: 0,
863 live_tool_call_count: 0,
864 planning_in_flight: false,
865 verifying_in_flight: false,
866 });
867 self.active_since = Some(Instant::now());
868 self.record_event(GoalEvent::GoalCreated, None);
869 }
870
871 pub fn set_phase(&mut self, phase: GoalPhase) {
873 if let Some(o) = &mut self.orchestration {
874 o.phase = phase;
875 }
876 }
877
878 pub fn set_current_subagent(&mut self, id: Option<String>, role: Option<String>) {
880 if let Some(o) = &mut self.orchestration {
881 o.current_subagent_id = id;
882 o.current_subagent_role = role;
883 }
884 }
885
886 pub fn pause(&mut self, reason: GoalPauseReason) -> bool {
888 self.pause_inner(reason, None)
889 }
890
891 pub fn pause_with_message(&mut self, reason: GoalPauseReason, message: String) -> bool {
893 self.pause_inner(reason, Some(message))
894 }
895
896 fn pause_inner(&mut self, reason: GoalPauseReason, message: Option<String>) -> bool {
897 let applied = if let Some(o) = &mut self.orchestration
898 && o.status == GoalStatus::Active
899 {
900 if let Some(since) = self.active_since.take() {
901 o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
902 }
903 o.status = reason.to_status();
904 if message.is_some() {
905 o.pause_message = message;
906 }
907 true
908 } else {
909 false
910 };
911 if applied {
912 self.record_event(GoalEvent::GoalPaused, Some(reason.history_detail().to_owned()));
913 }
914 applied
915 }
916
917 pub fn resume(&mut self) -> bool {
919 if let Some(o) = &mut self.orchestration
920 && o.status.is_paused()
921 {
922 o.status = GoalStatus::Active;
923 o.pause_message = None;
924 o.classifier_runs_attempted = 0;
925 o.rounds_since_verify = 0;
926 o.reset_strategist_fields();
927 o.reset_classifier_stall_fields();
928 self.active_since = Some(Instant::now());
929 self.record_event(GoalEvent::GoalResumed, None);
930 return true;
931 }
932 false
933 }
934
935 pub fn complete(&mut self) -> bool {
937 if let Some(o) = &mut self.orchestration
938 && (o.status == GoalStatus::Active || o.status.is_paused())
939 {
940 if let Some(since) = self.active_since.take() {
941 o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
942 }
943 o.status = GoalStatus::Complete;
944 o.phase = GoalPhase::Idle;
945 o.current_subagent_id = None;
946 o.current_subagent_role = None;
947 o.pause_message = None;
948 o.reset_strategist_fields();
949 self.record_event(GoalEvent::GoalCompleted, None);
950 return true;
951 }
952 false
953 }
954
955 pub fn budget_limit(&mut self) -> bool {
957 if let Some(o) = &mut self.orchestration
958 && (o.status == GoalStatus::Active || o.status.is_paused())
959 {
960 if let Some(since) = self.active_since.take() {
961 o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
962 }
963 o.status = GoalStatus::BudgetLimited;
964 o.phase = GoalPhase::Idle;
965 o.current_subagent_id = None;
966 o.current_subagent_role = None;
967 o.pause_message = None;
968 o.reset_strategist_fields();
969 self.record_event(GoalEvent::BudgetExceeded, None);
970 return true;
971 }
972 false
973 }
974
975 pub fn clear(&mut self) {
977 self.orchestration = None;
978 self.active_since = None;
979 }
980
981 fn goal_dir(&self) -> PathBuf {
982 self.session_dir.join("goal")
983 }
984
985 fn remove_scratch_root(&self) {
986 let _ = std::fs::remove_dir_all(self.session_dir.join("goal"));
987 }
988
989 pub fn account_elapsed(&mut self) {
991 if let Some(o) = &mut self.orchestration
992 && let Some(since) = self.active_since
993 {
994 o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
995 self.active_since = Some(since);
996 }
997 }
998
999 pub fn record_classifier_stall(&mut self, fingerprint: &str) -> bool {
1002 let Some(o) = self.orchestration.as_mut() else {
1003 return false;
1004 };
1005 if o.last_gap_fingerprint.as_deref() == Some(fingerprint) {
1006 o.classifier_stall_count = o.classifier_stall_count.saturating_add(1);
1007 } else {
1008 o.last_gap_fingerprint = Some(fingerprint.to_string());
1009 o.classifier_stall_count = 1;
1010 }
1011 o.classifier_stall_count >= 2
1012 }
1013
1014 pub fn rollback_classifier_attempt(&mut self) {
1016 if let Some(o) = self.orchestration.as_mut() {
1017 o.classifier_runs_attempted = o.classifier_runs_attempted.saturating_sub(1);
1018 }
1019 }
1020
1021 pub fn reset_classifier_stall(&mut self) {
1023 if let Some(o) = self.orchestration.as_mut() {
1024 o.reset_classifier_stall_fields();
1025 }
1026 }
1027
1028 pub fn record_not_achieved_streak(&mut self) -> u32 {
1030 match self.orchestration.as_mut() {
1031 Some(o) => {
1032 o.consecutive_not_achieved = o.consecutive_not_achieved.saturating_add(1);
1033 o.consecutive_not_achieved
1034 }
1035 None => 0,
1036 }
1037 }
1038
1039 pub fn claim_strategist_fire(&mut self, should_fire: impl Fn(u32, u32) -> bool) -> Option<u32> {
1041 let o = self.orchestration.as_mut()?;
1042 if should_fire(o.consecutive_not_achieved, o.last_strategist_fired_at) {
1043 o.last_strategist_fired_at = o.consecutive_not_achieved;
1044 o.strategist_cap_bonus = 3;
1045 o.reset_classifier_stall_fields();
1046 Some(o.consecutive_not_achieved)
1047 } else {
1048 None
1049 }
1050 }
1051
1052 pub fn revoke_strategist_cap_bonus(&mut self) {
1054 if let Some(o) = self.orchestration.as_mut() {
1055 o.strategist_cap_bonus = 0;
1056 }
1057 }
1058
1059 pub fn reset_strategist_state(&mut self) {
1061 if let Some(o) = self.orchestration.as_mut() {
1062 o.reset_strategist_fields();
1063 }
1064 }
1065
1066 pub fn record_strategy_recommendation(&mut self, path: String, recommendation: String) {
1068 if let Some(o) = self.orchestration.as_mut() {
1069 o.last_strategy_path = Some(path);
1070 o.last_strategy_recommendation = Some(recommendation);
1071 }
1072 }
1073
1074 pub fn append_history(&mut self, entry: GoalHistoryEntry) {
1076 if let Some(o) = &mut self.orchestration {
1077 o.history.push(entry);
1078 }
1079 }
1080
1081 fn record_event(&mut self, event: GoalEvent, detail: Option<String>) {
1082 self.append_history(GoalHistoryEntry::now(event, detail));
1083 }
1084}
1085
1086#[cfg(test)]
1087mod goal_tracker_tests {
1088 use super::*;
1089
1090 fn make_tracker() -> GoalTracker {
1091 GoalTracker::new(PathBuf::from("/tmp/test-goal-session"))
1092 }
1093
1094 fn activate_tracker(t: &mut GoalTracker) {
1095 t.create_goal("goal-1".into(), "Build a widget".into(), Some(100_000), 0, "2026-01-01T00:00:00Z".into(), None);
1096 }
1097
1098 #[test]
1099 fn create_goal_activates_and_starts_timer() {
1100 let mut t = make_tracker();
1101 activate_tracker(&mut t);
1102
1103 assert!(t.is_active());
1104 assert_eq!(t.phase(), Some(GoalPhase::Executing));
1105 assert_eq!(t.status(), Some(GoalStatus::Active));
1106 assert_eq!(t.objective(), Some("Build a widget"));
1107 assert_eq!(t.token_budget(), Some(100_000));
1108 assert!(t.active_since.is_some());
1109 }
1110
1111 #[test]
1112 fn lifecycle_transitions_record_history_events() {
1113 let mut t = make_tracker();
1114 activate_tracker(&mut t);
1115 assert!(
1116 matches!(t.snapshot().unwrap().history.last().map(|e| &e.event), Some(GoalEvent::GoalCreated)),
1117 "create_goal must record GoalCreated"
1118 );
1119
1120 assert!(t.pause(GoalPauseReason::User));
1121 {
1122 let last = t.snapshot().unwrap().history.last().unwrap();
1123 assert!(matches!(last.event, GoalEvent::GoalPaused));
1124 assert_eq!(last.detail.as_deref(), Some("user"), "pause records its cause as the history detail");
1125 }
1126
1127 assert!(t.resume());
1128 assert!(matches!(t.snapshot().unwrap().history.last().map(|e| &e.event), Some(GoalEvent::GoalResumed)));
1129
1130 assert!(t.complete());
1131 let o = t.snapshot().unwrap();
1132 assert!(matches!(o.history.last().map(|e| &e.event), Some(GoalEvent::GoalCompleted)));
1133 }
1134
1135 #[test]
1136 fn pause_only_from_active() {
1137 let mut t = make_tracker();
1138 activate_tracker(&mut t);
1139
1140 assert!(t.pause(GoalPauseReason::User));
1141 assert_eq!(t.status(), Some(GoalStatus::UserPaused));
1142 assert!(!t.is_active());
1143
1144 assert!(t.resume());
1145 assert_eq!(t.status(), Some(GoalStatus::Active));
1146 assert!(t.is_active());
1147 }
1148
1149 #[test]
1150 fn pause_from_complete_is_noop() {
1151 let mut t = make_tracker();
1152 activate_tracker(&mut t);
1153 t.complete();
1154
1155 assert!(!t.pause(GoalPauseReason::User));
1156 assert_eq!(t.status(), Some(GoalStatus::Complete));
1157 }
1158
1159 #[test]
1160 fn resume_only_from_paused_variants() {
1161 let mut t = make_tracker();
1162 activate_tracker(&mut t);
1163
1164 assert!(!t.resume());
1165 assert_eq!(t.status(), Some(GoalStatus::Active));
1166
1167 t.budget_limit();
1168 assert!(!t.resume());
1169 assert_eq!(t.status(), Some(GoalStatus::BudgetLimited));
1170 }
1171
1172 #[test]
1173 fn complete_from_active_succeeds() {
1174 let mut t = make_tracker();
1175 activate_tracker(&mut t);
1176 t.set_current_subagent(Some("sub-1".into()), Some("worker".into()));
1177
1178 assert!(t.complete());
1179 assert_eq!(t.status(), Some(GoalStatus::Complete));
1180 assert!(t.current_subagent_id().is_none());
1181 }
1182
1183 #[test]
1184 fn complete_from_paused_succeeds() {
1185 let mut t = make_tracker();
1186 activate_tracker(&mut t);
1187 t.pause(GoalPauseReason::User);
1188
1189 assert!(t.complete());
1190 assert_eq!(t.status(), Some(GoalStatus::Complete));
1191 }
1192
1193 #[test]
1194 fn complete_from_blocked_succeeds() {
1195 let mut t = make_tracker();
1196 activate_tracker(&mut t);
1197 t.pause(GoalPauseReason::Verification);
1198 assert!(t.complete());
1199 assert_eq!(t.status(), Some(GoalStatus::Complete));
1200 }
1201
1202 #[test]
1203 fn budget_limit_from_active_succeeds() {
1204 let mut t = make_tracker();
1205 activate_tracker(&mut t);
1206 t.set_phase(GoalPhase::Executing);
1207
1208 assert!(t.budget_limit());
1209 assert_eq!(t.status(), Some(GoalStatus::BudgetLimited));
1210 assert_eq!(t.phase(), Some(GoalPhase::Idle));
1211 }
1212
1213 #[test]
1214 fn pause_reason_maps_to_correct_status() {
1215 let mut t = make_tracker();
1216 activate_tracker(&mut t);
1217
1218 assert!(t.pause(GoalPauseReason::User));
1219 assert_eq!(t.status(), Some(GoalStatus::UserPaused));
1220
1221 t.resume();
1222 assert!(t.pause(GoalPauseReason::BackOff));
1223 assert_eq!(t.status(), Some(GoalStatus::BackOffPaused));
1224
1225 t.resume();
1226 assert!(t.pause(GoalPauseReason::NoProgress));
1227 assert_eq!(t.status(), Some(GoalStatus::NoProgressPaused));
1228
1229 t.resume();
1230 assert!(t.pause_with_message(GoalPauseReason::Infra, "Turn failed: rate limit".into()));
1231 assert_eq!(t.status(), Some(GoalStatus::InfraPaused));
1232 }
1233
1234 #[test]
1235 fn is_paused_matches_all_paused_variants() {
1236 assert!(GoalStatus::UserPaused.is_paused());
1237 assert!(GoalStatus::BackOffPaused.is_paused());
1238 assert!(GoalStatus::NoProgressPaused.is_paused());
1239 assert!(GoalStatus::InfraPaused.is_paused());
1240 assert!(GoalStatus::Blocked.is_paused());
1241 assert!(!GoalStatus::Active.is_paused());
1242 assert!(!GoalStatus::Complete.is_paused());
1243 assert!(!GoalStatus::BudgetLimited.is_paused());
1244 }
1245
1246 #[test]
1247 fn no_progress_paused_round_trips_distinctly_from_back_off() {
1248 assert_eq!(GoalStatus::from_wire_str("no_progress_paused"), GoalStatus::NoProgressPaused);
1249 let json = serde_json::to_string(&GoalStatus::NoProgressPaused).unwrap();
1250 assert_eq!(json, "\"no_progress_paused\"");
1251 let back: GoalStatus = serde_json::from_str(&json).unwrap();
1252 assert_eq!(back, GoalStatus::NoProgressPaused);
1253 assert_eq!(GoalStatus::from_wire_str("back_off_paused"), GoalStatus::BackOffPaused);
1254 assert_ne!(GoalStatus::NoProgressPaused, GoalStatus::BackOffPaused);
1255 }
1256
1257 #[test]
1258 fn resume_from_user_paused() {
1259 let mut t = make_tracker();
1260 activate_tracker(&mut t);
1261 t.pause(GoalPauseReason::User);
1262 assert!(t.resume());
1263 assert_eq!(t.status(), Some(GoalStatus::Active));
1264 }
1265
1266 #[test]
1267 fn resume_from_infra_paused() {
1268 let mut t = make_tracker();
1269 activate_tracker(&mut t);
1270 t.pause_with_message(GoalPauseReason::Infra, "Turn failed: auth".into());
1271 assert_eq!(t.snapshot().and_then(|o| o.pause_message.clone()), Some("Turn failed: auth".into()));
1272 assert!(t.resume());
1273 assert_eq!(t.status(), Some(GoalStatus::Active));
1274 assert!(t.snapshot().unwrap().pause_message.is_none());
1275 }
1276
1277 #[test]
1278 fn pause_with_verification_reason_transitions_to_blocked() {
1279 let mut t = make_tracker();
1280 activate_tracker(&mut t);
1281
1282 assert!(t.pause(GoalPauseReason::Verification));
1283 assert_eq!(t.status(), Some(GoalStatus::Blocked));
1284 assert!(t.status().unwrap().is_paused());
1285 }
1286
1287 #[test]
1288 fn resume_from_blocked_transitions_to_active() {
1289 let mut t = make_tracker();
1290 activate_tracker(&mut t);
1291 t.pause(GoalPauseReason::Verification);
1292 assert!(t.resume());
1293 assert_eq!(t.status(), Some(GoalStatus::Active));
1294 }
1295
1296 #[test]
1297 fn unknown_future_paused_status_deserializes_to_user_paused() {
1298 let parsed: GoalStatus = serde_json::from_str(r#""error_paused""#).unwrap();
1299 assert_eq!(parsed, GoalStatus::UserPaused);
1300 }
1301
1302 #[test]
1303 fn unknown_non_paused_status_deserializes_to_user_paused_not_active() {
1304 for wire in [r#""quarantined""#, r#""v9_super_active""#, r#""""#] {
1305 let parsed: GoalStatus = serde_json::from_str(wire).unwrap();
1306 assert_eq!(parsed, GoalStatus::UserPaused, "wire {wire}");
1307 }
1308 assert_eq!(GoalStatus::from_wire_str("not-a-status"), GoalStatus::UserPaused,);
1309 }
1310
1311 #[test]
1312 fn legacy_pascal_case_paused_deserializes_to_user_paused() {
1313 let legacy = r#""Paused""#;
1314 let parsed: GoalStatus = serde_json::from_str(legacy).unwrap();
1315 assert_eq!(parsed, GoalStatus::UserPaused);
1316 }
1317
1318 #[test]
1319 fn legacy_pascal_case_other_variants_deserialize() {
1320 for (legacy, expected) in [
1321 (r#""Active""#, GoalStatus::Active),
1322 (r#""BudgetLimited""#, GoalStatus::BudgetLimited),
1323 (r#""Complete""#, GoalStatus::Complete),
1324 ] {
1325 let parsed: GoalStatus = serde_json::from_str(legacy).unwrap();
1326 assert_eq!(parsed, expected, "legacy {legacy} must parse");
1327 }
1328 }
1329
1330 #[test]
1331 fn legacy_infra_paused_deserializes() {
1332 let parsed: GoalStatus = serde_json::from_str(r#""infra_paused""#).unwrap();
1333 assert_eq!(parsed, GoalStatus::InfraPaused);
1334 }
1335
1336 #[test]
1337 fn goal_event_unknown_string_deserializes_to_unknown() {
1338 let unknown: GoalEvent = serde_json::from_str("\"some_future_event\"").unwrap();
1339 assert!(matches!(unknown, GoalEvent::Unknown));
1340 let known: GoalEvent = serde_json::from_str("\"goal_paused\"").unwrap();
1341 assert!(matches!(known, GoalEvent::GoalPaused));
1342 }
1343
1344 #[test]
1345 fn record_classifier_stall_trips_on_two_consecutive_identical_fingerprints() {
1346 let mut t = make_tracker();
1347 activate_tracker(&mut t);
1348 assert!(!t.record_classifier_stall("fp-a"), "first occurrence of a fingerprint is not a stall");
1349 assert!(t.record_classifier_stall("fp-a"), "the same fingerprint twice running trips the stall early-exit");
1350 assert_eq!(t.snapshot().unwrap().classifier_stall_count, 2);
1351 }
1352
1353 #[test]
1354 fn record_classifier_stall_resets_when_fingerprint_changes() {
1355 let mut t = make_tracker();
1356 activate_tracker(&mut t);
1357 assert!(!t.record_classifier_stall("fp-a"));
1358 assert!(t.record_classifier_stall("fp-a"));
1359 assert!(
1360 !t.record_classifier_stall("fp-b"),
1361 "a different fingerprint resets the streak to its first occurrence"
1362 );
1363 assert_eq!(t.snapshot().unwrap().classifier_stall_count, 1);
1364 assert!(t.record_classifier_stall("fp-b"), "the new fingerprint then trips on its own second occurrence");
1365 }
1366
1367 #[test]
1368 fn reset_classifier_stall_clears_streak_so_next_occurrence_is_first() {
1369 let mut t = make_tracker();
1370 activate_tracker(&mut t);
1371 assert!(!t.record_classifier_stall("fp-a"));
1372 assert!(t.record_classifier_stall("fp-a"));
1373 t.reset_classifier_stall();
1374 {
1375 let o = t.snapshot().unwrap();
1376 assert_eq!(o.classifier_stall_count, 0);
1377 assert!(o.last_gap_fingerprint.is_none());
1378 }
1379 assert!(!t.record_classifier_stall("fp-a"), "after reset, a repeat of the old fingerprint must not re-stall");
1380 }
1381
1382 #[test]
1383 fn full_lifecycle_create_to_complete() {
1384 let mut t = make_tracker();
1385 activate_tracker(&mut t);
1386
1387 t.set_phase(GoalPhase::Executing);
1388 assert_eq!(t.phase(), Some(GoalPhase::Executing));
1389
1390 t.set_current_subagent(Some("sub-1".into()), Some("worker".into()));
1391 assert_eq!(t.current_subagent_id(), Some("sub-1"));
1392
1393 assert!(t.complete());
1394 assert_eq!(t.status(), Some(GoalStatus::Complete));
1395 assert_eq!(t.phase(), Some(GoalPhase::Idle));
1396 assert!(t.current_subagent_id().is_none());
1397 assert!(t.active_since.is_none());
1398 }
1399
1400 #[test]
1401 fn serde_round_trip_preserves_data() {
1402 let mut t = make_tracker();
1403 activate_tracker(&mut t);
1404 t.set_phase(GoalPhase::Executing);
1405
1406 let original = t.snapshot().unwrap().clone();
1407 let json = serde_json::to_string(&original).unwrap();
1408 let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();
1409
1410 assert_eq!(restored.goal_id, original.goal_id);
1411 assert_eq!(restored.objective, original.objective);
1412 assert_eq!(restored.status, original.status);
1413 assert_eq!(restored.phase, original.phase);
1414 }
1415
1416 #[test]
1417 fn record_not_achieved_streak_increments_and_returns_new_count() {
1418 let mut t = make_tracker();
1419 activate_tracker(&mut t);
1420 assert_eq!(t.record_not_achieved_streak(), 1);
1421 assert_eq!(t.record_not_achieved_streak(), 2);
1422 assert_eq!(t.record_not_achieved_streak(), 3);
1423 assert_eq!(t.snapshot().unwrap().consecutive_not_achieved, 3);
1424 }
1425
1426 #[test]
1427 fn claim_strategist_fire_marks_current_streak() {
1428 let mut t = make_tracker();
1429 activate_tracker(&mut t);
1430 let _ = t.record_not_achieved_streak();
1431 let _ = t.record_not_achieved_streak();
1432 assert_eq!(t.claim_strategist_fire(|_, _| true), Some(2));
1433 let o = t.snapshot().unwrap();
1434 assert_eq!((o.consecutive_not_achieved, o.last_strategist_fired_at), (2, 2));
1435 }
1436
1437 #[test]
1438 fn claim_strategist_fire_skips_and_preserves_state_when_predicate_false() {
1439 let mut t = make_tracker();
1440 activate_tracker(&mut t);
1441 let _ = t.record_not_achieved_streak();
1442 assert_eq!(t.claim_strategist_fire(|_, _| false), None);
1443 let o = t.snapshot().unwrap();
1444 assert_eq!(o.last_strategist_fired_at, 0, "no fire => marker untouched");
1445 assert_eq!(o.strategist_cap_bonus, 0, "no fire => no cap bonus");
1446 }
1447
1448 #[test]
1449 fn strategist_fire_grants_cap_bonus_then_reset_clears_it() {
1450 let mut t = make_tracker();
1451 activate_tracker(&mut t);
1452 assert_eq!(t.snapshot().unwrap().strategist_cap_bonus, 0);
1453 let _ = t.record_not_achieved_streak();
1454 let _ = t.record_not_achieved_streak();
1455 let _ = t.claim_strategist_fire(|_, _| true);
1456 assert_eq!(t.snapshot().unwrap().strategist_cap_bonus, 3,);
1457 t.reset_strategist_state();
1458 assert_eq!(t.snapshot().unwrap().strategist_cap_bonus, 0);
1459 }
1460
1461 #[test]
1462 fn reset_strategist_state_clears_streak_marker_and_recommendation() {
1463 let mut t = make_tracker();
1464 activate_tracker(&mut t);
1465 let _ = t.record_not_achieved_streak();
1466 let _ = t.record_not_achieved_streak();
1467 let _ = t.claim_strategist_fire(|_, _| true);
1468 t.record_strategy_recommendation("/tmp/goal/strategy.md".into(), "split it".into());
1469
1470 t.reset_strategist_state();
1471
1472 let o = t.snapshot().unwrap();
1473 assert_eq!(o.consecutive_not_achieved, 0);
1474 assert_eq!(o.last_strategist_fired_at, 0);
1475 assert!(o.last_strategy_path.is_none());
1476 assert!(o.last_strategy_recommendation.is_none());
1477 }
1478}