//! 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);
}
}