Skip to main content

vtcode_memory/
progress.rs

1//! Durable, compaction-safe progress ledger.
2//!
3//! Long-horizon agent capability requires a persistent signal of *goal
4//! progress* that survives compaction, fork, and resume. The live conversation
5//! is never reloaded into context from disk, but the progress ledger is a tiny
6//! derived artifact (like `manifest.json`) that the harness can read on each
7//! turn to decide whether work is actually advancing toward completion.
8//!
9//! The ledger is stored under `<session_dir>/derived/progress.json` and
10//! overwritten on each update — it is a single mutable summary, not an
11//! append-only log, which keeps reads O(1) and cheap.
12
13use chrono::Utc;
14use serde::{Deserialize, Serialize};
15use std::path::{Path, PathBuf};
16
17use crate::error::SessionStoreError;
18use crate::session_dir;
19
20/// Lifecycle status of a single milestone toward the session goal.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum MilestoneStatus {
24    /// Not yet started.
25    Pending,
26    /// Actively being worked on this/last turn.
27    InProgress,
28    /// Completed and verified.
29    Done,
30    /// Blocked — cannot proceed without external input or a replan.
31    Blocked,
32}
33
34impl MilestoneStatus {
35    /// Whether this status counts as forward progress toward completion.
36    #[must_use]
37    pub fn is_terminal(&self) -> bool {
38        matches!(self, MilestoneStatus::Done)
39    }
40}
41
42/// A single tracked milestone derived from the task tracker / plan.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct Milestone {
45    /// Stable identifier (e.g. tracker item index or plan item id).
46    pub id: String,
47    /// Human-readable description.
48    pub description: String,
49    /// Current status.
50    pub status: MilestoneStatus,
51}
52
53/// Compact, durable progress signal for one session.
54///
55/// This is the harness's externalized memory of "are we getting closer to
56/// done?" It is intentionally small so it can be loaded every turn without
57/// touching the event log. Includes handoff metadata so cross-session
58/// continuity is explicit in the ledger itself.
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct ProgressLedger {
61    /// Owning session id.
62    pub session_id: String,
63    /// The objective the agent is pursuing.
64    pub goal: String,
65    /// Tracked milestones; empty when the agent has no explicit tracker.
66    pub milestones: Vec<Milestone>,
67    /// Agent's confidence in eventual completion, `0.0..=1.0`.
68    pub confidence: f32,
69    /// RFC3339 timestamp of the first turn where no forward progress was
70    /// detected, or `None` if progress is currently being made.
71    pub stalled_since: Option<String>,
72    /// RFC3339 timestamp of the last ledger update.
73    pub updated_at: String,
74    /// The session id of the predecessor session that handed off to this one.
75    /// `None` for the first session in a chain.
76    #[serde(default)]
77    pub previous_session_id: Option<String>,
78    /// Summary communicated by the previous session at handoff time.
79    #[serde(default)]
80    pub handoff_summary: Option<String>,
81    /// Issues carried forward from the previous session.
82    #[serde(default)]
83    pub known_issues: Vec<String>,
84    /// Git commit hash at the time of handoff (the "checkpoint").
85    #[serde(default)]
86    pub git_checkpoint: Option<String>,
87}
88
89impl ProgressLedger {
90    /// Create a fresh ledger for a session with an initial goal.
91    #[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    /// Fraction of milestones in a terminal (`Done`) state, `0.0..=1.0`.
109    /// Returns `1.0` when there are no milestones (nothing tracked yet).
110    #[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    /// Whether every tracked milestone is complete (or none are tracked).
120    #[must_use]
121    pub fn is_complete(&self) -> bool {
122        self.completion_ratio() >= 1.0
123    }
124
125    /// Whether the ledger currently reports a stall.
126    #[must_use]
127    pub fn is_stalled(&self) -> bool {
128        self.stalled_since.is_some()
129    }
130
131    /// Record forward progress: clears any stall marker and refreshes the
132    /// timestamp. Confidence is nudged upward (bounded at 1.0).
133    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    /// Record a stall: sets `stalled_since` on first occurrence and refreshes
140    /// the timestamp. Confidence is nudged downward (bounded at `0.0`).
141    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    /// Replace the milestone set and refresh the timestamp.
150    pub fn set_milestones(&mut self, milestones: Vec<Milestone>) {
151        self.milestones = milestones;
152        self.updated_at = Utc::now().to_rfc3339();
153    }
154
155    /// Set the session goal and refresh the timestamp.
156    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    /// Record handoff metadata from a previous session.
162    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    /// Add a known issue carried forward from a previous session.
170    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    /// Render a compact, human-readable progress summary for durable memory
176    /// (e.g. `<workspace>/memories/progress.md`). Survives compaction and gives
177    /// a resumed session an accurate picture of what is done. Includes handoff
178    /// metadata when present so the next session can orient from this alone.
179    #[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/// Resolve the on-disk path of the progress ledger for a session.
226#[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
233/// Load the progress ledger for a session, if one has been persisted.
234///
235/// Returns `Ok(None)` when no ledger file exists yet (a fresh or pre-ledger
236/// session) rather than an error, so callers can treat absence as "no signal".
237pub 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
247/// Persist the progress ledger for a session, creating `derived/` if needed.
248pub 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
396// ============================================================================
397// Goal tracker state machine
398// ============================================================================
399
400use std::time::Instant;
401
402/// Phase of goal execution.
403#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
404#[serde(rename_all = "snake_case")]
405pub enum GoalPhase {
406    /// Goal is idle; no active planning or execution.
407    Idle,
408    /// Planning phase is in progress.
409    Planning,
410    /// Execution phase is in progress.
411    Executing,
412}
413
414/// Lifecycle status of a goal.
415///
416/// The paused variants encode the reason the goal was paused:
417/// - `UserPaused` for explicit pause requests
418/// - `BackOffPaused` when the classifier run cap is hit
419/// - `NoProgressPaused` when the verifier flags the same gaps with no progress
420/// - `InfraPaused` when a turn finishes with an infrastructure error
421/// - `Blocked` when the model determined the goal is not achievable
422///
423/// **Backwards-compat serde aliases:** older shells serialized this
424/// enum with the default PascalCase form (`"Active"`, `"Paused"`,
425/// `"BudgetLimited"`, `"Complete"`). The `#[serde(alias = ...)]`
426/// attributes preserve in-flight goal snapshots written by older shells.
427#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
428#[serde(rename_all = "snake_case")]
429pub enum GoalStatus {
430    /// Goal is actively running.
431    #[serde(alias = "Active")]
432    Active,
433    /// Explicitly paused by the user.
434    #[serde(alias = "Paused")]
435    UserPaused,
436    /// Paused due to repeated classifier failures (back-off cap hit).
437    BackOffPaused,
438    /// Paused because the verifier reported the same gaps with no progress.
439    NoProgressPaused,
440    /// Paused due to an infrastructure error in a turn.
441    InfraPaused,
442    /// Blocked; the model determined the goal is not achievable.
443    Blocked,
444    /// Hit the token budget limit.
445    #[serde(alias = "BudgetLimited")]
446    BudgetLimited,
447    /// Goal completed successfully.
448    #[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    /// Parse a persisted/wire status string. Unknown values map to
464    /// `UserPaused`: a status this shell cannot interpret must restore as
465    /// a resumable paused goal, never an Active self-driving one.
466    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    /// `true` for any paused variant.
482    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/// Reason for pausing a goal. Maps 1:1 to one of the paused variants on
491/// [`GoalStatus`].
492#[derive(Debug, Clone, Copy, PartialEq, Eq)]
493pub enum GoalPauseReason {
494    /// Paused by explicit user request.
495    User,
496    /// Paused after repeated classifier failures hit the back-off cap.
497    BackOff,
498    /// Paused because the verifier reported no progress on known gaps.
499    NoProgress,
500    /// Paused because the verifier determined the goal is not achievable.
501    Verification,
502    /// Paused due to an infrastructure error.
503    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/// Aggregate verdict produced by the goal-verification stage.
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
530#[serde(rename_all = "snake_case")]
531pub enum GoalClassifierVerdict {
532    /// Goal was achieved.
533    Achieved,
534    /// Goal was not achieved.
535    NotAchieved,
536}
537
538/// Lifecycle event recorded in the goal history.
539#[derive(Debug, Clone, Serialize, Deserialize)]
540#[serde(rename_all = "snake_case")]
541pub enum GoalEvent {
542    /// Goal was created.
543    GoalCreated,
544    /// Planning phase started.
545    PlanningStarted,
546    /// Planning phase completed.
547    PlanningCompleted,
548    /// Planning phase failed.
549    PlanningFailed,
550    /// Worker started processing.
551    WorkerStarted,
552    /// Worker completed successfully.
553    WorkerCompleted,
554    /// Worker failed.
555    WorkerFailed,
556    /// Context was rotated.
557    ContextRotated,
558    /// Goal was paused.
559    GoalPaused,
560    /// Goal was resumed.
561    GoalResumed,
562    /// Goal completed successfully.
563    GoalCompleted,
564    /// Goal was cleared.
565    GoalCleared,
566    /// Budget was exceeded.
567    BudgetExceeded,
568    /// Premature stop was detected.
569    PrematureStopDetected,
570    /// Unknown or unrecognized event.
571    #[serde(other)]
572    Unknown,
573}
574
575/// A single history entry for a goal lifecycle event.
576#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct GoalHistoryEntry {
578    /// ISO-8601 timestamp of the event.
579    pub timestamp: String,
580    /// Lifecycle event type.
581    pub event: GoalEvent,
582    /// Optional human-readable detail string.
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub detail: Option<String>,
585    /// Optional round number associated with the event.
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    pub round: Option<u32>,
588    /// Optional token count at the time of the event.
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub tokens_used: Option<i64>,
591    /// Unmet requirements or blockers recorded at this event.
592    #[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/// Full persisted state for a goal orchestration.
610#[derive(Debug, Clone, Serialize, Deserialize)]
611pub struct GoalOrchestration {
612    /// Unique identifier for the goal.
613    pub goal_id: String,
614    /// Human-readable objective description.
615    pub objective: String,
616    /// Current lifecycle status.
617    pub status: GoalStatus,
618    /// Current execution phase.
619    pub phase: GoalPhase,
620    /// Optional token budget cap.
621    pub token_budget: Option<i64>,
622    /// Elapsed wall-clock time in milliseconds.
623    pub elapsed_ms: u64,
624    /// ISO-8601 creation timestamp.
625    pub created_at: String,
626    /// Currently executing subagent ID, if any.
627    pub current_subagent_id: Option<String>,
628    /// Role of the current subagent.
629    pub current_subagent_role: Option<String>,
630    /// Total worker rounds executed.
631    #[serde(default)]
632    pub total_worker_rounds: u32,
633    /// Total verification rounds executed.
634    #[serde(default)]
635    pub total_verify_rounds: u32,
636    /// Whether the budget limit notification has already been emitted.
637    #[serde(skip)]
638    pub budget_limit_reported: bool,
639    /// Baseline token count when the goal started.
640    #[serde(default)]
641    pub token_baseline: i64,
642    /// High-water mark for tokens used.
643    #[serde(default)]
644    pub tokens_used_high_water: i64,
645    /// Tokens spent by the parent session before this goal started.
646    #[serde(default)]
647    pub parent_tokens_spent: i64,
648    /// Last observed session token count.
649    #[serde(default)]
650    pub last_session_tokens_seen: Option<i64>,
651    /// Ordered history of lifecycle events.
652    pub history: Vec<GoalHistoryEntry>,
653    /// User-facing pause message, if any.
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub pause_message: Option<String>,
656    /// Number of consecutive classifier stall detections.
657    #[serde(default)]
658    pub classifier_stall_count: u32,
659    /// Total classifier run attempts.
660    #[serde(default)]
661    pub classifier_runs_attempted: u32,
662    /// Rounds since the last verification pass.
663    #[serde(default)]
664    pub rounds_since_verify: u32,
665    /// Consecutive not-achieved verdicts.
666    #[serde(default)]
667    pub consecutive_not_achieved: u32,
668    /// Turn at which the strategist last fired.
669    #[serde(default)]
670    pub last_strategist_fired_at: u32,
671    /// Bonus tokens granted by the strategist.
672    #[serde(default)]
673    pub strategist_cap_bonus: u32,
674    /// Path of the last strategy recommendation, if any.
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub last_strategy_path: Option<String>,
677    /// Last strategy recommendation text, if any.
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub last_strategy_recommendation: Option<String>,
680    /// Commit hash at the last strategy change baseline, if any.
681    #[serde(default, skip_serializing_if = "Option::is_none")]
682    pub changes_baseline_commit: Option<String>,
683    /// Fingerprint of the last gap signature, if any.
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub last_gap_fingerprint: Option<String>,
686    /// Live token count for active subagents.
687    #[serde(skip)]
688    pub live_subagent_tokens: u64,
689    /// Live token usage broken down by model.
690    #[serde(skip)]
691    pub live_tokens_by_model: Vec<(String, u64)>,
692    /// Live context window size in tokens.
693    #[serde(skip)]
694    pub live_context_window: u64,
695    /// Live context window utilization percentage (0-100).
696    #[serde(skip)]
697    pub live_context_pct: u8,
698    /// Live turn count for the current goal.
699    #[serde(skip)]
700    pub live_turn_count: u32,
701    /// Live tool call count for the current goal.
702    #[serde(skip)]
703    pub live_tool_call_count: u32,
704    /// Whether a planning pass is currently in flight.
705    #[serde(skip)]
706    pub planning_in_flight: bool,
707    /// Whether a verification pass is currently in flight.
708    #[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/// Pure state machine for goal tracking.
728#[derive(Debug)]
729pub struct GoalTracker {
730    /// Current goal orchestration state, if a goal is active.
731    orchestration: Option<GoalOrchestration>,
732    /// Directory where progress snapshots are persisted.
733    session_dir: PathBuf,
734    /// Instant when the goal became active.
735    active_since: Option<Instant>,
736}
737
738impl GoalTracker {
739    /// Create a new tracker with no active goal.
740    pub fn new(session_dir: PathBuf) -> Self {
741        Self {
742            orchestration: None,
743            session_dir,
744            active_since: None,
745        }
746    }
747
748    /// Restore tracker state from a persisted snapshot.
749    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    /// Return an immutable reference to the current orchestration snapshot.
776    pub fn snapshot(&self) -> Option<&GoalOrchestration> {
777        self.orchestration.as_ref()
778    }
779
780    /// Return a mutable reference to the current orchestration snapshot.
781    pub fn snapshot_mut(&mut self) -> Option<&mut GoalOrchestration> {
782        self.orchestration.as_mut()
783    }
784
785    /// `true` if a goal is currently active.
786    pub fn is_active(&self) -> bool {
787        self.orchestration.as_ref().is_some_and(|o| o.status == GoalStatus::Active)
788    }
789
790    /// Current execution phase, if a goal is active.
791    pub fn phase(&self) -> Option<GoalPhase> {
792        self.orchestration.as_ref().map(|o| o.phase)
793    }
794
795    /// Current lifecycle status, if a goal is active.
796    pub fn status(&self) -> Option<GoalStatus> {
797        self.orchestration.as_ref().map(|o| o.status)
798    }
799
800    /// ID of the currently executing subagent, if any.
801    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    /// Human-readable objective, if a goal is active.
806    pub fn objective(&self) -> Option<&str> {
807        self.orchestration.as_ref().map(|o| o.objective.as_str())
808    }
809
810    /// Token budget cap, if set.
811    pub fn token_budget(&self) -> Option<i64> {
812        self.orchestration.as_ref().and_then(|o| o.token_budget)
813    }
814
815    /// Create a new goal. Replaces any existing orchestration.
816    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    /// Update the execution phase of the active goal.
872    pub fn set_phase(&mut self, phase: GoalPhase) {
873        if let Some(o) = &mut self.orchestration {
874            o.phase = phase;
875        }
876    }
877
878    /// Update the current subagent ID and role.
879    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    /// Pause the goal with a specific reason. Only transitions from `Active`.
887    pub fn pause(&mut self, reason: GoalPauseReason) -> bool {
888        self.pause_inner(reason, None)
889    }
890
891    /// Like [`Self::pause`] but also stores a human-readable `message`.
892    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    /// Resume a paused goal (any paused variant). Returns `true` if applied.
918    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    /// Mark the goal as complete. Accepts `Active` or any paused variant.
936    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    /// Mark the goal as budget-limited. Accepts `Active` or any paused variant.
956    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    /// Clear the goal entirely.
976    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    /// Flush elapsed wall-clock time into `elapsed_ms`.
990    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    /// Record a `NotAchieved` rejection's gap `fingerprint` and report
1000    /// whether the goal has stalled.
1001    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    /// Undo the most recent attempt-slot reservation.
1015    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    /// Clear the stall streak.
1022    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    /// Increment the consecutive-`NotAchieved` streak and return the new value.
1029    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    /// Atomically evaluate the strategist trigger and claim a fire.
1040    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    /// Revoke the cap bonus granted by [`Self::claim_strategist_fire`].
1053    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    /// Reset ALL strategist state.
1060    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    /// Persist the strategist's latest output path + short recommendation.
1067    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    /// Append a history entry to the active goal, if any.
1075    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}