summaryrefslogtreecommitdiff
path: root/makima/src/daemon
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/daemon')
-rw-r--r--makima/src/daemon/task/context_recovery.rs494
-rw-r--r--makima/src/daemon/task/mod.rs2
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;