diff options
| author | soryu <soryu@soryu.co> | 2026-01-24 12:58:01 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-24 12:58:01 +0000 |
| commit | 4eb2d89335fe5ec573443b91fed8614bebb23011 (patch) | |
| tree | 907676612a8ec3c59d2ce5b26aff82c65a1e06d6 | |
| parent | ba3906c05d8979236600385656dd454c1aa34352 (diff) | |
| download | soryu-4eb2d89335fe5ec573443b91fed8614bebb23011.tar.gz soryu-4eb2d89335fe5ec573443b91fed8614bebb23011.zip | |
feat: Add context recovery module for task resumption
Implements a standardized context recovery pattern that helps rebuild
context when tasks resume or restart, ensuring Claude can quickly
orient itself and reducing confusion and repeated work.
The ContextRecoveryInfo struct captures:
- Current git branch name
- Git status summary (uncommitted changes count)
- Last checkpoint info (timestamp, message, SHA)
- Recent progress log entries from progress.log
- Current contract phase
Includes to_markdown() method for generating prompt-injection-ready
context blocks.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | makima/src/daemon/task/context_recovery.rs | 494 | ||||
| -rw-r--r-- | makima/src/daemon/task/mod.rs | 2 |
2 files changed, 496 insertions, 0 deletions
diff --git a/makima/src/daemon/task/context_recovery.rs b/makima/src/daemon/task/context_recovery.rs new file mode 100644 index 0000000..1172684 --- /dev/null +++ b/makima/src/daemon/task/context_recovery.rs @@ -0,0 +1,494 @@ +//! 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::Pending, + 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); + } +} diff --git a/makima/src/daemon/task/mod.rs b/makima/src/daemon/task/mod.rs index d69b055..1507a67 100644 --- a/makima/src/daemon/task/mod.rs +++ b/makima/src/daemon/task/mod.rs @@ -1,11 +1,13 @@ //! Task management and execution. pub mod completion_gate; +pub mod context_recovery; pub mod manager; pub mod progress_log; pub mod state; pub use completion_gate::CompletionGate; +pub use context_recovery::{build_context_recovery, CheckpointInfo, ContextRecoveryInfo}; pub use manager::{ManagedTask, TaskConfig, TaskManager}; pub use progress_log::{Learning, ProgressLogEntry, TaskCompletionStatus}; pub use state::TaskState; |
