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::fmt::Write as _;
16use std::path::{Path, PathBuf};
17
18use crate::error::SessionStoreError;
19use crate::session_dir;
20
21/// Lifecycle status of a single milestone toward the session goal.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum MilestoneStatus {
25    /// Not yet started.
26    Pending,
27    /// Actively being worked on this/last turn.
28    InProgress,
29    /// Completed and verified.
30    Done,
31    /// Blocked — cannot proceed without external input or a replan.
32    Blocked,
33}
34
35impl MilestoneStatus {
36    /// Whether this status counts as forward progress toward completion.
37    #[must_use]
38    fn is_terminal(&self) -> bool {
39        matches!(self, MilestoneStatus::Done)
40    }
41}
42
43/// A single tracked milestone derived from the task tracker / plan.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct Milestone {
46    /// Stable identifier (e.g. tracker item index or plan item id).
47    pub id: String,
48    /// Human-readable description.
49    pub description: String,
50    /// Current status.
51    pub status: MilestoneStatus,
52}
53
54/// Compact, durable progress signal for one session.
55///
56/// This is the harness's externalized memory of "are we getting closer to
57/// done?" It is intentionally small so it can be loaded every turn without
58/// touching the event log. Includes handoff metadata so cross-session
59/// continuity is explicit in the ledger itself.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct ProgressLedger {
62    /// Owning session id.
63    pub session_id: String,
64    /// The objective the agent is pursuing.
65    pub goal: String,
66    /// Tracked milestones; empty when the agent has no explicit tracker.
67    milestones: Vec<Milestone>,
68    /// Agent's confidence in eventual completion, `0.0..=1.0`.
69    pub confidence: f32,
70    /// RFC3339 timestamp of the first turn where no forward progress was
71    /// detected, or `None` if progress is currently being made.
72    stalled_since: Option<String>,
73    /// RFC3339 timestamp of the last ledger update.
74    updated_at: String,
75    /// The session id of the predecessor session that handed off to this one.
76    /// `None` for the first session in a chain.
77    #[serde(default)]
78    previous_session_id: Option<String>,
79    /// Summary communicated by the previous session at handoff time.
80    #[serde(default)]
81    handoff_summary: Option<String>,
82    /// Issues carried forward from the previous session.
83    #[serde(default)]
84    known_issues: Vec<String>,
85    /// Git commit hash at the time of handoff (the "checkpoint").
86    #[serde(default)]
87    git_checkpoint: Option<String>,
88}
89
90impl ProgressLedger {
91    /// Create a fresh ledger for a session with an initial goal.
92    #[must_use]
93    pub fn new(session_id: &str, goal: &str) -> Self {
94        let ts = Utc::now().to_rfc3339();
95        Self {
96            session_id: session_id.to_string(),
97            goal: goal.to_string(),
98            milestones: Vec::new(),
99            confidence: 1.0,
100            stalled_since: None,
101            updated_at: ts,
102            previous_session_id: None,
103            handoff_summary: None,
104            known_issues: Vec::new(),
105            git_checkpoint: None,
106        }
107    }
108
109    /// Fraction of milestones in a terminal (`Done`) state, `0.0..=1.0`.
110    /// Returns `1.0` when there are no milestones (nothing tracked yet).
111    #[must_use]
112    pub fn completion_ratio(&self) -> f32 {
113        if self.milestones.is_empty() {
114            return 1.0;
115        }
116        let done = self.milestones.iter().filter(|m| m.status.is_terminal()).count() as f32;
117        done / self.milestones.len() as f32
118    }
119
120    /// Whether every tracked milestone is complete (or none are tracked).
121    #[must_use]
122    pub fn is_complete(&self) -> bool {
123        self.completion_ratio() >= 1.0
124    }
125
126    /// Whether the ledger currently reports a stall.
127    #[must_use]
128    pub fn is_stalled(&self) -> bool {
129        self.stalled_since.is_some()
130    }
131
132    /// Record forward progress: clears any stall marker and refreshes the
133    /// timestamp. Confidence is nudged upward (bounded at 1.0).
134    pub fn note_advance(&mut self) {
135        self.stalled_since = None;
136        self.confidence = (self.confidence + 0.05).min(1.0);
137        self.updated_at = Utc::now().to_rfc3339();
138    }
139
140    /// Record a stall: sets `stalled_since` on first occurrence and refreshes
141    /// the timestamp. Confidence is nudged downward (bounded at `0.0`).
142    pub fn note_stall(&mut self) {
143        if self.stalled_since.is_none() {
144            self.stalled_since = Some(Utc::now().to_rfc3339());
145        }
146        self.confidence = (self.confidence - 0.1).max(0.0);
147        self.updated_at = Utc::now().to_rfc3339();
148    }
149
150    /// Replace the milestone set and refresh the timestamp.
151    pub fn set_milestones(&mut self, milestones: Vec<Milestone>) {
152        self.milestones = milestones;
153        self.updated_at = Utc::now().to_rfc3339();
154    }
155
156    /// Set the session goal and refresh the timestamp.
157    pub fn set_goal(&mut self, goal: &str) {
158        self.goal = goal.to_string();
159        self.updated_at = Utc::now().to_rfc3339();
160    }
161
162    /// Record handoff metadata from a previous session.
163    fn set_handoff(&mut self, previous_session_id: &str, summary: &str, git_checkpoint: Option<String>) {
164        self.previous_session_id = Some(previous_session_id.to_string());
165        self.handoff_summary = Some(summary.to_string());
166        self.git_checkpoint = git_checkpoint;
167        self.updated_at = Utc::now().to_rfc3339();
168    }
169
170    /// Add a known issue carried forward from a previous session.
171    fn add_known_issue(&mut self, issue: &str) {
172        self.known_issues.push(issue.to_string());
173        self.updated_at = Utc::now().to_rfc3339();
174    }
175
176    /// Render a compact, human-readable progress summary for durable memory
177    /// (e.g. `<workspace>/memories/progress.md`). Survives compaction and gives
178    /// a resumed session an accurate picture of what is done. Includes handoff
179    /// metadata when present so the next session can orient from this alone.
180    #[must_use]
181    pub fn to_markdown(&self) -> String {
182        let mut out = String::new();
183        out.push_str("# Session Progress\n\n");
184        let _ = writeln!(out, "**Goal:** {}", self.goal);
185        let _ = writeln!(out, "**Completion:** {:.0}%", (self.completion_ratio() * 100.0).round());
186        let _ = writeln!(out, "**Confidence:** {:.2}", self.confidence);
187        if let Some(since) = &self.stalled_since {
188            let _ = writeln!(out, "**Stalled since:** {since}");
189        }
190        let _ = writeln!(out, "**Updated:** {}\n", self.updated_at);
191
192        if let Some(prev) = &self.previous_session_id {
193            let _ = writeln!(out, "**Handed off from:** {prev}");
194        }
195        if let Some(summary) = &self.handoff_summary {
196            let _ = writeln!(out, "**Handoff summary:** {summary}");
197        }
198        if let Some(checkpoint) = &self.git_checkpoint {
199            let _ = writeln!(out, "**Git checkpoint:** `{checkpoint}`");
200        }
201        if !self.known_issues.is_empty() {
202            out.push_str("\n## Known Issues\n\n");
203            for issue in &self.known_issues {
204                let _ = writeln!(out, "- {issue}");
205            }
206        }
207
208        if self.milestones.is_empty() {
209            out.push_str("\n_No tracked milestones yet._\n");
210        } else {
211            out.push_str("\n## Milestones\n\n");
212            for m in &self.milestones {
213                let mark = match m.status {
214                    MilestoneStatus::Done => "[x]",
215                    MilestoneStatus::InProgress => "[~]",
216                    MilestoneStatus::Blocked => "[!]",
217                    MilestoneStatus::Pending => "[ ]",
218                };
219                let _ = writeln!(out, "{} {} — {}", mark, m.id, m.description);
220            }
221        }
222        out
223    }
224}
225
226/// Resolve the on-disk path of the progress ledger for a session.
227#[must_use]
228pub fn progress_path(workspace: &Path, session_id: &str) -> PathBuf {
229    session_dir(workspace, session_id)
230        .join(crate::DERIVED_DIR)
231        .join("progress.json")
232}
233
234/// Load the progress ledger for a session, if one has been persisted.
235///
236/// Returns `Ok(None)` when no ledger file exists yet (a fresh or pre-ledger
237/// session) rather than an error, so callers can treat absence as "no signal".
238pub fn load_progress(workspace: &Path, session_id: &str) -> Result<Option<ProgressLedger>, SessionStoreError> {
239    let path = progress_path(workspace, session_id);
240    if !path.exists() {
241        return Ok(None);
242    }
243    let bytes = std::fs::read(&path).map_err(|e| SessionStoreError::io(path.clone(), e))?;
244    let ledger: ProgressLedger = serde_json::from_slice(&bytes)?;
245    Ok(Some(ledger))
246}
247
248/// Persist the progress ledger for a session, creating `derived/` if needed.
249pub fn save_progress(workspace: &Path, session_id: &str, ledger: &ProgressLedger) -> Result<(), SessionStoreError> {
250    let path = progress_path(workspace, session_id);
251    if let Some(parent) = path.parent() {
252        std::fs::create_dir_all(parent)
253            .map_err(|e| SessionStoreError::CreateDir { path: parent.to_path_buf(), source: e })?;
254    }
255    let bytes = serde_json::to_vec(ledger)?;
256    std::fs::write(&path, bytes).map_err(|e| SessionStoreError::io(path, e))?;
257    Ok(())
258}
259
260#[cfg(test)]
261mod progress_tests {
262    use super::*;
263
264    fn sample_ledger() -> ProgressLedger {
265        let mut l = ProgressLedger::new("s1", "ship the feature");
266        l.set_milestones(vec![
267            Milestone {
268                id: "1".into(),
269                description: "design".into(),
270                status: MilestoneStatus::Done,
271            },
272            Milestone {
273                id: "2".into(),
274                description: "implement".into(),
275                status: MilestoneStatus::InProgress,
276            },
277            Milestone {
278                id: "3".into(),
279                description: "verify".into(),
280                status: MilestoneStatus::Pending,
281            },
282        ]);
283        l
284    }
285
286    #[test]
287    fn completion_ratio_reflects_terminal_milestones() {
288        let l = sample_ledger();
289        assert!((l.completion_ratio() - 1.0 / 3.0).abs() < f32::EPSILON);
290        assert!(!l.is_complete());
291    }
292
293    #[test]
294    fn empty_ledger_is_complete() {
295        let l = ProgressLedger::new("s", "goal");
296        assert!(l.is_complete());
297        assert!((l.completion_ratio() - 1.0).abs() < f32::EPSILON);
298    }
299
300    #[test]
301    fn advance_clears_stall_and_bumps_confidence() {
302        let mut l = sample_ledger();
303        l.note_stall();
304        assert!(l.is_stalled());
305        let before = l.confidence;
306        l.note_advance();
307        assert!(!l.is_stalled());
308        assert!(l.confidence >= before);
309    }
310
311    #[test]
312    fn persistence_round_trips() {
313        let tmp = std::env::temp_dir().join(format!("vtcode-prog-{}", std::process::id()));
314        let ws = tmp.join("ws");
315        std::fs::create_dir_all(&ws).unwrap();
316        let mut l = sample_ledger();
317        l.note_stall();
318        save_progress(&ws, "s1", &l).unwrap();
319        let loaded = load_progress(&ws, "s1").unwrap().expect("ledger present");
320        assert_eq!(loaded, l);
321        assert!(loaded.is_stalled());
322        assert!(load_progress(&ws, "absent").unwrap().is_none());
323        let _ = std::fs::remove_dir_all(&tmp);
324    }
325
326    #[test]
327    fn handoff_metadata_defaults_to_none() {
328        let l = ProgressLedger::new("s1", "goal");
329        assert!(l.previous_session_id.is_none());
330        assert!(l.handoff_summary.is_none());
331        assert!(l.known_issues.is_empty());
332        assert!(l.git_checkpoint.is_none());
333    }
334
335    #[test]
336    fn set_handoff_records_metadata() {
337        let mut l = ProgressLedger::new("s2", "goal");
338        l.set_handoff("s1", "implemented login", Some("abc123".to_string()));
339        assert_eq!(l.previous_session_id.as_deref(), Some("s1"));
340        assert_eq!(l.handoff_summary.as_deref(), Some("implemented login"));
341        assert_eq!(l.git_checkpoint.as_deref(), Some("abc123"));
342    }
343
344    #[test]
345    fn add_known_issue_accumulates() {
346        let mut l = ProgressLedger::new("s3", "goal");
347        l.add_known_issue("rate limiting missing");
348        l.add_known_issue("no error handling for timeouts");
349        assert_eq!(l.known_issues.len(), 2);
350        assert_eq!(l.known_issues[0], "rate limiting missing");
351    }
352
353    #[test]
354    fn handoff_metadata_survives_persistence() {
355        let tmp = std::env::temp_dir().join(format!("vtcode-prog-{}", std::process::id()));
356        let ws = tmp.join("ws");
357        std::fs::create_dir_all(&ws).unwrap();
358
359        let mut l = sample_ledger();
360        l.set_handoff("prev-session", "built auth", Some("def456".to_string()));
361        l.add_known_issue("tests are flaky");
362
363        save_progress(&ws, "s4", &l).unwrap();
364        let loaded = load_progress(&ws, "s4").unwrap().expect("present");
365        assert_eq!(loaded.previous_session_id.as_deref(), Some("prev-session"));
366        assert_eq!(loaded.handoff_summary.as_deref(), Some("built auth"));
367        assert_eq!(loaded.git_checkpoint.as_deref(), Some("def456"));
368        assert_eq!(loaded.known_issues, vec!["tests are flaky"]);
369
370        let _ = std::fs::remove_dir_all(&tmp);
371    }
372
373    #[test]
374    fn to_markdown_includes_handoff_metadata() {
375        let mut l = ProgressLedger::new("s5", "build feature");
376        l.set_handoff("s4", "implemented core", Some("abc123".to_string()));
377        l.add_known_issue("missing error handling");
378
379        let md = l.to_markdown();
380        assert!(md.contains("Handed off from:** s4"));
381        assert!(md.contains("Handoff summary:** implemented core"));
382        assert!(md.contains("Git checkpoint:** `abc123`"));
383        assert!(md.contains("- missing error handling"));
384    }
385
386    #[test]
387    fn to_markdown_omits_handoff_when_absent() {
388        let l = ProgressLedger::new("s6", "goal");
389        let md = l.to_markdown();
390        assert!(!md.contains("Handed off from"));
391        assert!(!md.contains("Handoff summary"));
392        assert!(!md.contains("Git checkpoint"));
393        assert!(!md.contains("Known Issues"));
394    }
395}
396
397// ============================================================================
398// Goal tracker state machine
399// ============================================================================
400
401use std::time::Instant;
402
403/// Phase of goal execution.
404#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(rename_all = "snake_case")]
406pub enum GoalPhase {
407    /// Goal is idle; no active planning or execution.
408    Idle,
409    /// Planning phase is in progress.
410    Planning,
411    /// Execution phase is in progress.
412    Executing,
413}
414
415/// Lifecycle status of a goal.
416///
417/// The paused variants encode the reason the goal was paused:
418/// - `UserPaused` for explicit pause requests
419/// - `BackOffPaused` when the classifier run cap is hit
420/// - `NoProgressPaused` when the verifier flags the same gaps with no progress
421/// - `InfraPaused` when a turn finishes with an infrastructure error
422/// - `Blocked` when the model determined the goal is not achievable
423///
424/// **Backwards-compat serde aliases:** older shells serialized this
425/// enum with the default PascalCase form (`"Active"`, `"Paused"`,
426/// `"BudgetLimited"`, `"Complete"`). The `#[serde(alias = ...)]`
427/// attributes preserve in-flight goal snapshots written by older shells.
428#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
429#[serde(rename_all = "snake_case")]
430pub enum GoalStatus {
431    /// Goal is actively running.
432    #[serde(alias = "Active")]
433    Active,
434    /// Explicitly paused by the user.
435    #[serde(alias = "Paused")]
436    UserPaused,
437    /// Paused due to repeated classifier failures (back-off cap hit).
438    BackOffPaused,
439    /// Paused because the verifier reported the same gaps with no progress.
440    NoProgressPaused,
441    /// Paused due to an infrastructure error in a turn.
442    InfraPaused,
443    /// Blocked; the model determined the goal is not achievable.
444    Blocked,
445    /// Hit the token budget limit.
446    #[serde(alias = "BudgetLimited")]
447    BudgetLimited,
448    /// Goal completed successfully.
449    #[serde(alias = "Complete")]
450    Complete,
451}
452
453impl<'de> Deserialize<'de> for GoalStatus {
454    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
455    where
456        D: serde::Deserializer<'de>,
457    {
458        let s = String::deserialize(deserializer)?;
459        Ok(Self::from_wire_str(&s))
460    }
461}
462
463impl GoalStatus {
464    /// Parse a persisted/wire status string. Unknown values map to
465    /// `UserPaused`: a status this shell cannot interpret must restore as
466    /// a resumable paused goal, never an Active self-driving one.
467    fn from_wire_str(s: &str) -> Self {
468        match s {
469            "active" | "Active" => Self::Active,
470            "user_paused" | "paused" | "Paused" => Self::UserPaused,
471            "doom_loop_paused" => Self::UserPaused,
472            "back_off_paused" => Self::BackOffPaused,
473            "no_progress_paused" => Self::NoProgressPaused,
474            "infra_paused" => Self::InfraPaused,
475            "blocked" => Self::Blocked,
476            "budget_limited" | "BudgetLimited" => Self::BudgetLimited,
477            "complete" | "Complete" => Self::Complete,
478            _ => Self::UserPaused,
479        }
480    }
481
482    /// `true` for any paused variant.
483    fn is_paused(&self) -> bool {
484        matches!(
485            self,
486            Self::UserPaused | Self::BackOffPaused | Self::NoProgressPaused | Self::InfraPaused | Self::Blocked
487        )
488    }
489}
490
491/// Reason for pausing a goal. Maps 1:1 to one of the paused variants on
492/// [`GoalStatus`].
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
494pub enum GoalPauseReason {
495    /// Paused by explicit user request.
496    User,
497    /// Paused after repeated classifier failures hit the back-off cap.
498    BackOff,
499    /// Paused because the verifier reported no progress on known gaps.
500    NoProgress,
501    /// Paused because the verifier determined the goal is not achievable.
502    Verification,
503    /// Paused due to an infrastructure error.
504    Infra,
505}
506
507impl GoalPauseReason {
508    fn to_status(self) -> GoalStatus {
509        match self {
510            Self::User => GoalStatus::UserPaused,
511            Self::BackOff => GoalStatus::BackOffPaused,
512            Self::NoProgress => GoalStatus::NoProgressPaused,
513            Self::Verification => GoalStatus::Blocked,
514            Self::Infra => GoalStatus::InfraPaused,
515        }
516    }
517
518    fn history_detail(self) -> &'static str {
519        match self {
520            Self::User => "user",
521            Self::BackOff => "back_off",
522            Self::NoProgress => "no_progress",
523            Self::Verification => "blocked",
524            Self::Infra => "infra",
525        }
526    }
527}
528
529/// Aggregate verdict produced by the goal-verification stage.
530#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
531#[serde(rename_all = "snake_case")]
532pub enum GoalClassifierVerdict {
533    /// Goal was achieved.
534    Achieved,
535    /// Goal was not achieved.
536    NotAchieved,
537}
538
539/// Lifecycle event recorded in the goal history.
540#[derive(Debug, Clone, Serialize, Deserialize)]
541#[serde(rename_all = "snake_case")]
542pub enum GoalEvent {
543    /// Goal was created.
544    GoalCreated,
545    /// Planning phase started.
546    PlanningStarted,
547    /// Planning phase completed.
548    PlanningCompleted,
549    /// Planning phase failed.
550    PlanningFailed,
551    /// Worker started processing.
552    WorkerStarted,
553    /// Worker completed successfully.
554    WorkerCompleted,
555    /// Worker failed.
556    WorkerFailed,
557    /// Context was rotated.
558    ContextRotated,
559    /// Goal was paused.
560    GoalPaused,
561    /// Goal was resumed.
562    GoalResumed,
563    /// Goal completed successfully.
564    GoalCompleted,
565    /// Goal was cleared.
566    GoalCleared,
567    /// Budget was exceeded.
568    BudgetExceeded,
569    /// Premature stop was detected.
570    PrematureStopDetected,
571    /// Unknown or unrecognized event.
572    #[serde(other)]
573    Unknown,
574}
575
576/// A single history entry for a goal lifecycle event.
577#[derive(Debug, Clone, Serialize, Deserialize)]
578pub struct GoalHistoryEntry {
579    /// ISO-8601 timestamp of the event.
580    timestamp: String,
581    /// Lifecycle event type.
582    event: GoalEvent,
583    /// Optional human-readable detail string.
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    detail: Option<String>,
586    /// Optional round number associated with the event.
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    round: Option<u32>,
589    /// Optional token count at the time of the event.
590    #[serde(default, skip_serializing_if = "Option::is_none")]
591    tokens_used: Option<i64>,
592    /// Unmet requirements or blockers recorded at this event.
593    #[serde(default, skip_serializing_if = "Vec::is_empty")]
594    unmet: Vec<String>,
595}
596
597impl GoalHistoryEntry {
598    fn now(event: GoalEvent, detail: Option<String>) -> Self {
599        Self {
600            timestamp: Utc::now().to_rfc3339(),
601            event,
602            detail,
603            round: None,
604            tokens_used: None,
605            unmet: Vec::new(),
606        }
607    }
608}
609
610/// Full persisted state for a goal orchestration.
611#[derive(Debug, Clone, Serialize, Deserialize)]
612pub struct GoalOrchestration {
613    /// Unique identifier for the goal.
614    goal_id: String,
615    /// Human-readable objective description.
616    objective: String,
617    /// Current lifecycle status.
618    status: GoalStatus,
619    /// Current execution phase.
620    phase: GoalPhase,
621    /// Optional token budget cap.
622    token_budget: Option<i64>,
623    /// Elapsed wall-clock time in milliseconds.
624    elapsed_ms: u64,
625    /// ISO-8601 creation timestamp.
626    created_at: String,
627    /// Currently executing subagent ID, if any.
628    current_subagent_id: Option<String>,
629    /// Role of the current subagent.
630    current_subagent_role: Option<String>,
631    /// Total worker rounds executed.
632    #[serde(default)]
633    total_worker_rounds: u32,
634    /// Total verification rounds executed.
635    #[serde(default)]
636    total_verify_rounds: u32,
637    /// Whether the budget limit notification has already been emitted.
638    #[serde(skip)]
639    budget_limit_reported: bool,
640    /// Baseline token count when the goal started.
641    #[serde(default)]
642    token_baseline: i64,
643    /// High-water mark for tokens used.
644    #[serde(default)]
645    tokens_used_high_water: i64,
646    /// Tokens spent by the parent session before this goal started.
647    #[serde(default)]
648    parent_tokens_spent: i64,
649    /// Last observed session token count.
650    #[serde(default)]
651    last_session_tokens_seen: Option<i64>,
652    /// Ordered history of lifecycle events.
653    history: Vec<GoalHistoryEntry>,
654    /// User-facing pause message, if any.
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    pause_message: Option<String>,
657    /// Number of consecutive classifier stall detections.
658    #[serde(default)]
659    classifier_stall_count: u32,
660    /// Total classifier run attempts.
661    #[serde(default)]
662    classifier_runs_attempted: u32,
663    /// Rounds since the last verification pass.
664    #[serde(default)]
665    rounds_since_verify: u32,
666    /// Consecutive not-achieved verdicts.
667    #[serde(default)]
668    consecutive_not_achieved: u32,
669    /// Turn at which the strategist last fired.
670    #[serde(default)]
671    last_strategist_fired_at: u32,
672    /// Bonus tokens granted by the strategist.
673    #[serde(default)]
674    strategist_cap_bonus: u32,
675    /// Path of the last strategy recommendation, if any.
676    #[serde(default, skip_serializing_if = "Option::is_none")]
677    last_strategy_path: Option<String>,
678    /// Last strategy recommendation text, if any.
679    #[serde(default, skip_serializing_if = "Option::is_none")]
680    last_strategy_recommendation: Option<String>,
681    /// Commit hash at the last strategy change baseline, if any.
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    changes_baseline_commit: Option<String>,
684    /// Fingerprint of the last gap signature, if any.
685    #[serde(default, skip_serializing_if = "Option::is_none")]
686    last_gap_fingerprint: Option<String>,
687    /// Live token count for active subagents.
688    #[serde(skip)]
689    live_subagent_tokens: u64,
690    /// Live token usage broken down by model.
691    #[serde(skip)]
692    live_tokens_by_model: Vec<(String, u64)>,
693    /// Live context window size in tokens.
694    #[serde(skip)]
695    live_context_window: u64,
696    /// Live context window utilization percentage (0-100).
697    #[serde(skip)]
698    live_context_pct: u8,
699    /// Live turn count for the current goal.
700    #[serde(skip)]
701    live_turn_count: u32,
702    /// Live tool call count for the current goal.
703    #[serde(skip)]
704    live_tool_call_count: u32,
705    /// Whether a planning pass is currently in flight.
706    #[serde(skip)]
707    planning_in_flight: bool,
708    /// Whether a verification pass is currently in flight.
709    #[serde(skip)]
710    verifying_in_flight: bool,
711}
712
713impl GoalOrchestration {
714    fn reset_strategist_fields(&mut self) {
715        self.consecutive_not_achieved = 0;
716        self.last_strategist_fired_at = 0;
717        self.strategist_cap_bonus = 0;
718        self.last_strategy_path = None;
719        self.last_strategy_recommendation = None;
720    }
721
722    fn reset_classifier_stall_fields(&mut self) {
723        self.classifier_stall_count = 0;
724        self.last_gap_fingerprint = None;
725    }
726}
727
728/// Pure state machine for goal tracking.
729#[derive(Debug)]
730pub struct GoalTracker {
731    /// Current goal orchestration state, if a goal is active.
732    orchestration: Option<GoalOrchestration>,
733    /// Directory where progress snapshots are persisted.
734    session_dir: PathBuf,
735    /// Instant when the goal became active.
736    active_since: Option<Instant>,
737}
738
739impl GoalTracker {
740    /// Create a new tracker with no active goal.
741    fn new(session_dir: PathBuf) -> Self {
742        Self {
743            orchestration: None,
744            session_dir,
745            active_since: None,
746        }
747    }
748
749    /// Restore tracker state from a persisted snapshot.
750    pub fn from_snapshot(session_dir: PathBuf, mut snapshot: GoalOrchestration) -> Self {
751        match snapshot.phase {
752            GoalPhase::Planning | GoalPhase::Executing => {
753                snapshot.phase = GoalPhase::Idle;
754                if snapshot.status == GoalStatus::Active {
755                    snapshot.status = GoalStatus::UserPaused;
756                }
757                snapshot.current_subagent_id = None;
758                snapshot.current_subagent_role = None;
759            }
760            GoalPhase::Idle => {}
761        }
762        snapshot.planning_in_flight = false;
763        snapshot.verifying_in_flight = false;
764        let active_since = if snapshot.status == GoalStatus::Active {
765            Some(Instant::now())
766        } else {
767            None
768        };
769        Self {
770            orchestration: Some(snapshot),
771            session_dir,
772            active_since,
773        }
774    }
775
776    /// Return an immutable reference to the current orchestration snapshot.
777    fn snapshot(&self) -> Option<&GoalOrchestration> {
778        self.orchestration.as_ref()
779    }
780
781    /// Return a mutable reference to the current orchestration snapshot.
782    pub fn snapshot_mut(&mut self) -> Option<&mut GoalOrchestration> {
783        self.orchestration.as_mut()
784    }
785
786    /// `true` if a goal is currently active.
787    fn is_active(&self) -> bool {
788        self.orchestration.as_ref().is_some_and(|o| o.status == GoalStatus::Active)
789    }
790
791    /// Current execution phase, if a goal is active.
792    fn phase(&self) -> Option<GoalPhase> {
793        self.orchestration.as_ref().map(|o| o.phase)
794    }
795
796    /// Current lifecycle status, if a goal is active.
797    fn status(&self) -> Option<GoalStatus> {
798        self.orchestration.as_ref().map(|o| o.status)
799    }
800
801    /// ID of the currently executing subagent, if any.
802    fn current_subagent_id(&self) -> Option<&str> {
803        self.orchestration.as_ref().and_then(|o| o.current_subagent_id.as_deref())
804    }
805
806    /// Human-readable objective, if a goal is active.
807    fn objective(&self) -> Option<&str> {
808        self.orchestration.as_ref().map(|o| o.objective.as_str())
809    }
810
811    /// Token budget cap, if set.
812    fn token_budget(&self) -> Option<i64> {
813        self.orchestration.as_ref().and_then(|o| o.token_budget)
814    }
815
816    /// Create a new goal. Replaces any existing orchestration.
817    fn create_goal(
818        &mut self,
819        goal_id: String,
820        objective: String,
821        token_budget: Option<i64>,
822        token_baseline: i64,
823        created_at: String,
824        baseline_commit: Option<String>,
825    ) {
826        let _ = std::fs::create_dir_all(self.goal_dir());
827        if self.orchestration.is_some() {
828            self.remove_scratch_root();
829        }
830        self.orchestration = Some(GoalOrchestration {
831            goal_id,
832            objective,
833            status: GoalStatus::Active,
834            phase: GoalPhase::Executing,
835            token_budget,
836            elapsed_ms: 0,
837            created_at,
838            current_subagent_id: None,
839            current_subagent_role: None,
840            total_worker_rounds: 0,
841            total_verify_rounds: 0,
842            budget_limit_reported: false,
843            token_baseline,
844            tokens_used_high_water: 0,
845            parent_tokens_spent: 0,
846            last_session_tokens_seen: Some(token_baseline),
847            history: Vec::new(),
848            pause_message: None,
849            classifier_stall_count: 0,
850            classifier_runs_attempted: 0,
851            rounds_since_verify: 0,
852            consecutive_not_achieved: 0,
853            last_strategist_fired_at: 0,
854            strategist_cap_bonus: 0,
855            last_strategy_path: None,
856            last_strategy_recommendation: None,
857            changes_baseline_commit: baseline_commit,
858            last_gap_fingerprint: None,
859            live_subagent_tokens: 0,
860            live_tokens_by_model: Vec::new(),
861            live_context_window: 0,
862            live_context_pct: 0,
863            live_turn_count: 0,
864            live_tool_call_count: 0,
865            planning_in_flight: false,
866            verifying_in_flight: false,
867        });
868        self.active_since = Some(Instant::now());
869        self.record_event(GoalEvent::GoalCreated, None);
870    }
871
872    /// Update the execution phase of the active goal.
873    fn set_phase(&mut self, phase: GoalPhase) {
874        if let Some(o) = &mut self.orchestration {
875            o.phase = phase;
876        }
877    }
878
879    /// Update the current subagent ID and role.
880    fn set_current_subagent(&mut self, id: Option<String>, role: Option<String>) {
881        if let Some(o) = &mut self.orchestration {
882            o.current_subagent_id = id;
883            o.current_subagent_role = role;
884        }
885    }
886
887    /// Pause the goal with a specific reason. Only transitions from `Active`.
888    fn pause(&mut self, reason: GoalPauseReason) -> bool {
889        self.pause_inner(reason, None)
890    }
891
892    /// Like [`Self::pause`] but also stores a human-readable `message`.
893    fn pause_with_message(&mut self, reason: GoalPauseReason, message: String) -> bool {
894        self.pause_inner(reason, Some(message))
895    }
896
897    fn pause_inner(&mut self, reason: GoalPauseReason, message: Option<String>) -> bool {
898        let applied = if let Some(o) = &mut self.orchestration
899            && o.status == GoalStatus::Active
900        {
901            if let Some(since) = self.active_since.take() {
902                o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
903            }
904            o.status = reason.to_status();
905            if message.is_some() {
906                o.pause_message = message;
907            }
908            true
909        } else {
910            false
911        };
912        if applied {
913            self.record_event(GoalEvent::GoalPaused, Some(reason.history_detail().to_owned()));
914        }
915        applied
916    }
917
918    /// Resume a paused goal (any paused variant). Returns `true` if applied.
919    fn resume(&mut self) -> bool {
920        if let Some(o) = &mut self.orchestration
921            && o.status.is_paused()
922        {
923            o.status = GoalStatus::Active;
924            o.pause_message = None;
925            o.classifier_runs_attempted = 0;
926            o.rounds_since_verify = 0;
927            o.reset_strategist_fields();
928            o.reset_classifier_stall_fields();
929            self.active_since = Some(Instant::now());
930            self.record_event(GoalEvent::GoalResumed, None);
931            return true;
932        }
933        false
934    }
935
936    /// Mark the goal as complete. Accepts `Active` or any paused variant.
937    fn complete(&mut self) -> bool {
938        if let Some(o) = &mut self.orchestration
939            && (o.status == GoalStatus::Active || o.status.is_paused())
940        {
941            if let Some(since) = self.active_since.take() {
942                o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
943            }
944            o.status = GoalStatus::Complete;
945            o.phase = GoalPhase::Idle;
946            o.current_subagent_id = None;
947            o.current_subagent_role = None;
948            o.pause_message = None;
949            o.reset_strategist_fields();
950            self.record_event(GoalEvent::GoalCompleted, None);
951            return true;
952        }
953        false
954    }
955
956    /// Mark the goal as budget-limited. Accepts `Active` or any paused variant.
957    fn budget_limit(&mut self) -> bool {
958        if let Some(o) = &mut self.orchestration
959            && (o.status == GoalStatus::Active || o.status.is_paused())
960        {
961            if let Some(since) = self.active_since.take() {
962                o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
963            }
964            o.status = GoalStatus::BudgetLimited;
965            o.phase = GoalPhase::Idle;
966            o.current_subagent_id = None;
967            o.current_subagent_role = None;
968            o.pause_message = None;
969            o.reset_strategist_fields();
970            self.record_event(GoalEvent::BudgetExceeded, None);
971            return true;
972        }
973        false
974    }
975
976    /// Clear the goal entirely.
977    pub fn clear(&mut self) {
978        self.orchestration = None;
979        self.active_since = None;
980    }
981
982    fn goal_dir(&self) -> PathBuf {
983        self.session_dir.join("goal")
984    }
985
986    fn remove_scratch_root(&self) {
987        let _ = std::fs::remove_dir_all(self.session_dir.join("goal"));
988    }
989
990    /// Flush elapsed wall-clock time into `elapsed_ms`.
991    pub fn account_elapsed(&mut self) {
992        if let Some(o) = &mut self.orchestration
993            && let Some(since) = self.active_since
994        {
995            o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
996            self.active_since = Some(since);
997        }
998    }
999
1000    /// Record a `NotAchieved` rejection's gap `fingerprint` and report
1001    /// whether the goal has stalled.
1002    fn record_classifier_stall(&mut self, fingerprint: &str) -> bool {
1003        let Some(o) = self.orchestration.as_mut() else {
1004            return false;
1005        };
1006        if o.last_gap_fingerprint.as_deref() == Some(fingerprint) {
1007            o.classifier_stall_count = o.classifier_stall_count.saturating_add(1);
1008        } else {
1009            o.last_gap_fingerprint = Some(fingerprint.to_string());
1010            o.classifier_stall_count = 1;
1011        }
1012        o.classifier_stall_count >= 2
1013    }
1014
1015    /// Undo the most recent attempt-slot reservation.
1016    pub fn rollback_classifier_attempt(&mut self) {
1017        if let Some(o) = self.orchestration.as_mut() {
1018            o.classifier_runs_attempted = o.classifier_runs_attempted.saturating_sub(1);
1019        }
1020    }
1021
1022    /// Clear the stall streak.
1023    fn reset_classifier_stall(&mut self) {
1024        if let Some(o) = self.orchestration.as_mut() {
1025            o.reset_classifier_stall_fields();
1026        }
1027    }
1028
1029    /// Increment the consecutive-`NotAchieved` streak and return the new value.
1030    fn record_not_achieved_streak(&mut self) -> u32 {
1031        match self.orchestration.as_mut() {
1032            Some(o) => {
1033                o.consecutive_not_achieved = o.consecutive_not_achieved.saturating_add(1);
1034                o.consecutive_not_achieved
1035            }
1036            None => 0,
1037        }
1038    }
1039
1040    /// Atomically evaluate the strategist trigger and claim a fire.
1041    pub fn claim_strategist_fire(&mut self, should_fire: impl Fn(u32, u32) -> bool) -> Option<u32> {
1042        let o = self.orchestration.as_mut()?;
1043        if should_fire(o.consecutive_not_achieved, o.last_strategist_fired_at) {
1044            o.last_strategist_fired_at = o.consecutive_not_achieved;
1045            o.strategist_cap_bonus = 3;
1046            o.reset_classifier_stall_fields();
1047            Some(o.consecutive_not_achieved)
1048        } else {
1049            None
1050        }
1051    }
1052
1053    /// Revoke the cap bonus granted by [`Self::claim_strategist_fire`].
1054    pub fn revoke_strategist_cap_bonus(&mut self) {
1055        if let Some(o) = self.orchestration.as_mut() {
1056            o.strategist_cap_bonus = 0;
1057        }
1058    }
1059
1060    /// Reset ALL strategist state.
1061    fn reset_strategist_state(&mut self) {
1062        if let Some(o) = self.orchestration.as_mut() {
1063            o.reset_strategist_fields();
1064        }
1065    }
1066
1067    /// Persist the strategist's latest output path + short recommendation.
1068    fn record_strategy_recommendation(&mut self, path: String, recommendation: String) {
1069        if let Some(o) = self.orchestration.as_mut() {
1070            o.last_strategy_path = Some(path);
1071            o.last_strategy_recommendation = Some(recommendation);
1072        }
1073    }
1074
1075    /// Append a history entry to the active goal, if any.
1076    fn append_history(&mut self, entry: GoalHistoryEntry) {
1077        if let Some(o) = &mut self.orchestration {
1078            o.history.push(entry);
1079        }
1080    }
1081
1082    fn record_event(&mut self, event: GoalEvent, detail: Option<String>) {
1083        self.append_history(GoalHistoryEntry::now(event, detail));
1084    }
1085}
1086
1087#[cfg(test)]
1088mod goal_tracker_tests {
1089    use super::*;
1090
1091    fn make_tracker() -> GoalTracker {
1092        GoalTracker::new(PathBuf::from("/tmp/test-goal-session"))
1093    }
1094
1095    fn activate_tracker(t: &mut GoalTracker) {
1096        t.create_goal("goal-1".into(), "Build a widget".into(), Some(100_000), 0, "2026-01-01T00:00:00Z".into(), None);
1097    }
1098
1099    #[test]
1100    fn create_goal_activates_and_starts_timer() {
1101        let mut t = make_tracker();
1102        activate_tracker(&mut t);
1103
1104        assert!(t.is_active());
1105        assert_eq!(t.phase(), Some(GoalPhase::Executing));
1106        assert_eq!(t.status(), Some(GoalStatus::Active));
1107        assert_eq!(t.objective(), Some("Build a widget"));
1108        assert_eq!(t.token_budget(), Some(100_000));
1109        assert!(t.active_since.is_some());
1110    }
1111
1112    #[test]
1113    fn lifecycle_transitions_record_history_events() {
1114        let mut t = make_tracker();
1115        activate_tracker(&mut t);
1116        assert!(
1117            matches!(t.snapshot().unwrap().history.last().map(|e| &e.event), Some(GoalEvent::GoalCreated)),
1118            "create_goal must record GoalCreated"
1119        );
1120
1121        assert!(t.pause(GoalPauseReason::User));
1122        {
1123            let last = t.snapshot().unwrap().history.last().unwrap();
1124            assert!(matches!(last.event, GoalEvent::GoalPaused));
1125            assert_eq!(last.detail.as_deref(), Some("user"), "pause records its cause as the history detail");
1126        }
1127
1128        assert!(t.resume());
1129        assert!(matches!(t.snapshot().unwrap().history.last().map(|e| &e.event), Some(GoalEvent::GoalResumed)));
1130
1131        assert!(t.complete());
1132        let o = t.snapshot().unwrap();
1133        assert!(matches!(o.history.last().map(|e| &e.event), Some(GoalEvent::GoalCompleted)));
1134    }
1135
1136    #[test]
1137    fn pause_only_from_active() {
1138        let mut t = make_tracker();
1139        activate_tracker(&mut t);
1140
1141        assert!(t.pause(GoalPauseReason::User));
1142        assert_eq!(t.status(), Some(GoalStatus::UserPaused));
1143        assert!(!t.is_active());
1144
1145        assert!(t.resume());
1146        assert_eq!(t.status(), Some(GoalStatus::Active));
1147        assert!(t.is_active());
1148    }
1149
1150    #[test]
1151    fn pause_from_complete_is_noop() {
1152        let mut t = make_tracker();
1153        activate_tracker(&mut t);
1154        t.complete();
1155
1156        assert!(!t.pause(GoalPauseReason::User));
1157        assert_eq!(t.status(), Some(GoalStatus::Complete));
1158    }
1159
1160    #[test]
1161    fn resume_only_from_paused_variants() {
1162        let mut t = make_tracker();
1163        activate_tracker(&mut t);
1164
1165        assert!(!t.resume());
1166        assert_eq!(t.status(), Some(GoalStatus::Active));
1167
1168        t.budget_limit();
1169        assert!(!t.resume());
1170        assert_eq!(t.status(), Some(GoalStatus::BudgetLimited));
1171    }
1172
1173    #[test]
1174    fn complete_from_active_succeeds() {
1175        let mut t = make_tracker();
1176        activate_tracker(&mut t);
1177        t.set_current_subagent(Some("sub-1".into()), Some("worker".into()));
1178
1179        assert!(t.complete());
1180        assert_eq!(t.status(), Some(GoalStatus::Complete));
1181        assert!(t.current_subagent_id().is_none());
1182    }
1183
1184    #[test]
1185    fn complete_from_paused_succeeds() {
1186        let mut t = make_tracker();
1187        activate_tracker(&mut t);
1188        t.pause(GoalPauseReason::User);
1189
1190        assert!(t.complete());
1191        assert_eq!(t.status(), Some(GoalStatus::Complete));
1192    }
1193
1194    #[test]
1195    fn complete_from_blocked_succeeds() {
1196        let mut t = make_tracker();
1197        activate_tracker(&mut t);
1198        t.pause(GoalPauseReason::Verification);
1199        assert!(t.complete());
1200        assert_eq!(t.status(), Some(GoalStatus::Complete));
1201    }
1202
1203    #[test]
1204    fn budget_limit_from_active_succeeds() {
1205        let mut t = make_tracker();
1206        activate_tracker(&mut t);
1207        t.set_phase(GoalPhase::Executing);
1208
1209        assert!(t.budget_limit());
1210        assert_eq!(t.status(), Some(GoalStatus::BudgetLimited));
1211        assert_eq!(t.phase(), Some(GoalPhase::Idle));
1212    }
1213
1214    #[test]
1215    fn pause_reason_maps_to_correct_status() {
1216        let mut t = make_tracker();
1217        activate_tracker(&mut t);
1218
1219        assert!(t.pause(GoalPauseReason::User));
1220        assert_eq!(t.status(), Some(GoalStatus::UserPaused));
1221
1222        t.resume();
1223        assert!(t.pause(GoalPauseReason::BackOff));
1224        assert_eq!(t.status(), Some(GoalStatus::BackOffPaused));
1225
1226        t.resume();
1227        assert!(t.pause(GoalPauseReason::NoProgress));
1228        assert_eq!(t.status(), Some(GoalStatus::NoProgressPaused));
1229
1230        t.resume();
1231        assert!(t.pause_with_message(GoalPauseReason::Infra, "Turn failed: rate limit".into()));
1232        assert_eq!(t.status(), Some(GoalStatus::InfraPaused));
1233    }
1234
1235    #[test]
1236    fn is_paused_matches_all_paused_variants() {
1237        assert!(GoalStatus::UserPaused.is_paused());
1238        assert!(GoalStatus::BackOffPaused.is_paused());
1239        assert!(GoalStatus::NoProgressPaused.is_paused());
1240        assert!(GoalStatus::InfraPaused.is_paused());
1241        assert!(GoalStatus::Blocked.is_paused());
1242        assert!(!GoalStatus::Active.is_paused());
1243        assert!(!GoalStatus::Complete.is_paused());
1244        assert!(!GoalStatus::BudgetLimited.is_paused());
1245    }
1246
1247    #[test]
1248    fn no_progress_paused_round_trips_distinctly_from_back_off() {
1249        assert_eq!(GoalStatus::from_wire_str("no_progress_paused"), GoalStatus::NoProgressPaused);
1250        let json = serde_json::to_string(&GoalStatus::NoProgressPaused).unwrap();
1251        assert_eq!(json, "\"no_progress_paused\"");
1252        let back: GoalStatus = serde_json::from_str(&json).unwrap();
1253        assert_eq!(back, GoalStatus::NoProgressPaused);
1254        assert_eq!(GoalStatus::from_wire_str("back_off_paused"), GoalStatus::BackOffPaused);
1255        assert_ne!(GoalStatus::NoProgressPaused, GoalStatus::BackOffPaused);
1256    }
1257
1258    #[test]
1259    fn resume_from_user_paused() {
1260        let mut t = make_tracker();
1261        activate_tracker(&mut t);
1262        t.pause(GoalPauseReason::User);
1263        assert!(t.resume());
1264        assert_eq!(t.status(), Some(GoalStatus::Active));
1265    }
1266
1267    #[test]
1268    fn resume_from_infra_paused() {
1269        let mut t = make_tracker();
1270        activate_tracker(&mut t);
1271        t.pause_with_message(GoalPauseReason::Infra, "Turn failed: auth".into());
1272        assert_eq!(t.snapshot().and_then(|o| o.pause_message.clone()), Some("Turn failed: auth".into()));
1273        assert!(t.resume());
1274        assert_eq!(t.status(), Some(GoalStatus::Active));
1275        assert!(t.snapshot().unwrap().pause_message.is_none());
1276    }
1277
1278    #[test]
1279    fn pause_with_verification_reason_transitions_to_blocked() {
1280        let mut t = make_tracker();
1281        activate_tracker(&mut t);
1282
1283        assert!(t.pause(GoalPauseReason::Verification));
1284        assert_eq!(t.status(), Some(GoalStatus::Blocked));
1285        assert!(t.status().unwrap().is_paused());
1286    }
1287
1288    #[test]
1289    fn resume_from_blocked_transitions_to_active() {
1290        let mut t = make_tracker();
1291        activate_tracker(&mut t);
1292        t.pause(GoalPauseReason::Verification);
1293        assert!(t.resume());
1294        assert_eq!(t.status(), Some(GoalStatus::Active));
1295    }
1296
1297    #[test]
1298    fn unknown_future_paused_status_deserializes_to_user_paused() {
1299        let parsed: GoalStatus = serde_json::from_str(r#""error_paused""#).unwrap();
1300        assert_eq!(parsed, GoalStatus::UserPaused);
1301    }
1302
1303    #[test]
1304    fn unknown_non_paused_status_deserializes_to_user_paused_not_active() {
1305        for wire in [r#""quarantined""#, r#""v9_super_active""#, r#""""#] {
1306            let parsed: GoalStatus = serde_json::from_str(wire).unwrap();
1307            assert_eq!(parsed, GoalStatus::UserPaused, "wire {wire}");
1308        }
1309        assert_eq!(GoalStatus::from_wire_str("not-a-status"), GoalStatus::UserPaused,);
1310    }
1311
1312    #[test]
1313    fn legacy_pascal_case_paused_deserializes_to_user_paused() {
1314        let legacy = r#""Paused""#;
1315        let parsed: GoalStatus = serde_json::from_str(legacy).unwrap();
1316        assert_eq!(parsed, GoalStatus::UserPaused);
1317    }
1318
1319    #[test]
1320    fn legacy_pascal_case_other_variants_deserialize() {
1321        for (legacy, expected) in [
1322            (r#""Active""#, GoalStatus::Active),
1323            (r#""BudgetLimited""#, GoalStatus::BudgetLimited),
1324            (r#""Complete""#, GoalStatus::Complete),
1325        ] {
1326            let parsed: GoalStatus = serde_json::from_str(legacy).unwrap();
1327            assert_eq!(parsed, expected, "legacy {legacy} must parse");
1328        }
1329    }
1330
1331    #[test]
1332    fn legacy_infra_paused_deserializes() {
1333        let parsed: GoalStatus = serde_json::from_str(r#""infra_paused""#).unwrap();
1334        assert_eq!(parsed, GoalStatus::InfraPaused);
1335    }
1336
1337    #[test]
1338    fn goal_event_unknown_string_deserializes_to_unknown() {
1339        let unknown: GoalEvent = serde_json::from_str("\"some_future_event\"").unwrap();
1340        assert!(matches!(unknown, GoalEvent::Unknown));
1341        let known: GoalEvent = serde_json::from_str("\"goal_paused\"").unwrap();
1342        assert!(matches!(known, GoalEvent::GoalPaused));
1343    }
1344
1345    #[test]
1346    fn record_classifier_stall_trips_on_two_consecutive_identical_fingerprints() {
1347        let mut t = make_tracker();
1348        activate_tracker(&mut t);
1349        assert!(!t.record_classifier_stall("fp-a"), "first occurrence of a fingerprint is not a stall");
1350        assert!(t.record_classifier_stall("fp-a"), "the same fingerprint twice running trips the stall early-exit");
1351        assert_eq!(t.snapshot().unwrap().classifier_stall_count, 2);
1352    }
1353
1354    #[test]
1355    fn record_classifier_stall_resets_when_fingerprint_changes() {
1356        let mut t = make_tracker();
1357        activate_tracker(&mut t);
1358        assert!(!t.record_classifier_stall("fp-a"));
1359        assert!(t.record_classifier_stall("fp-a"));
1360        assert!(
1361            !t.record_classifier_stall("fp-b"),
1362            "a different fingerprint resets the streak to its first occurrence"
1363        );
1364        assert_eq!(t.snapshot().unwrap().classifier_stall_count, 1);
1365        assert!(t.record_classifier_stall("fp-b"), "the new fingerprint then trips on its own second occurrence");
1366    }
1367
1368    #[test]
1369    fn reset_classifier_stall_clears_streak_so_next_occurrence_is_first() {
1370        let mut t = make_tracker();
1371        activate_tracker(&mut t);
1372        assert!(!t.record_classifier_stall("fp-a"));
1373        assert!(t.record_classifier_stall("fp-a"));
1374        t.reset_classifier_stall();
1375        {
1376            let o = t.snapshot().unwrap();
1377            assert_eq!(o.classifier_stall_count, 0);
1378            assert!(o.last_gap_fingerprint.is_none());
1379        }
1380        assert!(!t.record_classifier_stall("fp-a"), "after reset, a repeat of the old fingerprint must not re-stall");
1381    }
1382
1383    #[test]
1384    fn full_lifecycle_create_to_complete() {
1385        let mut t = make_tracker();
1386        activate_tracker(&mut t);
1387
1388        t.set_phase(GoalPhase::Executing);
1389        assert_eq!(t.phase(), Some(GoalPhase::Executing));
1390
1391        t.set_current_subagent(Some("sub-1".into()), Some("worker".into()));
1392        assert_eq!(t.current_subagent_id(), Some("sub-1"));
1393
1394        assert!(t.complete());
1395        assert_eq!(t.status(), Some(GoalStatus::Complete));
1396        assert_eq!(t.phase(), Some(GoalPhase::Idle));
1397        assert!(t.current_subagent_id().is_none());
1398        assert!(t.active_since.is_none());
1399    }
1400
1401    #[test]
1402    fn serde_round_trip_preserves_data() {
1403        let mut t = make_tracker();
1404        activate_tracker(&mut t);
1405        t.set_phase(GoalPhase::Executing);
1406
1407        let original = t.snapshot().unwrap().clone();
1408        let json = serde_json::to_string(&original).unwrap();
1409        let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();
1410
1411        assert_eq!(restored.goal_id, original.goal_id);
1412        assert_eq!(restored.objective, original.objective);
1413        assert_eq!(restored.status, original.status);
1414        assert_eq!(restored.phase, original.phase);
1415    }
1416
1417    #[test]
1418    fn record_not_achieved_streak_increments_and_returns_new_count() {
1419        let mut t = make_tracker();
1420        activate_tracker(&mut t);
1421        assert_eq!(t.record_not_achieved_streak(), 1);
1422        assert_eq!(t.record_not_achieved_streak(), 2);
1423        assert_eq!(t.record_not_achieved_streak(), 3);
1424        assert_eq!(t.snapshot().unwrap().consecutive_not_achieved, 3);
1425    }
1426
1427    #[test]
1428    fn claim_strategist_fire_marks_current_streak() {
1429        let mut t = make_tracker();
1430        activate_tracker(&mut t);
1431        let _ = t.record_not_achieved_streak();
1432        let _ = t.record_not_achieved_streak();
1433        assert_eq!(t.claim_strategist_fire(|_, _| true), Some(2));
1434        let o = t.snapshot().unwrap();
1435        assert_eq!((o.consecutive_not_achieved, o.last_strategist_fired_at), (2, 2));
1436    }
1437
1438    #[test]
1439    fn claim_strategist_fire_skips_and_preserves_state_when_predicate_false() {
1440        let mut t = make_tracker();
1441        activate_tracker(&mut t);
1442        let _ = t.record_not_achieved_streak();
1443        assert_eq!(t.claim_strategist_fire(|_, _| false), None);
1444        let o = t.snapshot().unwrap();
1445        assert_eq!(o.last_strategist_fired_at, 0, "no fire => marker untouched");
1446        assert_eq!(o.strategist_cap_bonus, 0, "no fire => no cap bonus");
1447    }
1448
1449    #[test]
1450    fn strategist_fire_grants_cap_bonus_then_reset_clears_it() {
1451        let mut t = make_tracker();
1452        activate_tracker(&mut t);
1453        assert_eq!(t.snapshot().unwrap().strategist_cap_bonus, 0);
1454        let _ = t.record_not_achieved_streak();
1455        let _ = t.record_not_achieved_streak();
1456        let _ = t.claim_strategist_fire(|_, _| true);
1457        assert_eq!(t.snapshot().unwrap().strategist_cap_bonus, 3,);
1458        t.reset_strategist_state();
1459        assert_eq!(t.snapshot().unwrap().strategist_cap_bonus, 0);
1460    }
1461
1462    #[test]
1463    fn reset_strategist_state_clears_streak_marker_and_recommendation() {
1464        let mut t = make_tracker();
1465        activate_tracker(&mut t);
1466        let _ = t.record_not_achieved_streak();
1467        let _ = t.record_not_achieved_streak();
1468        let _ = t.claim_strategist_fire(|_, _| true);
1469        t.record_strategy_recommendation("/tmp/goal/strategy.md".into(), "split it".into());
1470
1471        t.reset_strategist_state();
1472
1473        let o = t.snapshot().unwrap();
1474        assert_eq!(o.consecutive_not_achieved, 0);
1475        assert_eq!(o.last_strategist_fired_at, 0);
1476        assert!(o.last_strategy_path.is_none());
1477        assert!(o.last_strategy_recommendation.is_none());
1478    }
1479}