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