//! Context recovery for task resumption. //! //! This module implements a standardized context recovery pattern that helps //! rebuild context when tasks resume or restart. This ensures Claude can quickly //! orient itself and reduces confusion and repeated work. //! //! The context recovery information includes: //! - Current branch name //! - Git status summary (uncommitted changes count) //! - Last checkpoint info (timestamp, message, SHA) //! - Recent progress log entries //! - Current contract phase use std::path::Path; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tokio::process::Command; use super::progress_log::{self, ProgressLogEntry}; use super::ManagedTask; /// Maximum number of recent progress entries to include in context recovery. const DEFAULT_RECENT_ENTRIES_LIMIT: usize = 5; /// Error type for context recovery operations. #[derive(Debug, thiserror::Error)] pub enum ContextRecoveryError { #[error("Git command failed: {0}")] GitCommand(String), #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("Failed to parse timestamp: {0}")] TimestampParse(String), } /// Information about the last git checkpoint. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CheckpointInfo { /// Timestamp of the checkpoint commit. pub timestamp: DateTime, /// Commit message. pub message: String, /// Short commit SHA (first 7 characters). pub sha: String, } /// Summary of a progress log entry for context recovery. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProgressSummary { /// Timestamp formatted as HH:MM. pub time: String, /// Task identifier. pub task_id: String, /// Brief description of what was done. pub description: String, /// Task status (done/failed). pub status: String, } impl From<&ProgressLogEntry> for ProgressSummary { fn from(entry: &ProgressLogEntry) -> Self { Self { time: entry.timestamp.format("%H:%M").to_string(), task_id: entry.task_id.clone(), description: entry.task_name.clone(), status: entry.status.as_str().to_string(), } } } /// Context recovery information for task resumption. /// /// This struct captures the essential context needed to resume a task, /// including git state, checkpoint info, and recent progress. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextRecoveryInfo { /// Current git branch name. pub current_branch: String, /// Number of uncommitted changes in the worktree. pub uncommitted_changes_count: usize, /// Information about the last checkpoint commit, if any. pub last_checkpoint: Option, /// Current contract phase (e.g., "research", "specify", "plan", "execute", "review"). pub current_phase: Option, /// Recent progress log entries. pub recent_progress: Vec, } impl ContextRecoveryInfo { /// Format the context recovery info as markdown for prompt injection. /// /// The output format is designed to be prepended to task prompts: /// ```markdown /// ## Context Recovery /// - Current branch: makima/feature-x /// - Git status: 3 uncommitted changes /// - Last checkpoint: 2026-01-24T12:00:00Z - "Add login form" (abc123) /// - Current phase: execute /// - Recent progress: /// - [12:45] Task xyz: Implemented auth module (done) /// - [12:30] Task abc: Added database schema (done) /// ``` pub fn to_markdown(&self) -> String { let mut md = String::new(); md.push_str("## Context Recovery\n"); // Current branch md.push_str(&format!("- Current branch: {}\n", self.current_branch)); // Git status if self.uncommitted_changes_count == 0 { md.push_str("- Git status: clean (no uncommitted changes)\n"); } else if self.uncommitted_changes_count == 1 { md.push_str("- Git status: 1 uncommitted change\n"); } else { md.push_str(&format!( "- Git status: {} uncommitted changes\n", self.uncommitted_changes_count )); } // Last checkpoint if let Some(ref checkpoint) = self.last_checkpoint { md.push_str(&format!( "- Last checkpoint: {} - \"{}\" ({})\n", checkpoint.timestamp.format("%Y-%m-%dT%H:%M:%SZ"), checkpoint.message, checkpoint.sha )); } else { md.push_str("- Last checkpoint: none\n"); } // Current phase if let Some(ref phase) = self.current_phase { md.push_str(&format!("- Current phase: {}\n", phase)); } // Recent progress md.push_str("- Recent progress:\n"); if self.recent_progress.is_empty() { md.push_str(" - (none)\n"); } else { for entry in &self.recent_progress { md.push_str(&format!( " - [{}] Task {}: {} ({})\n", entry.time, entry.task_id, entry.description, entry.status )); } } md } } /// Build context recovery information from worktree and task state. /// /// This function gathers all necessary context for task resumption: /// - Git branch and status from the worktree /// - Last checkpoint from git log /// - Recent progress from progress.log file /// - Contract phase from the task /// /// # Arguments /// * `worktree_path` - Path to the task's worktree directory /// * `task` - The managed task struct /// /// # Returns /// A `ContextRecoveryInfo` struct containing all recovery context. pub async fn build_context_recovery( worktree_path: &Path, task: &ManagedTask, ) -> Result { // Get current branch name let current_branch = get_current_branch(worktree_path).await?; // Get uncommitted changes count let uncommitted_changes_count = get_uncommitted_changes_count(worktree_path).await?; // Get last checkpoint info let last_checkpoint = get_last_checkpoint(worktree_path).await?; // Get recent progress entries let recent_progress = get_recent_progress(worktree_path)?; // Get current phase from task context // The phase is typically stored in the contract, but we can derive it from // the task's context or use a default let current_phase = get_task_phase(task); Ok(ContextRecoveryInfo { current_branch, uncommitted_changes_count, last_checkpoint, current_phase, recent_progress, }) } /// Get the current git branch name. async fn get_current_branch(worktree_path: &Path) -> Result { let output = Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(worktree_path) .output() .await?; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } else { let stderr = String::from_utf8_lossy(&output.stderr); Err(ContextRecoveryError::GitCommand(format!( "Failed to get current branch: {}", stderr ))) } } /// Get the count of uncommitted changes (staged + unstaged + untracked). async fn get_uncommitted_changes_count( worktree_path: &Path, ) -> Result { let output = Command::new("git") .args(["status", "--porcelain"]) .current_dir(worktree_path) .output() .await?; if output.status.success() { let status = String::from_utf8_lossy(&output.stdout); let count = status.lines().filter(|l| !l.is_empty()).count(); Ok(count) } else { // If git status fails, return 0 rather than erroring Ok(0) } } /// Get information about the last checkpoint commit. /// /// This looks for the most recent commit that appears to be a checkpoint. /// For now, it just returns the last commit, but could be enhanced to /// specifically look for checkpoint commit patterns. async fn get_last_checkpoint( worktree_path: &Path, ) -> Result, ContextRecoveryError> { // Get the last commit info in a format we can parse // Format: %H|%s|%aI (full sha|subject|ISO timestamp) let output = Command::new("git") .args(["log", "-1", "--format=%H|%s|%aI"]) .current_dir(worktree_path) .output() .await?; if !output.status.success() { // No commits yet or git error return Ok(None); } let log_output = String::from_utf8_lossy(&output.stdout); let log_line = log_output.trim(); if log_line.is_empty() { return Ok(None); } // Parse the log line let parts: Vec<&str> = log_line.splitn(3, '|').collect(); if parts.len() < 3 { return Ok(None); } let full_sha = parts[0]; let message = parts[1].to_string(); let timestamp_str = parts[2]; // Parse the ISO timestamp let timestamp = DateTime::parse_from_rfc3339(timestamp_str) .map_err(|e| ContextRecoveryError::TimestampParse(e.to_string()))? .with_timezone(&Utc); // Use short SHA (first 7 characters) let sha = full_sha.chars().take(7).collect(); Ok(Some(CheckpointInfo { timestamp, message, sha, })) } /// Get recent progress entries from the progress log. fn get_recent_progress(worktree_path: &Path) -> Result, ContextRecoveryError> { match progress_log::read_recent_entries(worktree_path, DEFAULT_RECENT_ENTRIES_LIMIT) { Ok(entries) => { // Convert to summaries and reverse to show most recent first let summaries: Vec = entries .iter() .rev() .map(ProgressSummary::from) .collect(); Ok(summaries) } Err(e) => { // Log the error but don't fail - just return empty progress tracing::warn!( path = %worktree_path.display(), error = %e, "Failed to read progress log, continuing without progress entries" ); Ok(Vec::new()) } } } /// Derive the current phase from task context. /// /// The contract phase is typically managed at the contract level, /// not the individual task level. For now, we return None and let /// the caller provide this information from the contract if available. fn get_task_phase(task: &ManagedTask) -> Option { // The phase is stored at the contract level, not the task level. // Supervisors handle phase management through the contract API. // For individual tasks, we don't have direct access to the phase. // This could be enhanced to: // 1. Cache the phase in the task when it's spawned // 2. Read from a local file written by the supervisor // 3. Query the API (though we want to avoid network calls here) // // For now, we infer based on task properties: if task.is_supervisor { // Supervisors coordinate phases, so we don't report a specific phase None } else if task.is_orchestrator { Some("execute".to_string()) } else { // Regular tasks are typically in the execute phase Some("execute".to_string()) } } #[cfg(test)] mod tests { use super::*; use std::time::Instant; fn create_test_task() -> ManagedTask { ManagedTask { id: uuid::Uuid::new_v4(), task_name: "Test task".to_string(), state: crate::daemon::task::state::TaskState::Initializing, worktree: None, plan: "Test plan".to_string(), repo_source: None, base_branch: None, target_branch: None, parent_task_id: None, depth: 0, is_orchestrator: false, is_supervisor: false, target_repo_path: None, completion_action: None, continue_from_task_id: None, copy_files: None, contract_id: None, concurrency_key: uuid::Uuid::new_v4(), autonomous_loop: false, created_at: Instant::now(), started_at: None, completed_at: None, error: None, } } #[test] fn test_context_recovery_to_markdown_full() { let info = ContextRecoveryInfo { current_branch: "makima/feature-auth".to_string(), uncommitted_changes_count: 3, last_checkpoint: Some(CheckpointInfo { timestamp: DateTime::parse_from_rfc3339("2026-01-24T12:00:00Z") .unwrap() .with_timezone(&Utc), message: "Add login form".to_string(), sha: "abc1234".to_string(), }), current_phase: Some("execute".to_string()), recent_progress: vec![ ProgressSummary { time: "12:45".to_string(), task_id: "xyz".to_string(), description: "Implemented auth module".to_string(), status: "done".to_string(), }, ProgressSummary { time: "12:30".to_string(), task_id: "abc".to_string(), description: "Added database schema".to_string(), status: "done".to_string(), }, ], }; let md = info.to_markdown(); assert!(md.contains("## Context Recovery")); assert!(md.contains("- Current branch: makima/feature-auth")); assert!(md.contains("- Git status: 3 uncommitted changes")); assert!(md.contains("- Last checkpoint: 2026-01-24T12:00:00Z - \"Add login form\" (abc1234)")); assert!(md.contains("- Current phase: execute")); assert!(md.contains("- Recent progress:")); assert!(md.contains(" - [12:45] Task xyz: Implemented auth module (done)")); assert!(md.contains(" - [12:30] Task abc: Added database schema (done)")); } #[test] fn test_context_recovery_to_markdown_minimal() { let info = ContextRecoveryInfo { current_branch: "main".to_string(), uncommitted_changes_count: 0, last_checkpoint: None, current_phase: None, recent_progress: Vec::new(), }; let md = info.to_markdown(); assert!(md.contains("## Context Recovery")); assert!(md.contains("- Current branch: main")); assert!(md.contains("- Git status: clean (no uncommitted changes)")); assert!(md.contains("- Last checkpoint: none")); assert!(!md.contains("- Current phase:")); assert!(md.contains(" - (none)")); } #[test] fn test_context_recovery_to_markdown_single_change() { let info = ContextRecoveryInfo { current_branch: "feature".to_string(), uncommitted_changes_count: 1, last_checkpoint: None, current_phase: None, recent_progress: Vec::new(), }; let md = info.to_markdown(); assert!(md.contains("- Git status: 1 uncommitted change")); } #[test] fn test_progress_summary_from_entry() { use super::super::progress_log::{ProgressLogEntry, TaskCompletionStatus}; let entry = ProgressLogEntry::new("task-123", "Test task name", TaskCompletionStatus::Done); let summary = ProgressSummary::from(&entry); assert_eq!(summary.task_id, "task-123"); assert_eq!(summary.description, "Test task name"); assert_eq!(summary.status, "done"); } #[test] fn test_get_task_phase_regular_task() { let task = create_test_task(); let phase = get_task_phase(&task); assert_eq!(phase, Some("execute".to_string())); } #[test] fn test_get_task_phase_orchestrator() { let mut task = create_test_task(); task.is_orchestrator = true; let phase = get_task_phase(&task); assert_eq!(phase, Some("execute".to_string())); } #[test] fn test_get_task_phase_supervisor() { let mut task = create_test_task(); task.is_supervisor = true; let phase = get_task_phase(&task); assert_eq!(phase, None); } }