Skip to main content

vtcode_memory/progress/
mod.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// ============================================================================
261// Goal tracker state machine
262// ============================================================================
263
264use std::time::Instant;
265
266/// Phase of goal execution.
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "snake_case")]
269pub enum GoalPhase {
270    /// Goal is idle; no active planning or execution.
271    Idle,
272    /// Planning phase is in progress.
273    Planning,
274    /// Execution phase is in progress.
275    Executing,
276}
277
278/// Lifecycle status of a goal.
279///
280/// The paused variants encode the reason the goal was paused:
281/// - `UserPaused` for explicit pause requests
282/// - `BackOffPaused` when the classifier run cap is hit
283/// - `NoProgressPaused` when the verifier flags the same gaps with no progress
284/// - `InfraPaused` when a turn finishes with an infrastructure error
285/// - `Blocked` when the model determined the goal is not achievable
286///
287/// **Backwards-compat serde aliases:** older shells serialized this
288/// enum with the default PascalCase form (`"Active"`, `"Paused"`,
289/// `"BudgetLimited"`, `"Complete"`). The `#[serde(alias = ...)]`
290/// attributes preserve in-flight goal snapshots written by older shells.
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
292#[serde(rename_all = "snake_case")]
293pub enum GoalStatus {
294    /// Goal is actively running.
295    #[serde(alias = "Active")]
296    Active,
297    /// Explicitly paused by the user.
298    #[serde(alias = "Paused")]
299    UserPaused,
300    /// Paused due to repeated classifier failures (back-off cap hit).
301    BackOffPaused,
302    /// Paused because the verifier reported the same gaps with no progress.
303    NoProgressPaused,
304    /// Paused due to an infrastructure error in a turn.
305    InfraPaused,
306    /// Blocked; the model determined the goal is not achievable.
307    Blocked,
308    /// Hit the token budget limit.
309    #[serde(alias = "BudgetLimited")]
310    BudgetLimited,
311    /// Goal completed successfully.
312    #[serde(alias = "Complete")]
313    Complete,
314}
315
316impl<'de> Deserialize<'de> for GoalStatus {
317    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
318    where
319        D: serde::Deserializer<'de>,
320    {
321        let s = String::deserialize(deserializer)?;
322        Ok(Self::from_wire_str(&s))
323    }
324}
325
326impl GoalStatus {
327    /// Parse a persisted/wire status string. Unknown values map to
328    /// `UserPaused`: a status this shell cannot interpret must restore as
329    /// a resumable paused goal, never an Active self-driving one.
330    fn from_wire_str(s: &str) -> Self {
331        match s {
332            "active" | "Active" => Self::Active,
333            "user_paused" | "paused" | "Paused" => Self::UserPaused,
334            "doom_loop_paused" => Self::UserPaused,
335            "back_off_paused" => Self::BackOffPaused,
336            "no_progress_paused" => Self::NoProgressPaused,
337            "infra_paused" => Self::InfraPaused,
338            "blocked" => Self::Blocked,
339            "budget_limited" | "BudgetLimited" => Self::BudgetLimited,
340            "complete" | "Complete" => Self::Complete,
341            _ => Self::UserPaused,
342        }
343    }
344
345    /// `true` for any paused variant.
346    fn is_paused(&self) -> bool {
347        matches!(
348            self,
349            Self::UserPaused | Self::BackOffPaused | Self::NoProgressPaused | Self::InfraPaused | Self::Blocked
350        )
351    }
352}
353
354/// Reason for pausing a goal. Maps 1:1 to one of the paused variants on
355/// [`GoalStatus`].
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum GoalPauseReason {
358    /// Paused by explicit user request.
359    User,
360    /// Paused after repeated classifier failures hit the back-off cap.
361    BackOff,
362    /// Paused because the verifier reported no progress on known gaps.
363    NoProgress,
364    /// Paused because the verifier determined the goal is not achievable.
365    Verification,
366    /// Paused due to an infrastructure error.
367    Infra,
368}
369
370impl GoalPauseReason {
371    fn to_status(self) -> GoalStatus {
372        match self {
373            Self::User => GoalStatus::UserPaused,
374            Self::BackOff => GoalStatus::BackOffPaused,
375            Self::NoProgress => GoalStatus::NoProgressPaused,
376            Self::Verification => GoalStatus::Blocked,
377            Self::Infra => GoalStatus::InfraPaused,
378        }
379    }
380
381    fn history_detail(self) -> &'static str {
382        match self {
383            Self::User => "user",
384            Self::BackOff => "back_off",
385            Self::NoProgress => "no_progress",
386            Self::Verification => "blocked",
387            Self::Infra => "infra",
388        }
389    }
390}
391
392/// Aggregate verdict produced by the goal-verification stage.
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "snake_case")]
395pub enum GoalClassifierVerdict {
396    /// Goal was achieved.
397    Achieved,
398    /// Goal was not achieved.
399    NotAchieved,
400}
401
402/// Lifecycle event recorded in the goal history.
403#[derive(Debug, Clone, Serialize, Deserialize)]
404#[serde(rename_all = "snake_case")]
405pub enum GoalEvent {
406    /// Goal was created.
407    GoalCreated,
408    /// Planning phase started.
409    PlanningStarted,
410    /// Planning phase completed.
411    PlanningCompleted,
412    /// Planning phase failed.
413    PlanningFailed,
414    /// Worker started processing.
415    WorkerStarted,
416    /// Worker completed successfully.
417    WorkerCompleted,
418    /// Worker failed.
419    WorkerFailed,
420    /// Context was rotated.
421    ContextRotated,
422    /// Goal was paused.
423    GoalPaused,
424    /// Goal was resumed.
425    GoalResumed,
426    /// Goal completed successfully.
427    GoalCompleted,
428    /// Goal was cleared.
429    GoalCleared,
430    /// Budget was exceeded.
431    BudgetExceeded,
432    /// Premature stop was detected.
433    PrematureStopDetected,
434    /// Unknown or unrecognized event.
435    #[serde(other)]
436    Unknown,
437}
438
439/// A single history entry for a goal lifecycle event.
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct GoalHistoryEntry {
442    /// ISO-8601 timestamp of the event.
443    timestamp: String,
444    /// Lifecycle event type.
445    event: GoalEvent,
446    /// Optional human-readable detail string.
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    detail: Option<String>,
449    /// Optional round number associated with the event.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    round: Option<u32>,
452    /// Optional token count at the time of the event.
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    tokens_used: Option<i64>,
455    /// Unmet requirements or blockers recorded at this event.
456    #[serde(default, skip_serializing_if = "Vec::is_empty")]
457    unmet: Vec<String>,
458}
459
460impl GoalHistoryEntry {
461    fn now(event: GoalEvent, detail: Option<String>) -> Self {
462        Self {
463            timestamp: Utc::now().to_rfc3339(),
464            event,
465            detail,
466            round: None,
467            tokens_used: None,
468            unmet: Vec::new(),
469        }
470    }
471}
472
473/// Full persisted state for a goal orchestration.
474#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct GoalOrchestration {
476    /// Unique identifier for the goal.
477    goal_id: String,
478    /// Human-readable objective description.
479    objective: String,
480    /// Current lifecycle status.
481    status: GoalStatus,
482    /// Current execution phase.
483    phase: GoalPhase,
484    /// Optional token budget cap.
485    token_budget: Option<i64>,
486    /// Elapsed wall-clock time in milliseconds.
487    elapsed_ms: u64,
488    /// ISO-8601 creation timestamp.
489    created_at: String,
490    /// Currently executing subagent ID, if any.
491    current_subagent_id: Option<String>,
492    /// Role of the current subagent.
493    current_subagent_role: Option<String>,
494    /// Total worker rounds executed.
495    #[serde(default)]
496    total_worker_rounds: u32,
497    /// Total verification rounds executed.
498    #[serde(default)]
499    total_verify_rounds: u32,
500    /// Whether the budget limit notification has already been emitted.
501    #[serde(skip)]
502    budget_limit_reported: bool,
503    /// Baseline token count when the goal started.
504    #[serde(default)]
505    token_baseline: i64,
506    /// High-water mark for tokens used.
507    #[serde(default)]
508    tokens_used_high_water: i64,
509    /// Tokens spent by the parent session before this goal started.
510    #[serde(default)]
511    parent_tokens_spent: i64,
512    /// Last observed session token count.
513    #[serde(default)]
514    last_session_tokens_seen: Option<i64>,
515    /// Ordered history of lifecycle events.
516    history: Vec<GoalHistoryEntry>,
517    /// User-facing pause message, if any.
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pause_message: Option<String>,
520    /// Number of consecutive classifier stall detections.
521    #[serde(default)]
522    classifier_stall_count: u32,
523    /// Total classifier run attempts.
524    #[serde(default)]
525    classifier_runs_attempted: u32,
526    /// Rounds since the last verification pass.
527    #[serde(default)]
528    rounds_since_verify: u32,
529    /// Consecutive not-achieved verdicts.
530    #[serde(default)]
531    consecutive_not_achieved: u32,
532    /// Turn at which the strategist last fired.
533    #[serde(default)]
534    last_strategist_fired_at: u32,
535    /// Bonus tokens granted by the strategist.
536    #[serde(default)]
537    strategist_cap_bonus: u32,
538    /// Path of the last strategy recommendation, if any.
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    last_strategy_path: Option<String>,
541    /// Last strategy recommendation text, if any.
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    last_strategy_recommendation: Option<String>,
544    /// Commit hash at the last strategy change baseline, if any.
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    changes_baseline_commit: Option<String>,
547    /// Fingerprint of the last gap signature, if any.
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    last_gap_fingerprint: Option<String>,
550    /// Live token count for active subagents.
551    #[serde(skip)]
552    live_subagent_tokens: u64,
553    /// Live token usage broken down by model.
554    #[serde(skip)]
555    live_tokens_by_model: Vec<(String, u64)>,
556    /// Live context window size in tokens.
557    #[serde(skip)]
558    live_context_window: u64,
559    /// Live context window utilization percentage (0-100).
560    #[serde(skip)]
561    live_context_pct: u8,
562    /// Live turn count for the current goal.
563    #[serde(skip)]
564    live_turn_count: u32,
565    /// Live tool call count for the current goal.
566    #[serde(skip)]
567    live_tool_call_count: u32,
568    /// Whether a planning pass is currently in flight.
569    #[serde(skip)]
570    planning_in_flight: bool,
571    /// Whether a verification pass is currently in flight.
572    #[serde(skip)]
573    verifying_in_flight: bool,
574}
575
576impl GoalOrchestration {
577    fn reset_strategist_fields(&mut self) {
578        self.consecutive_not_achieved = 0;
579        self.last_strategist_fired_at = 0;
580        self.strategist_cap_bonus = 0;
581        self.last_strategy_path = None;
582        self.last_strategy_recommendation = None;
583    }
584
585    fn reset_classifier_stall_fields(&mut self) {
586        self.classifier_stall_count = 0;
587        self.last_gap_fingerprint = None;
588    }
589}
590
591/// Pure state machine for goal tracking.
592#[derive(Debug)]
593pub struct GoalTracker {
594    /// Current goal orchestration state, if a goal is active.
595    orchestration: Option<GoalOrchestration>,
596    /// Directory where progress snapshots are persisted.
597    session_dir: PathBuf,
598    /// Instant when the goal became active.
599    active_since: Option<Instant>,
600}
601
602impl GoalTracker {
603    /// Create a new tracker with no active goal.
604    fn new(session_dir: PathBuf) -> Self {
605        Self {
606            orchestration: None,
607            session_dir,
608            active_since: None,
609        }
610    }
611
612    /// Restore tracker state from a persisted snapshot.
613    pub fn from_snapshot(session_dir: PathBuf, mut snapshot: GoalOrchestration) -> Self {
614        match snapshot.phase {
615            GoalPhase::Planning | GoalPhase::Executing => {
616                snapshot.phase = GoalPhase::Idle;
617                if snapshot.status == GoalStatus::Active {
618                    snapshot.status = GoalStatus::UserPaused;
619                }
620                snapshot.current_subagent_id = None;
621                snapshot.current_subagent_role = None;
622            }
623            GoalPhase::Idle => {}
624        }
625        snapshot.planning_in_flight = false;
626        snapshot.verifying_in_flight = false;
627        let active_since = if snapshot.status == GoalStatus::Active {
628            Some(Instant::now())
629        } else {
630            None
631        };
632        Self {
633            orchestration: Some(snapshot),
634            session_dir,
635            active_since,
636        }
637    }
638
639    /// Return an immutable reference to the current orchestration snapshot.
640    fn snapshot(&self) -> Option<&GoalOrchestration> {
641        self.orchestration.as_ref()
642    }
643
644    /// Return a mutable reference to the current orchestration snapshot.
645    pub fn snapshot_mut(&mut self) -> Option<&mut GoalOrchestration> {
646        self.orchestration.as_mut()
647    }
648
649    /// `true` if a goal is currently active.
650    fn is_active(&self) -> bool {
651        self.orchestration.as_ref().is_some_and(|o| o.status == GoalStatus::Active)
652    }
653
654    /// Current execution phase, if a goal is active.
655    fn phase(&self) -> Option<GoalPhase> {
656        self.orchestration.as_ref().map(|o| o.phase)
657    }
658
659    /// Current lifecycle status, if a goal is active.
660    fn status(&self) -> Option<GoalStatus> {
661        self.orchestration.as_ref().map(|o| o.status)
662    }
663
664    /// ID of the currently executing subagent, if any.
665    fn current_subagent_id(&self) -> Option<&str> {
666        self.orchestration.as_ref().and_then(|o| o.current_subagent_id.as_deref())
667    }
668
669    /// Human-readable objective, if a goal is active.
670    fn objective(&self) -> Option<&str> {
671        self.orchestration.as_ref().map(|o| o.objective.as_str())
672    }
673
674    /// Token budget cap, if set.
675    fn token_budget(&self) -> Option<i64> {
676        self.orchestration.as_ref().and_then(|o| o.token_budget)
677    }
678
679    /// Create a new goal. Replaces any existing orchestration.
680    fn create_goal(
681        &mut self,
682        goal_id: String,
683        objective: String,
684        token_budget: Option<i64>,
685        token_baseline: i64,
686        created_at: String,
687        baseline_commit: Option<String>,
688    ) {
689        let _ = std::fs::create_dir_all(self.goal_dir());
690        if self.orchestration.is_some() {
691            self.remove_scratch_root();
692        }
693        self.orchestration = Some(GoalOrchestration {
694            goal_id,
695            objective,
696            status: GoalStatus::Active,
697            phase: GoalPhase::Executing,
698            token_budget,
699            elapsed_ms: 0,
700            created_at,
701            current_subagent_id: None,
702            current_subagent_role: None,
703            total_worker_rounds: 0,
704            total_verify_rounds: 0,
705            budget_limit_reported: false,
706            token_baseline,
707            tokens_used_high_water: 0,
708            parent_tokens_spent: 0,
709            last_session_tokens_seen: Some(token_baseline),
710            history: Vec::new(),
711            pause_message: None,
712            classifier_stall_count: 0,
713            classifier_runs_attempted: 0,
714            rounds_since_verify: 0,
715            consecutive_not_achieved: 0,
716            last_strategist_fired_at: 0,
717            strategist_cap_bonus: 0,
718            last_strategy_path: None,
719            last_strategy_recommendation: None,
720            changes_baseline_commit: baseline_commit,
721            last_gap_fingerprint: None,
722            live_subagent_tokens: 0,
723            live_tokens_by_model: Vec::new(),
724            live_context_window: 0,
725            live_context_pct: 0,
726            live_turn_count: 0,
727            live_tool_call_count: 0,
728            planning_in_flight: false,
729            verifying_in_flight: false,
730        });
731        self.active_since = Some(Instant::now());
732        self.record_event(GoalEvent::GoalCreated, None);
733    }
734
735    /// Update the execution phase of the active goal.
736    fn set_phase(&mut self, phase: GoalPhase) {
737        if let Some(o) = &mut self.orchestration {
738            o.phase = phase;
739        }
740    }
741
742    /// Update the current subagent ID and role.
743    fn set_current_subagent(&mut self, id: Option<String>, role: Option<String>) {
744        if let Some(o) = &mut self.orchestration {
745            o.current_subagent_id = id;
746            o.current_subagent_role = role;
747        }
748    }
749
750    /// Pause the goal with a specific reason. Only transitions from `Active`.
751    fn pause(&mut self, reason: GoalPauseReason) -> bool {
752        self.pause_inner(reason, None)
753    }
754
755    /// Like [`Self::pause`] but also stores a human-readable `message`.
756    fn pause_with_message(&mut self, reason: GoalPauseReason, message: String) -> bool {
757        self.pause_inner(reason, Some(message))
758    }
759
760    fn pause_inner(&mut self, reason: GoalPauseReason, message: Option<String>) -> bool {
761        let applied = if let Some(o) = &mut self.orchestration
762            && o.status == GoalStatus::Active
763        {
764            if let Some(since) = self.active_since.take() {
765                o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
766            }
767            o.status = reason.to_status();
768            if message.is_some() {
769                o.pause_message = message;
770            }
771            true
772        } else {
773            false
774        };
775        if applied {
776            self.record_event(GoalEvent::GoalPaused, Some(reason.history_detail().to_owned()));
777        }
778        applied
779    }
780
781    /// Resume a paused goal (any paused variant). Returns `true` if applied.
782    fn resume(&mut self) -> bool {
783        if let Some(o) = &mut self.orchestration
784            && o.status.is_paused()
785        {
786            o.status = GoalStatus::Active;
787            o.pause_message = None;
788            o.classifier_runs_attempted = 0;
789            o.rounds_since_verify = 0;
790            o.reset_strategist_fields();
791            o.reset_classifier_stall_fields();
792            self.active_since = Some(Instant::now());
793            self.record_event(GoalEvent::GoalResumed, None);
794            return true;
795        }
796        false
797    }
798
799    /// Mark the goal as complete. Accepts `Active` or any paused variant.
800    fn complete(&mut self) -> bool {
801        if let Some(o) = &mut self.orchestration
802            && (o.status == GoalStatus::Active || o.status.is_paused())
803        {
804            if let Some(since) = self.active_since.take() {
805                o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
806            }
807            o.status = GoalStatus::Complete;
808            o.phase = GoalPhase::Idle;
809            o.current_subagent_id = None;
810            o.current_subagent_role = None;
811            o.pause_message = None;
812            o.reset_strategist_fields();
813            self.record_event(GoalEvent::GoalCompleted, None);
814            return true;
815        }
816        false
817    }
818
819    /// Mark the goal as budget-limited. Accepts `Active` or any paused variant.
820    fn budget_limit(&mut self) -> bool {
821        if let Some(o) = &mut self.orchestration
822            && (o.status == GoalStatus::Active || o.status.is_paused())
823        {
824            if let Some(since) = self.active_since.take() {
825                o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
826            }
827            o.status = GoalStatus::BudgetLimited;
828            o.phase = GoalPhase::Idle;
829            o.current_subagent_id = None;
830            o.current_subagent_role = None;
831            o.pause_message = None;
832            o.reset_strategist_fields();
833            self.record_event(GoalEvent::BudgetExceeded, None);
834            return true;
835        }
836        false
837    }
838
839    /// Clear the goal entirely.
840    pub fn clear(&mut self) {
841        self.orchestration = None;
842        self.active_since = None;
843    }
844
845    fn goal_dir(&self) -> PathBuf {
846        self.session_dir.join("goal")
847    }
848
849    fn remove_scratch_root(&self) {
850        let _ = std::fs::remove_dir_all(self.session_dir.join("goal"));
851    }
852
853    /// Flush elapsed wall-clock time into `elapsed_ms`.
854    pub fn account_elapsed(&mut self) {
855        if let Some(o) = &mut self.orchestration
856            && let Some(since) = self.active_since
857        {
858            o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
859            self.active_since = Some(since);
860        }
861    }
862
863    /// Record a `NotAchieved` rejection's gap `fingerprint` and report
864    /// whether the goal has stalled.
865    fn record_classifier_stall(&mut self, fingerprint: &str) -> bool {
866        let Some(o) = self.orchestration.as_mut() else {
867            return false;
868        };
869        if o.last_gap_fingerprint.as_deref() == Some(fingerprint) {
870            o.classifier_stall_count = o.classifier_stall_count.saturating_add(1);
871        } else {
872            o.last_gap_fingerprint = Some(fingerprint.to_string());
873            o.classifier_stall_count = 1;
874        }
875        o.classifier_stall_count >= 2
876    }
877
878    /// Undo the most recent attempt-slot reservation.
879    pub fn rollback_classifier_attempt(&mut self) {
880        if let Some(o) = self.orchestration.as_mut() {
881            o.classifier_runs_attempted = o.classifier_runs_attempted.saturating_sub(1);
882        }
883    }
884
885    /// Clear the stall streak.
886    fn reset_classifier_stall(&mut self) {
887        if let Some(o) = self.orchestration.as_mut() {
888            o.reset_classifier_stall_fields();
889        }
890    }
891
892    /// Increment the consecutive-`NotAchieved` streak and return the new value.
893    fn record_not_achieved_streak(&mut self) -> u32 {
894        match self.orchestration.as_mut() {
895            Some(o) => {
896                o.consecutive_not_achieved = o.consecutive_not_achieved.saturating_add(1);
897                o.consecutive_not_achieved
898            }
899            None => 0,
900        }
901    }
902
903    /// Atomically evaluate the strategist trigger and claim a fire.
904    pub fn claim_strategist_fire(&mut self, should_fire: impl Fn(u32, u32) -> bool) -> Option<u32> {
905        let o = self.orchestration.as_mut()?;
906        if should_fire(o.consecutive_not_achieved, o.last_strategist_fired_at) {
907            o.last_strategist_fired_at = o.consecutive_not_achieved;
908            o.strategist_cap_bonus = 3;
909            o.reset_classifier_stall_fields();
910            Some(o.consecutive_not_achieved)
911        } else {
912            None
913        }
914    }
915
916    /// Revoke the cap bonus granted by [`Self::claim_strategist_fire`].
917    pub fn revoke_strategist_cap_bonus(&mut self) {
918        if let Some(o) = self.orchestration.as_mut() {
919            o.strategist_cap_bonus = 0;
920        }
921    }
922
923    /// Reset ALL strategist state.
924    fn reset_strategist_state(&mut self) {
925        if let Some(o) = self.orchestration.as_mut() {
926            o.reset_strategist_fields();
927        }
928    }
929
930    /// Persist the strategist's latest output path + short recommendation.
931    fn record_strategy_recommendation(&mut self, path: String, recommendation: String) {
932        if let Some(o) = self.orchestration.as_mut() {
933            o.last_strategy_path = Some(path);
934            o.last_strategy_recommendation = Some(recommendation);
935        }
936    }
937
938    /// Append a history entry to the active goal, if any.
939    fn append_history(&mut self, entry: GoalHistoryEntry) {
940        if let Some(o) = &mut self.orchestration {
941            o.history.push(entry);
942        }
943    }
944
945    fn record_event(&mut self, event: GoalEvent, detail: Option<String>) {
946        self.append_history(GoalHistoryEntry::now(event, detail));
947    }
948}
949
950#[cfg(test)]
951mod goal_tracker_tests;
952#[cfg(test)]
953mod progress_tests;