Skip to main content

chant/mcp/tools/
work.rs

1//! MCP tools for work execution and management
2
3use anyhow::{Context, Result};
4use serde_json::{json, Value};
5use std::path::PathBuf;
6use std::process::{Command, Stdio};
7
8use crate::paths::LOGS_DIR;
9use crate::spec::{load_all_specs, resolve_spec, SpecStatus};
10
11use super::super::handlers::{
12    check_for_running_work_processes, find_project_root, mcp_ensure_initialized,
13};
14use super::super::response::{mcp_error_response, mcp_text_response};
15
16pub fn tool_chant_work_start(arguments: Option<&Value>) -> Result<Value> {
17    let specs_dir = match mcp_ensure_initialized() {
18        Ok(dir) => dir,
19        Err(err_response) => return Ok(err_response),
20    };
21
22    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
23
24    let id = args
25        .get("id")
26        .and_then(|v| v.as_str())
27        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
28
29    let chain = args.get("chain").and_then(|v| v.as_bool()).unwrap_or(false);
30    let parallel = args.get("parallel").and_then(|v| v.as_u64());
31    let skip_criteria = args
32        .get("skip_criteria")
33        .and_then(|v| v.as_bool())
34        .unwrap_or(false);
35
36    // Guard: prevent concurrent single/chain execution when not in parallel mode
37    if parallel.is_none() {
38        match check_for_running_work_processes() {
39            Ok(Some((running_spec, pid))) => {
40                return Ok(mcp_error_response(format!(
41                    "Another work process is already running (spec: {}, PID: {}).\n\
42                                 Only one single or chain work process can run at a time.\n\
43                                 To run specs concurrently, use the parallel parameter:\n  \
44                                 chant_work_start(id=\"<spec>\", parallel=<N>)",
45                    running_spec, pid
46                )));
47            }
48            Ok(None) => {
49                // No running processes, proceed
50            }
51            Err(e) => {
52                // Log warning but don't block - fail open if we can't check
53                eprintln!("Warning: failed to check for running processes: {}", e);
54            }
55        }
56    }
57
58    // Resolve spec to get full ID
59    let spec = match resolve_spec(&specs_dir, id) {
60        Ok(s) => s,
61        Err(e) => {
62            return Ok(mcp_error_response(e.to_string()));
63        }
64    };
65
66    let spec_id = spec.id.clone();
67
68    // Gate: reject specs in invalid states
69    match spec.frontmatter.status {
70        SpecStatus::Paused => {
71            return Ok(mcp_error_response(format!(
72                "Spec '{}' is paused. Cannot start work on a paused spec.\n\
73                             Resume the spec first or use `chant reset {}` to reset it to pending.",
74                spec_id, spec_id
75            )));
76        }
77        SpecStatus::InProgress => {
78            return Ok(mcp_error_response(format!(
79                            "Spec '{}' is already in progress. Cannot start work on a spec that is already being worked on.\n\
80                             Use `chant takeover {}` to take over the running work.",
81                            spec_id, spec_id
82                        )));
83        }
84        SpecStatus::Completed => {
85            return Ok(mcp_error_response(format!(
86                "Spec '{}' is already completed. Cannot start work on a completed spec.",
87                spec_id
88            )));
89        }
90        SpecStatus::Cancelled => {
91            return Ok(mcp_error_response(format!(
92                "Spec '{}' is cancelled. Cannot start work on a cancelled spec.",
93                spec_id
94            )));
95        }
96        _ => {
97            // Valid states: Pending, Ready, Failed, NeedsAttention, Blocked
98        }
99    }
100
101    // Calculate spec quality for advisory feedback (not a gate)
102    let quality_warning = if !skip_criteria {
103        use crate::config::Config;
104        use crate::scoring::{calculate_spec_score, TrafficLight};
105
106        let config = match Config::load() {
107            Ok(c) => c,
108            Err(e) => {
109                return Ok(mcp_error_response(format!("Failed to load config: {}", e)));
110            }
111        };
112
113        let all_specs = match load_all_specs(&specs_dir) {
114            Ok(specs) => specs,
115            Err(e) => {
116                return Ok(mcp_error_response(format!("Failed to load specs: {}", e)));
117            }
118        };
119
120        let quality_score = calculate_spec_score(&spec, &all_specs, &config);
121
122        if quality_score.traffic_light == TrafficLight::Refine {
123            use crate::score::traffic_light;
124
125            let suggestions = traffic_light::generate_suggestions(&quality_score);
126            let mut warning_message =
127                "Quality advisory (Red/Refine) - work will start but spec may need improvement:\n\n"
128                    .to_string();
129
130            warning_message.push_str("Quality Assessment:\n");
131            warning_message.push_str(&format!("  Complexity:    {}\n", quality_score.complexity));
132            warning_message.push_str(&format!("  Confidence:    {}\n", quality_score.confidence));
133            warning_message.push_str(&format!(
134                "  Splittability: {}\n",
135                quality_score.splittability
136            ));
137            warning_message.push_str(&format!("  AC Quality:    {}\n", quality_score.ac_quality));
138            if let Some(iso) = quality_score.isolation {
139                warning_message.push_str(&format!("  Isolation:     {}\n", iso));
140            }
141
142            if !suggestions.is_empty() {
143                warning_message.push_str("\nSuggestions:\n");
144                for suggestion in &suggestions {
145                    warning_message.push_str(&format!("  • {}\n", suggestion));
146                }
147            }
148
149            Some(json!({
150                "status": "refine",
151                "scores": {
152                    "complexity": quality_score.complexity.to_string(),
153                    "confidence": quality_score.confidence.to_string(),
154                    "splittability": quality_score.splittability.to_string(),
155                    "ac_quality": quality_score.ac_quality.to_string(),
156                    "isolation": quality_score.isolation.map(|i| i.to_string())
157                },
158                "suggestions": suggestions,
159                "message": warning_message
160            }))
161        } else {
162            None
163        }
164    } else {
165        None
166    };
167
168    // Note: Do NOT transition to InProgress here. The spawned `chant work`
169    // handles that transition itself (single.rs line 179). Setting it here
170    // causes `chant work` to reject the spec with "already in progress",
171    // leaving it stuck as in_progress forever (the error goes to /dev/null).
172
173    // Build command based on mode
174    let mut cmd = Command::new("chant");
175    cmd.arg("work");
176
177    if skip_criteria {
178        cmd.arg("--skip-criteria");
179    }
180
181    let mode = if let Some(p) = parallel {
182        cmd.arg("--parallel").arg(p.to_string());
183        format!("parallel({})", p)
184    } else if chain {
185        cmd.arg("--chain").arg(&spec_id);
186        "chain".to_string()
187    } else {
188        cmd.arg(&spec_id);
189        "single".to_string()
190    };
191
192    // Spawn as background process
193    cmd.stdin(Stdio::null())
194        .stdout(Stdio::null())
195        .stderr(Stdio::null());
196
197    let mut child = cmd.spawn().context("Failed to spawn chant work process")?;
198
199    let pid = child.id();
200    let started_at = chrono::Local::now().to_rfc3339();
201    let process_id = format!("{}-{}", spec_id, pid);
202
203    // Spawn a thread to reap the process when it exits (prevents zombies)
204    std::thread::spawn(move || {
205        let _ = child.wait();
206    });
207
208    // Store process info
209    let project_root =
210        find_project_root().ok_or_else(|| anyhow::anyhow!("Project root not found"))?;
211    let processes_dir = project_root.join(".chant/processes");
212    std::fs::create_dir_all(&processes_dir)?;
213
214    let mut process_info = json!({
215        "process_id": process_id,
216        "spec_id": spec_id,
217        "pid": pid,
218        "started_at": started_at,
219        "mode": mode,
220        "started": true,
221        "monitor": {
222            "instruction": "Poll spec status every 60 seconds using chant_spec_get until status changes to completed or failed. Use chant_log with the since parameter to check progress.",
223            "poll_tool": "chant_spec_get",
224            "poll_interval_seconds": 60,
225            "done_statuses": ["completed", "failed"]
226        }
227    });
228
229    // Include quality warning if present
230    if let Some(warning) = quality_warning {
231        process_info["quality_warning"] = warning;
232    }
233
234    let process_file = processes_dir.join(format!("{}.json", process_id));
235    std::fs::write(&process_file, serde_json::to_string_pretty(&process_info)?)?;
236
237    Ok(mcp_text_response(serde_json::to_string_pretty(
238        &process_info,
239    )?))
240}
241
242pub fn tool_chant_work_list(arguments: Option<&Value>) -> Result<Value> {
243    let specs_dir = match mcp_ensure_initialized() {
244        Ok(dir) => dir,
245        Err(err_response) => return Ok(err_response),
246    };
247
248    let include_completed = arguments
249        .and_then(|a| a.get("include_completed"))
250        .and_then(|v| v.as_bool())
251        .unwrap_or(false);
252
253    // Use PID files to determine running processes (reliable source of truth)
254    let active_pids = crate::pid::list_active_pids()?;
255
256    // Load all specs to get metadata
257    let all_specs = load_all_specs(&specs_dir)?;
258    let spec_map: std::collections::HashMap<String, &crate::spec::Spec> =
259        all_specs.iter().map(|s| (s.id.clone(), s)).collect();
260
261    let mut processes: Vec<Value> = Vec::new();
262    let mut running = 0;
263    let mut stale_count = 0;
264
265    let logs_dir = PathBuf::from(LOGS_DIR);
266
267    // Report processes with active PIDs
268    for (spec_id, pid, is_running) in &active_pids {
269        if !is_running {
270            // Self-healing: clean up stale PID and process files
271            let _ = crate::pid::remove_pid_file(spec_id);
272            let _ = crate::pid::remove_process_files(spec_id);
273            stale_count += 1;
274            if !include_completed {
275                continue;
276            }
277        } else {
278            running += 1;
279        }
280
281        let spec = spec_map.get(spec_id);
282        let title = spec.and_then(|s| s.title.as_deref());
283        let branch = spec.and_then(|s| s.frontmatter.branch.as_deref());
284        let spec_status = spec.map(|s| &s.frontmatter.status);
285
286        let log_path = logs_dir.join(format!("{}.log", spec_id));
287        let log_mtime = if log_path.exists() {
288            std::fs::metadata(&log_path)
289                .and_then(|m| m.modified())
290                .ok()
291                .map(|t| {
292                    chrono::DateTime::<chrono::Local>::from(t)
293                        .format("%Y-%m-%d %H:%M:%S")
294                        .to_string()
295                })
296        } else {
297            None
298        };
299
300        let is_dead_worker = !is_running && matches!(spec_status, Some(SpecStatus::InProgress));
301
302        let mut entry = json!({
303            "spec_id": spec_id,
304            "title": title,
305            "pid": pid,
306            "status": if *is_running { "running" } else { "stale" },
307            "log_modified": log_mtime,
308            "branch": branch
309        });
310
311        if is_dead_worker {
312            entry["warning"] = json!("process_dead");
313        }
314
315        processes.push(entry);
316    }
317
318    let summary = json!({
319        "running": running,
320        "stale": stale_count
321    });
322
323    let response = json!({
324        "processes": processes,
325        "summary": summary
326    });
327
328    Ok(mcp_text_response(serde_json::to_string_pretty(&response)?))
329}
330
331pub fn tool_chant_pause(arguments: Option<&Value>) -> Result<Value> {
332    let specs_dir = match mcp_ensure_initialized() {
333        Ok(dir) => dir,
334        Err(err_response) => return Ok(err_response),
335    };
336
337    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
338
339    let id = args
340        .get("id")
341        .and_then(|v| v.as_str())
342        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
343
344    // Resolve spec to get full ID
345    let mut spec = match resolve_spec(&specs_dir, id) {
346        Ok(s) => s,
347        Err(e) => {
348            return Ok(mcp_error_response(e.to_string()));
349        }
350    };
351
352    let spec_id = spec.id.clone();
353    let spec_path = specs_dir.join(format!("{}.md", spec_id));
354
355    // Use operations layer (MCP always forces pause)
356    let options = crate::operations::PauseOptions { force: true };
357    crate::operations::pause_spec(&mut spec, &spec_path, options)?;
358
359    Ok(mcp_text_response(format!(
360        "Successfully paused work for spec '{}'",
361        spec_id
362    )))
363}
364
365pub fn tool_chant_takeover(arguments: Option<&Value>) -> Result<Value> {
366    let specs_dir = match mcp_ensure_initialized() {
367        Ok(dir) => dir,
368        Err(err_response) => return Ok(err_response),
369    };
370
371    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
372
373    let id = args
374        .get("id")
375        .and_then(|v| v.as_str())
376        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
377
378    let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
379
380    // Resolve spec to get full ID
381    let spec = match resolve_spec(&specs_dir, id) {
382        Ok(s) => s,
383        Err(e) => {
384            return Ok(mcp_error_response(e.to_string()));
385        }
386    };
387
388    // Execute takeover
389    match crate::takeover::cmd_takeover(&spec.id, force) {
390        Ok(result) => {
391            let response = json!({
392                "spec_id": result.spec_id,
393                "analysis": result.analysis,
394                "log_tail": result.log_tail,
395                "suggestion": result.suggestion,
396                "worktree_path": result.worktree_path
397            });
398
399            Ok(mcp_text_response(serde_json::to_string_pretty(&response)?))
400        }
401        Err(e) => Ok(mcp_error_response(format!(
402            "Failed to take over spec '{}': {}",
403            spec.id, e
404        ))),
405    }
406}
407
408pub fn tool_chant_split(arguments: Option<&Value>) -> Result<Value> {
409    let specs_dir = match mcp_ensure_initialized() {
410        Ok(dir) => dir,
411        Err(err_response) => return Ok(err_response),
412    };
413
414    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
415
416    let id = args
417        .get("id")
418        .and_then(|v| v.as_str())
419        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
420
421    let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
422    let recursive = args
423        .get("recursive")
424        .and_then(|v| v.as_bool())
425        .unwrap_or(false);
426    let max_depth = args.get("max_depth").and_then(|v| v.as_u64());
427
428    // Resolve spec to validate it exists
429    let spec = match resolve_spec(&specs_dir, id) {
430        Ok(s) => s,
431        Err(e) => {
432            return Ok(mcp_error_response(e.to_string()));
433        }
434    };
435
436    let spec_id = spec.id.clone();
437
438    // Check if spec is in valid state for splitting
439    match spec.frontmatter.status {
440        SpecStatus::Pending => {
441            // Valid for splitting
442        }
443        _ => {
444            return Ok(mcp_error_response(format!(
445                "Spec '{}' must be in pending status to split. Current status: {:?}",
446                spec_id, spec.frontmatter.status
447            )));
448        }
449    }
450
451    // Build command
452    let mut cmd = Command::new("chant");
453    cmd.arg("split");
454    cmd.arg(&spec_id);
455
456    if force {
457        cmd.arg("--force");
458    }
459    if recursive {
460        cmd.arg("--recursive");
461    }
462    if let Some(depth) = max_depth {
463        cmd.arg("--max-depth").arg(depth.to_string());
464    }
465
466    // Spawn as background process
467    cmd.stdin(Stdio::null())
468        .stdout(Stdio::null())
469        .stderr(Stdio::null());
470
471    let mut child = cmd.spawn().context("Failed to spawn chant split process")?;
472
473    let pid = child.id();
474    let started_at = chrono::Local::now().to_rfc3339();
475    let process_id = format!("split-{}-{}", spec_id, pid);
476
477    // Spawn a thread to reap the process when it exits (prevents zombies)
478    std::thread::spawn(move || {
479        let _ = child.wait();
480    });
481
482    // Store process info
483    let project_root =
484        find_project_root().ok_or_else(|| anyhow::anyhow!("Project root not found"))?;
485    let processes_dir = project_root.join(".chant/processes");
486    std::fs::create_dir_all(&processes_dir)?;
487
488    let process_info = json!({
489        "process_id": process_id,
490        "spec_id": spec_id,
491        "pid": pid,
492        "started_at": started_at,
493        "mode": "split",
494        "options": {
495            "force": force,
496            "recursive": recursive,
497            "max_depth": max_depth
498        }
499    });
500
501    let process_file = processes_dir.join(format!("{}.json", process_id));
502    std::fs::write(&process_file, serde_json::to_string_pretty(&process_info)?)?;
503
504    Ok(mcp_text_response(serde_json::to_string_pretty(
505        &process_info,
506    )?))
507}