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