summaryrefslogblamecommitdiff
path: root/makima/src/daemon/task/context_recovery.rs
blob: 1564431d6fa0a0cdde2452d40ccaf12238296452 (plain) (tree)





































































































































































































































































































































































                                                                                                    
                                                                       






































































































































                                                                                                       
//! 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<Utc>,
    /// 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<CheckpointInfo>,

    /// Current contract phase (e.g., "research", "specify", "plan", "execute", "review").
    pub current_phase: Option<String>,

    /// Recent progress log entries.
    pub recent_progress: Vec<ProgressSummary>,
}

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<ContextRecoveryInfo, ContextRecoveryError> {
    // 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<String, ContextRecoveryError> {
    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<usize, ContextRecoveryError> {
    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<Option<CheckpointInfo>, 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<Vec<ProgressSummary>, 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<ProgressSummary> = 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<String> {
    // 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);
    }
}