use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use crate::error::SessionStoreError;
use crate::session_dir;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MilestoneStatus {
Pending,
InProgress,
Done,
Blocked,
}
impl MilestoneStatus {
#[must_use]
fn is_terminal(&self) -> bool {
matches!(self, MilestoneStatus::Done)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Milestone {
pub id: String,
pub description: String,
pub status: MilestoneStatus,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProgressLedger {
pub session_id: String,
pub goal: String,
milestones: Vec<Milestone>,
pub confidence: f32,
stalled_since: Option<String>,
updated_at: String,
#[serde(default)]
previous_session_id: Option<String>,
#[serde(default)]
handoff_summary: Option<String>,
#[serde(default)]
known_issues: Vec<String>,
#[serde(default)]
git_checkpoint: Option<String>,
}
impl ProgressLedger {
#[must_use]
pub fn new(session_id: &str, goal: &str) -> Self {
let ts = Utc::now().to_rfc3339();
Self {
session_id: session_id.to_string(),
goal: goal.to_string(),
milestones: Vec::new(),
confidence: 1.0,
stalled_since: None,
updated_at: ts,
previous_session_id: None,
handoff_summary: None,
known_issues: Vec::new(),
git_checkpoint: None,
}
}
#[must_use]
pub fn completion_ratio(&self) -> f32 {
if self.milestones.is_empty() {
return 1.0;
}
let done = self.milestones.iter().filter(|m| m.status.is_terminal()).count() as f32;
done / self.milestones.len() as f32
}
#[must_use]
pub fn is_complete(&self) -> bool {
self.completion_ratio() >= 1.0
}
#[must_use]
pub fn is_stalled(&self) -> bool {
self.stalled_since.is_some()
}
pub fn note_advance(&mut self) {
self.stalled_since = None;
self.confidence = (self.confidence + 0.05).min(1.0);
self.updated_at = Utc::now().to_rfc3339();
}
pub fn note_stall(&mut self) {
if self.stalled_since.is_none() {
self.stalled_since = Some(Utc::now().to_rfc3339());
}
self.confidence = (self.confidence - 0.1).max(0.0);
self.updated_at = Utc::now().to_rfc3339();
}
pub fn set_milestones(&mut self, milestones: Vec<Milestone>) {
self.milestones = milestones;
self.updated_at = Utc::now().to_rfc3339();
}
pub fn set_goal(&mut self, goal: &str) {
self.goal = goal.to_string();
self.updated_at = Utc::now().to_rfc3339();
}
fn set_handoff(&mut self, previous_session_id: &str, summary: &str, git_checkpoint: Option<String>) {
self.previous_session_id = Some(previous_session_id.to_string());
self.handoff_summary = Some(summary.to_string());
self.git_checkpoint = git_checkpoint;
self.updated_at = Utc::now().to_rfc3339();
}
fn add_known_issue(&mut self, issue: &str) {
self.known_issues.push(issue.to_string());
self.updated_at = Utc::now().to_rfc3339();
}
#[must_use]
pub fn to_markdown(&self) -> String {
let mut out = String::new();
out.push_str("# Session Progress\n\n");
let _ = writeln!(out, "**Goal:** {}", self.goal);
let _ = writeln!(out, "**Completion:** {:.0}%", (self.completion_ratio() * 100.0).round());
let _ = writeln!(out, "**Confidence:** {:.2}", self.confidence);
if let Some(since) = &self.stalled_since {
let _ = writeln!(out, "**Stalled since:** {since}");
}
let _ = writeln!(out, "**Updated:** {}\n", self.updated_at);
if let Some(prev) = &self.previous_session_id {
let _ = writeln!(out, "**Handed off from:** {prev}");
}
if let Some(summary) = &self.handoff_summary {
let _ = writeln!(out, "**Handoff summary:** {summary}");
}
if let Some(checkpoint) = &self.git_checkpoint {
let _ = writeln!(out, "**Git checkpoint:** `{checkpoint}`");
}
if !self.known_issues.is_empty() {
out.push_str("\n## Known Issues\n\n");
for issue in &self.known_issues {
let _ = writeln!(out, "- {issue}");
}
}
if self.milestones.is_empty() {
out.push_str("\n_No tracked milestones yet._\n");
} else {
out.push_str("\n## Milestones\n\n");
for m in &self.milestones {
let mark = match m.status {
MilestoneStatus::Done => "[x]",
MilestoneStatus::InProgress => "[~]",
MilestoneStatus::Blocked => "[!]",
MilestoneStatus::Pending => "[ ]",
};
let _ = writeln!(out, "{} {} — {}", mark, m.id, m.description);
}
}
out
}
}
#[must_use]
pub fn progress_path(workspace: &Path, session_id: &str) -> PathBuf {
session_dir(workspace, session_id)
.join(crate::DERIVED_DIR)
.join("progress.json")
}
pub fn load_progress(workspace: &Path, session_id: &str) -> Result<Option<ProgressLedger>, SessionStoreError> {
let path = progress_path(workspace, session_id);
if !path.exists() {
return Ok(None);
}
let bytes = std::fs::read(&path).map_err(|e| SessionStoreError::io(path.clone(), e))?;
let ledger: ProgressLedger = serde_json::from_slice(&bytes)?;
Ok(Some(ledger))
}
pub fn save_progress(workspace: &Path, session_id: &str, ledger: &ProgressLedger) -> Result<(), SessionStoreError> {
let path = progress_path(workspace, session_id);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| SessionStoreError::CreateDir { path: parent.to_path_buf(), source: e })?;
}
let bytes = serde_json::to_vec(ledger)?;
std::fs::write(&path, bytes).map_err(|e| SessionStoreError::io(path, e))?;
Ok(())
}
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalPhase {
Idle,
Planning,
Executing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalStatus {
#[serde(alias = "Active")]
Active,
#[serde(alias = "Paused")]
UserPaused,
BackOffPaused,
NoProgressPaused,
InfraPaused,
Blocked,
#[serde(alias = "BudgetLimited")]
BudgetLimited,
#[serde(alias = "Complete")]
Complete,
}
impl<'de> Deserialize<'de> for GoalStatus {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(Self::from_wire_str(&s))
}
}
impl GoalStatus {
fn from_wire_str(s: &str) -> Self {
match s {
"active" | "Active" => Self::Active,
"user_paused" | "paused" | "Paused" => Self::UserPaused,
"doom_loop_paused" => Self::UserPaused,
"back_off_paused" => Self::BackOffPaused,
"no_progress_paused" => Self::NoProgressPaused,
"infra_paused" => Self::InfraPaused,
"blocked" => Self::Blocked,
"budget_limited" | "BudgetLimited" => Self::BudgetLimited,
"complete" | "Complete" => Self::Complete,
_ => Self::UserPaused,
}
}
fn is_paused(&self) -> bool {
matches!(
self,
Self::UserPaused | Self::BackOffPaused | Self::NoProgressPaused | Self::InfraPaused | Self::Blocked
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GoalPauseReason {
User,
BackOff,
NoProgress,
Verification,
Infra,
}
impl GoalPauseReason {
fn to_status(self) -> GoalStatus {
match self {
Self::User => GoalStatus::UserPaused,
Self::BackOff => GoalStatus::BackOffPaused,
Self::NoProgress => GoalStatus::NoProgressPaused,
Self::Verification => GoalStatus::Blocked,
Self::Infra => GoalStatus::InfraPaused,
}
}
fn history_detail(self) -> &'static str {
match self {
Self::User => "user",
Self::BackOff => "back_off",
Self::NoProgress => "no_progress",
Self::Verification => "blocked",
Self::Infra => "infra",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalClassifierVerdict {
Achieved,
NotAchieved,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalEvent {
GoalCreated,
PlanningStarted,
PlanningCompleted,
PlanningFailed,
WorkerStarted,
WorkerCompleted,
WorkerFailed,
ContextRotated,
GoalPaused,
GoalResumed,
GoalCompleted,
GoalCleared,
BudgetExceeded,
PrematureStopDetected,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalHistoryEntry {
timestamp: String,
event: GoalEvent,
#[serde(default, skip_serializing_if = "Option::is_none")]
detail: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
round: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tokens_used: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
unmet: Vec<String>,
}
impl GoalHistoryEntry {
fn now(event: GoalEvent, detail: Option<String>) -> Self {
Self {
timestamp: Utc::now().to_rfc3339(),
event,
detail,
round: None,
tokens_used: None,
unmet: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalOrchestration {
goal_id: String,
objective: String,
status: GoalStatus,
phase: GoalPhase,
token_budget: Option<i64>,
elapsed_ms: u64,
created_at: String,
current_subagent_id: Option<String>,
current_subagent_role: Option<String>,
#[serde(default)]
total_worker_rounds: u32,
#[serde(default)]
total_verify_rounds: u32,
#[serde(skip)]
budget_limit_reported: bool,
#[serde(default)]
token_baseline: i64,
#[serde(default)]
tokens_used_high_water: i64,
#[serde(default)]
parent_tokens_spent: i64,
#[serde(default)]
last_session_tokens_seen: Option<i64>,
history: Vec<GoalHistoryEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pause_message: Option<String>,
#[serde(default)]
classifier_stall_count: u32,
#[serde(default)]
classifier_runs_attempted: u32,
#[serde(default)]
rounds_since_verify: u32,
#[serde(default)]
consecutive_not_achieved: u32,
#[serde(default)]
last_strategist_fired_at: u32,
#[serde(default)]
strategist_cap_bonus: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_strategy_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_strategy_recommendation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
changes_baseline_commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_gap_fingerprint: Option<String>,
#[serde(skip)]
live_subagent_tokens: u64,
#[serde(skip)]
live_tokens_by_model: Vec<(String, u64)>,
#[serde(skip)]
live_context_window: u64,
#[serde(skip)]
live_context_pct: u8,
#[serde(skip)]
live_turn_count: u32,
#[serde(skip)]
live_tool_call_count: u32,
#[serde(skip)]
planning_in_flight: bool,
#[serde(skip)]
verifying_in_flight: bool,
}
impl GoalOrchestration {
fn reset_strategist_fields(&mut self) {
self.consecutive_not_achieved = 0;
self.last_strategist_fired_at = 0;
self.strategist_cap_bonus = 0;
self.last_strategy_path = None;
self.last_strategy_recommendation = None;
}
fn reset_classifier_stall_fields(&mut self) {
self.classifier_stall_count = 0;
self.last_gap_fingerprint = None;
}
}
#[derive(Debug)]
pub struct GoalTracker {
orchestration: Option<GoalOrchestration>,
session_dir: PathBuf,
active_since: Option<Instant>,
}
impl GoalTracker {
fn new(session_dir: PathBuf) -> Self {
Self {
orchestration: None,
session_dir,
active_since: None,
}
}
pub fn from_snapshot(session_dir: PathBuf, mut snapshot: GoalOrchestration) -> Self {
match snapshot.phase {
GoalPhase::Planning | GoalPhase::Executing => {
snapshot.phase = GoalPhase::Idle;
if snapshot.status == GoalStatus::Active {
snapshot.status = GoalStatus::UserPaused;
}
snapshot.current_subagent_id = None;
snapshot.current_subagent_role = None;
}
GoalPhase::Idle => {}
}
snapshot.planning_in_flight = false;
snapshot.verifying_in_flight = false;
let active_since = if snapshot.status == GoalStatus::Active {
Some(Instant::now())
} else {
None
};
Self {
orchestration: Some(snapshot),
session_dir,
active_since,
}
}
fn snapshot(&self) -> Option<&GoalOrchestration> {
self.orchestration.as_ref()
}
pub fn snapshot_mut(&mut self) -> Option<&mut GoalOrchestration> {
self.orchestration.as_mut()
}
fn is_active(&self) -> bool {
self.orchestration.as_ref().is_some_and(|o| o.status == GoalStatus::Active)
}
fn phase(&self) -> Option<GoalPhase> {
self.orchestration.as_ref().map(|o| o.phase)
}
fn status(&self) -> Option<GoalStatus> {
self.orchestration.as_ref().map(|o| o.status)
}
fn current_subagent_id(&self) -> Option<&str> {
self.orchestration.as_ref().and_then(|o| o.current_subagent_id.as_deref())
}
fn objective(&self) -> Option<&str> {
self.orchestration.as_ref().map(|o| o.objective.as_str())
}
fn token_budget(&self) -> Option<i64> {
self.orchestration.as_ref().and_then(|o| o.token_budget)
}
fn create_goal(
&mut self,
goal_id: String,
objective: String,
token_budget: Option<i64>,
token_baseline: i64,
created_at: String,
baseline_commit: Option<String>,
) {
let _ = std::fs::create_dir_all(self.goal_dir());
if self.orchestration.is_some() {
self.remove_scratch_root();
}
self.orchestration = Some(GoalOrchestration {
goal_id,
objective,
status: GoalStatus::Active,
phase: GoalPhase::Executing,
token_budget,
elapsed_ms: 0,
created_at,
current_subagent_id: None,
current_subagent_role: None,
total_worker_rounds: 0,
total_verify_rounds: 0,
budget_limit_reported: false,
token_baseline,
tokens_used_high_water: 0,
parent_tokens_spent: 0,
last_session_tokens_seen: Some(token_baseline),
history: Vec::new(),
pause_message: None,
classifier_stall_count: 0,
classifier_runs_attempted: 0,
rounds_since_verify: 0,
consecutive_not_achieved: 0,
last_strategist_fired_at: 0,
strategist_cap_bonus: 0,
last_strategy_path: None,
last_strategy_recommendation: None,
changes_baseline_commit: baseline_commit,
last_gap_fingerprint: None,
live_subagent_tokens: 0,
live_tokens_by_model: Vec::new(),
live_context_window: 0,
live_context_pct: 0,
live_turn_count: 0,
live_tool_call_count: 0,
planning_in_flight: false,
verifying_in_flight: false,
});
self.active_since = Some(Instant::now());
self.record_event(GoalEvent::GoalCreated, None);
}
fn set_phase(&mut self, phase: GoalPhase) {
if let Some(o) = &mut self.orchestration {
o.phase = phase;
}
}
fn set_current_subagent(&mut self, id: Option<String>, role: Option<String>) {
if let Some(o) = &mut self.orchestration {
o.current_subagent_id = id;
o.current_subagent_role = role;
}
}
fn pause(&mut self, reason: GoalPauseReason) -> bool {
self.pause_inner(reason, None)
}
fn pause_with_message(&mut self, reason: GoalPauseReason, message: String) -> bool {
self.pause_inner(reason, Some(message))
}
fn pause_inner(&mut self, reason: GoalPauseReason, message: Option<String>) -> bool {
let applied = if let Some(o) = &mut self.orchestration
&& o.status == GoalStatus::Active
{
if let Some(since) = self.active_since.take() {
o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
}
o.status = reason.to_status();
if message.is_some() {
o.pause_message = message;
}
true
} else {
false
};
if applied {
self.record_event(GoalEvent::GoalPaused, Some(reason.history_detail().to_owned()));
}
applied
}
fn resume(&mut self) -> bool {
if let Some(o) = &mut self.orchestration
&& o.status.is_paused()
{
o.status = GoalStatus::Active;
o.pause_message = None;
o.classifier_runs_attempted = 0;
o.rounds_since_verify = 0;
o.reset_strategist_fields();
o.reset_classifier_stall_fields();
self.active_since = Some(Instant::now());
self.record_event(GoalEvent::GoalResumed, None);
return true;
}
false
}
fn complete(&mut self) -> bool {
if let Some(o) = &mut self.orchestration
&& (o.status == GoalStatus::Active || o.status.is_paused())
{
if let Some(since) = self.active_since.take() {
o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
}
o.status = GoalStatus::Complete;
o.phase = GoalPhase::Idle;
o.current_subagent_id = None;
o.current_subagent_role = None;
o.pause_message = None;
o.reset_strategist_fields();
self.record_event(GoalEvent::GoalCompleted, None);
return true;
}
false
}
fn budget_limit(&mut self) -> bool {
if let Some(o) = &mut self.orchestration
&& (o.status == GoalStatus::Active || o.status.is_paused())
{
if let Some(since) = self.active_since.take() {
o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
}
o.status = GoalStatus::BudgetLimited;
o.phase = GoalPhase::Idle;
o.current_subagent_id = None;
o.current_subagent_role = None;
o.pause_message = None;
o.reset_strategist_fields();
self.record_event(GoalEvent::BudgetExceeded, None);
return true;
}
false
}
pub fn clear(&mut self) {
self.orchestration = None;
self.active_since = None;
}
fn goal_dir(&self) -> PathBuf {
self.session_dir.join("goal")
}
fn remove_scratch_root(&self) {
let _ = std::fs::remove_dir_all(self.session_dir.join("goal"));
}
pub fn account_elapsed(&mut self) {
if let Some(o) = &mut self.orchestration
&& let Some(since) = self.active_since
{
o.elapsed_ms = o.elapsed_ms.saturating_add(since.elapsed().as_millis() as u64);
self.active_since = Some(since);
}
}
fn record_classifier_stall(&mut self, fingerprint: &str) -> bool {
let Some(o) = self.orchestration.as_mut() else {
return false;
};
if o.last_gap_fingerprint.as_deref() == Some(fingerprint) {
o.classifier_stall_count = o.classifier_stall_count.saturating_add(1);
} else {
o.last_gap_fingerprint = Some(fingerprint.to_string());
o.classifier_stall_count = 1;
}
o.classifier_stall_count >= 2
}
pub fn rollback_classifier_attempt(&mut self) {
if let Some(o) = self.orchestration.as_mut() {
o.classifier_runs_attempted = o.classifier_runs_attempted.saturating_sub(1);
}
}
fn reset_classifier_stall(&mut self) {
if let Some(o) = self.orchestration.as_mut() {
o.reset_classifier_stall_fields();
}
}
fn record_not_achieved_streak(&mut self) -> u32 {
match self.orchestration.as_mut() {
Some(o) => {
o.consecutive_not_achieved = o.consecutive_not_achieved.saturating_add(1);
o.consecutive_not_achieved
}
None => 0,
}
}
pub fn claim_strategist_fire(&mut self, should_fire: impl Fn(u32, u32) -> bool) -> Option<u32> {
let o = self.orchestration.as_mut()?;
if should_fire(o.consecutive_not_achieved, o.last_strategist_fired_at) {
o.last_strategist_fired_at = o.consecutive_not_achieved;
o.strategist_cap_bonus = 3;
o.reset_classifier_stall_fields();
Some(o.consecutive_not_achieved)
} else {
None
}
}
pub fn revoke_strategist_cap_bonus(&mut self) {
if let Some(o) = self.orchestration.as_mut() {
o.strategist_cap_bonus = 0;
}
}
fn reset_strategist_state(&mut self) {
if let Some(o) = self.orchestration.as_mut() {
o.reset_strategist_fields();
}
}
fn record_strategy_recommendation(&mut self, path: String, recommendation: String) {
if let Some(o) = self.orchestration.as_mut() {
o.last_strategy_path = Some(path);
o.last_strategy_recommendation = Some(recommendation);
}
}
fn append_history(&mut self, entry: GoalHistoryEntry) {
if let Some(o) = &mut self.orchestration {
o.history.push(entry);
}
}
fn record_event(&mut self, event: GoalEvent, detail: Option<String>) {
self.append_history(GoalHistoryEntry::now(event, detail));
}
}
#[cfg(test)]
mod goal_tracker_tests;
#[cfg(test)]
mod progress_tests;