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
46            // Wait for process to exit (up to 5 seconds)
47            let max_wait_secs = 5;
48            let mut waited_secs = 0;
49            while waited_secs < max_wait_secs && pid::is_process_running(pid) {
50                std::thread::sleep(std::time::Duration::from_millis(100));
51                waited_secs += 1;
52                if waited_secs % 10 == 0 {
53                    println!(
54                        "  {} Waiting for process to exit... ({}/{}s)",
55                        "•".cyan(),
56                        waited_secs / 10,
57                        max_wait_secs
58                    );
59                }
60            }
61
62            // If process still running after 5s, send SIGKILL
63            if pid::is_process_running(pid) {
64                println!(
65                    "  {} Process did not exit gracefully, sending SIGKILL",
66                    "⚠".yellow()
67                );
68                #[cfg(unix)]
69                {
70                    use std::process::Command;
71                    let _ = Command::new("kill")
72                        .args(["-KILL", &pid.to_string()])
73                        .output();
74                }
75                // Wait a bit more for SIGKILL to take effect
76                std::thread::sleep(std::time::Duration::from_millis(500));
77            }
78
79            pid::remove_pid_file(&spec_id)?;
80            println!("  {} Process stopped", "✓".green());
81            true
82        } else {
83            println!("  {} Cleaning up stale PID file", "•".cyan());
84            pid::remove_pid_file(&spec_id)?;
85            false
86        }
87    } else {
88        if !force {
89            anyhow::bail!(
90                "Spec {} is not currently running. Use --force to analyze anyway.",
91                spec_id
92            );
93        }
94        false
95    };
96
97    // Read and analyze the log
98    let log_path = PathBuf::from(LOGS_DIR).join(format!("{}.log", spec_id));
99    let (log_tail, analysis) = if log_path.exists() {
100        let log_content = fs::read_to_string(&log_path)
101            .with_context(|| format!("Failed to read log file: {}", log_path.display()))?;
102
103        let tail = get_log_tail(&log_content, 50);
104        let analysis = analyze_log(&log_content);
105
106        (tail, analysis)
107    } else {
108        (
109            "No log file found".to_string(),
110            "No execution log available for analysis".to_string(),
111        )
112    };
113
114    // Generate suggestion based on spec status and analysis
115    let suggestion = generate_suggestion(&spec, &analysis);
116
117    // Check for worktree path
118    let config = Config::load().ok();
119    let project_name = config.as_ref().map(|c| c.project.name.as_str());
120    let worktree_path = worktree::get_active_worktree(&spec_id, project_name);
121    let worktree_exists = worktree_path.is_some();
122    let worktree_path_str = worktree_path
123        .as_ref()
124        .map(|p| p.to_string_lossy().to_string());
125
126    // Update spec status to paused if it was in_progress
127    if spec.frontmatter.status == SpecStatus::InProgress {
128        let _ = spec.set_status(SpecStatus::Paused);
129    }
130
131    // Append takeover analysis to spec body
132    let worktree_info = if let Some(ref path) = worktree_path_str {
133        format!(
134            "\n\n### Worktree Location\n\nWork should be done in the isolated worktree:\n```\ncd {}\n```\n",
135            path
136        )
137    } else {
138        "\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()
139    };
140
141    let takeover_section = format!(
142        "\n\n## Takeover Analysis\n\n{}\n\n### Recent Log Activity\n\n```\n{}\n```\n{}\n### Recommendation\n\n{}\n",
143        analysis,
144        log_tail,
145        worktree_info,
146        suggestion
147    );
148
149    spec.body.push_str(&takeover_section);
150    spec.save(&spec_path)?;
151
152    println!("{} Updated spec with takeover analysis", "✓".green());
153    if was_running {
154        println!("  {} Status set to: paused", "•".cyan());
155    }
156    println!("  {} Analysis appended to spec body", "•".cyan());
157    if worktree_exists {
158        println!(
159            "  {} Worktree at: {}",
160            "•".cyan(),
161            worktree_path_str.as_ref().unwrap()
162        );
163    } else {
164        println!("  {} Worktree no longer exists", "⚠".yellow());
165    }
166
167    Ok(TakeoverResult {
168        spec_id,
169        analysis,
170        log_tail,
171        suggestion,
172        worktree_path: worktree_path_str,
173    })
174}
175
176/// Get the last N lines from a log
177fn get_log_tail(log_content: &str, lines: usize) -> String {
178    log_content
179        .lines()
180        .rev()
181        .take(lines)
182        .collect::<Vec<_>>()
183        .into_iter()
184        .rev()
185        .collect::<Vec<_>>()
186        .join("\n")
187}
188
189/// Analyze log content to understand what went wrong
190fn analyze_log(log_content: &str) -> String {
191    let lines: Vec<&str> = log_content.lines().collect();
192
193    if lines.is_empty() {
194        return "Log is empty - no execution activity recorded.".to_string();
195    }
196
197    let mut analysis = Vec::new();
198
199    // Check for common error patterns
200    let error_indicators = ["error:", "failed:", "ERROR", "FAIL", "exception", "panic"];
201    let errors: Vec<&str> = lines
202        .iter()
203        .filter(|line| {
204            error_indicators
205                .iter()
206                .any(|indicator| line.to_lowercase().contains(indicator))
207        })
208        .copied()
209        .collect();
210
211    if !errors.is_empty() {
212        analysis.push(format!("Found {} error indicator(s) in log:", errors.len()));
213        for error in errors.iter().take(3) {
214            analysis.push(format!("  - {}", error.trim()));
215        }
216        if errors.len() > 3 {
217            analysis.push(format!("  ... and {} more", errors.len() - 3));
218        }
219    }
220
221    // Check for tool usage patterns
222    let tool_indicators = [
223        "<function_calls>",
224        "tool_name",
225        "Bash",
226        "Read",
227        "Edit",
228        "Write",
229    ];
230    let tool_uses: Vec<&str> = lines
231        .iter()
232        .filter(|line| {
233            tool_indicators
234                .iter()
235                .any(|indicator| line.contains(indicator))
236        })
237        .copied()
238        .collect();
239
240    if !tool_uses.is_empty() {
241        analysis.push(format!("\nAgent made {} tool call(s)", tool_uses.len()));
242    }
243
244    // Check for completion indicators
245    let completion_indicators = ["completed", "finished", "done", "success"];
246    let has_completion = lines.iter().any(|line| {
247        completion_indicators
248            .iter()
249            .any(|indicator| line.to_lowercase().contains(indicator))
250    });
251
252    if has_completion {
253        analysis.push("\nLog contains completion indicators.".to_string());
254    }
255
256    // Estimate progress
257    let total_lines = lines.len();
258    analysis.push(format!("\nLog contains {} lines of output.", total_lines));
259
260    if errors.is_empty() && !has_completion {
261        analysis.push("\nNo errors detected, but work appears incomplete.".to_string());
262    }
263
264    if analysis.is_empty() {
265        "Agent execution started but no significant activity detected.".to_string()
266    } else {
267        analysis.join("\n")
268    }
269}
270
271/// Generate a suggestion based on spec and analysis
272fn generate_suggestion(spec: &spec::Spec, analysis: &str) -> String {
273    let mut suggestions: Vec<String> = Vec::new();
274
275    // Check if there are errors
276    if analysis.to_lowercase().contains("error") {
277        suggestions
278            .push("Review the errors in the log and address them before resuming.".to_string());
279    }
280
281    // Check acceptance criteria
282    let unchecked = spec.count_unchecked_checkboxes();
283    if unchecked > 0 {
284        suggestions.push(format!(
285            "{} acceptance criteria remain unchecked.",
286            unchecked
287        ));
288    }
289
290    // General suggestions
291    suggestions.push("Continue working on this spec manually or adjust the approach.".to_string());
292    suggestions
293        .push("When ready to resume automated work, use `chant work <spec-id>`.".to_string());
294
295    suggestions.join("\n")
296}