Skip to main content

chant/
takeover.rs

1//! Takeover command for intervening in running work processes
2//!
3//! Allows users to stop an autonomous agent and continue with manual guidance.
4
5use anyhow::{Context, Result};
6use colored::Colorize;
7use std::fs;
8use std::path::PathBuf;
9
10use crate::config::Config;
11use crate::paths::LOGS_DIR;
12use crate::pid;
13use crate::spec::{self, SpecStatus};
14use crate::worktree;
15
16/// Result of a takeover operation
17pub struct TakeoverResult {
18    pub spec_id: String,
19    pub analysis: String,
20    pub log_tail: String,
21    pub suggestion: String,
22    pub worktree_path: Option<String>,
23}
24
25/// Takeover a spec that is currently being worked on
26pub fn cmd_takeover(id: &str, force: bool) -> Result<TakeoverResult> {
27    let specs_dir = PathBuf::from(crate::paths::SPECS_DIR);
28    if !specs_dir.exists() {
29        anyhow::bail!("Not a chant project (no .chant/ directory found)");
30    }
31
32    // Resolve the spec ID
33    let mut spec = spec::resolve_spec(&specs_dir, id)?;
34    let spec_id = spec.id.clone();
35    let spec_path = specs_dir.join(format!("{}.md", spec_id));
36
37    println!("{} Taking over spec {}", "→".cyan(), spec_id.cyan());
38
39    // Pause the work (stops process and sets status to paused)
40    let pid = pid::read_pid_file(&spec_id)?;
41    let was_running = if let Some(pid) = pid {
42        if pid::is_process_running(pid) {
43            println!("  {} Stopping running process (PID: {})", "•".cyan(), pid);
44            pid::stop_process(pid)?;
45            pid::remove_pid_file(&spec_id)?;
46            println!("  {} Process stopped", "✓".green());
47            true
48        } else {
49            println!("  {} Cleaning up stale PID file", "•".cyan());
50            pid::remove_pid_file(&spec_id)?;
51            false
52        }
53    } else {
54        if !force {
55            anyhow::bail!(
56                "Spec {} is not currently running. Use --force to analyze anyway.",
57                spec_id
58            );
59        }
60        false
61    };
62
63    // Read and analyze the log
64    let log_path = PathBuf::from(LOGS_DIR).join(format!("{}.log", spec_id));
65    let (log_tail, analysis) = if log_path.exists() {
66        let log_content = fs::read_to_string(&log_path)
67            .with_context(|| format!("Failed to read log file: {}", log_path.display()))?;
68
69        let tail = get_log_tail(&log_content, 50);
70        let analysis = analyze_log(&log_content);
71
72        (tail, analysis)
73    } else {
74        (
75            "No log file found".to_string(),
76            "No execution log available for analysis".to_string(),
77        )
78    };
79
80    // Generate suggestion based on spec status and analysis
81    let suggestion = generate_suggestion(&spec, &analysis);
82
83    // Check for worktree path
84    let config = Config::load().ok();
85    let project_name = config.as_ref().map(|c| c.project.name.as_str());
86    let worktree_path = worktree::get_active_worktree(&spec_id, project_name);
87    let worktree_exists = worktree_path.is_some();
88    let worktree_path_str = worktree_path
89        .as_ref()
90        .map(|p| p.to_string_lossy().to_string());
91
92    // Update spec status to paused if it was in_progress
93    if spec.frontmatter.status == SpecStatus::InProgress {
94        let _ = spec.set_status(SpecStatus::Paused);
95    }
96
97    // Append takeover analysis to spec body
98    let worktree_info = if let Some(ref path) = worktree_path_str {
99        format!(
100            "\n\n### Worktree Location\n\nWork should be done in the isolated worktree:\n```\ncd {}\n```\n",
101            path
102        )
103    } else {
104        "\n\n### Worktree Location\n\nWorktree no longer exists (agent may have cleaned up). If you need to continue working, recreate the worktree with `chant work <spec-id>`.\n".to_string()
105    };
106
107    let takeover_section = format!(
108        "\n\n## Takeover Analysis\n\n{}\n\n### Recent Log Activity\n\n```\n{}\n```\n{}\n### Recommendation\n\n{}\n",
109        analysis,
110        log_tail,
111        worktree_info,
112        suggestion
113    );
114
115    spec.body.push_str(&takeover_section);
116    spec.save(&spec_path)?;
117
118    println!("{} Updated spec with takeover analysis", "✓".green());
119    if was_running {
120        println!("  {} Status set to: paused", "•".cyan());
121    }
122    println!("  {} Analysis appended to spec body", "•".cyan());
123    if worktree_exists {
124        println!(
125            "  {} Worktree at: {}",
126            "•".cyan(),
127            worktree_path_str.as_ref().unwrap()
128        );
129    } else {
130        println!("  {} Worktree no longer exists", "⚠".yellow());
131    }
132
133    Ok(TakeoverResult {
134        spec_id,
135        analysis,
136        log_tail,
137        suggestion,
138        worktree_path: worktree_path_str,
139    })
140}
141
142/// Get the last N lines from a log
143fn get_log_tail(log_content: &str, lines: usize) -> String {
144    log_content
145        .lines()
146        .rev()
147        .take(lines)
148        .collect::<Vec<_>>()
149        .into_iter()
150        .rev()
151        .collect::<Vec<_>>()
152        .join("\n")
153}
154
155/// Analyze log content to understand what went wrong
156fn analyze_log(log_content: &str) -> String {
157    let lines: Vec<&str> = log_content.lines().collect();
158
159    if lines.is_empty() {
160        return "Log is empty - no execution activity recorded.".to_string();
161    }
162
163    let mut analysis = Vec::new();
164
165    // Check for common error patterns
166    let error_indicators = ["error:", "failed:", "ERROR", "FAIL", "exception", "panic"];
167    let errors: Vec<&str> = lines
168        .iter()
169        .filter(|line| {
170            error_indicators
171                .iter()
172                .any(|indicator| line.to_lowercase().contains(indicator))
173        })
174        .copied()
175        .collect();
176
177    if !errors.is_empty() {
178        analysis.push(format!("Found {} error indicator(s) in log:", errors.len()));
179        for error in errors.iter().take(3) {
180            analysis.push(format!("  - {}", error.trim()));
181        }
182        if errors.len() > 3 {
183            analysis.push(format!("  ... and {} more", errors.len() - 3));
184        }
185    }
186
187    // Check for tool usage patterns
188    let tool_indicators = [
189        "<function_calls>",
190        "tool_name",
191        "Bash",
192        "Read",
193        "Edit",
194        "Write",
195    ];
196    let tool_uses: Vec<&str> = lines
197        .iter()
198        .filter(|line| {
199            tool_indicators
200                .iter()
201                .any(|indicator| line.contains(indicator))
202        })
203        .copied()
204        .collect();
205
206    if !tool_uses.is_empty() {
207        analysis.push(format!("\nAgent made {} tool call(s)", tool_uses.len()));
208    }
209
210    // Check for completion indicators
211    let completion_indicators = ["completed", "finished", "done", "success"];
212    let has_completion = lines.iter().any(|line| {
213        completion_indicators
214            .iter()
215            .any(|indicator| line.to_lowercase().contains(indicator))
216    });
217
218    if has_completion {
219        analysis.push("\nLog contains completion indicators.".to_string());
220    }
221
222    // Estimate progress
223    let total_lines = lines.len();
224    analysis.push(format!("\nLog contains {} lines of output.", total_lines));
225
226    if errors.is_empty() && !has_completion {
227        analysis.push("\nNo errors detected, but work appears incomplete.".to_string());
228    }
229
230    if analysis.is_empty() {
231        "Agent execution started but no significant activity detected.".to_string()
232    } else {
233        analysis.join("\n")
234    }
235}
236
237/// Generate a suggestion based on spec and analysis
238fn generate_suggestion(spec: &spec::Spec, analysis: &str) -> String {
239    let mut suggestions: Vec<String> = Vec::new();
240
241    // Check if there are errors
242    if analysis.to_lowercase().contains("error") {
243        suggestions
244            .push("Review the errors in the log and address them before resuming.".to_string());
245    }
246
247    // Check acceptance criteria
248    let unchecked = spec.count_unchecked_checkboxes();
249    if unchecked > 0 {
250        suggestions.push(format!(
251            "{} acceptance criteria remain unchecked.",
252            unchecked
253        ));
254    }
255
256    // General suggestions
257    suggestions.push("Continue working on this spec manually or adjust the approach.".to_string());
258    suggestions
259        .push("When ready to resume automated work, use `chant work <spec-id>`.".to_string());
260
261    suggestions.join("\n")
262}