1use 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
16pub 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
25pub 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 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 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 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 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 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 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 let suggestion = generate_suggestion(&spec, &analysis);
119
120 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 if spec.frontmatter.status == SpecStatus::InProgress {
131 let _ = spec.set_status(SpecStatus::Paused);
132 }
133
134 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
179fn 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
192fn 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 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 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 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 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
274fn generate_suggestion(spec: &spec::Spec, analysis: &str) -> String {
276 let mut suggestions: Vec<String> = Vec::new();
277
278 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 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 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}