//! MCP merge tools — merge agent work to master and report merge failures. use crate::agents::move_story_to_merge; use crate::http::context::AppContext; use crate::slog; use crate::slog_warn; use serde_json::{Value, json}; pub(super) async fn tool_merge_agent_work( args: &Value, ctx: &AppContext, ) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) .ok_or("Missing required argument: story_id")?; // Check CRDT stage before attempting merge — if already done or archived, // return success immediately to avoid spurious error notifications. if let Some(item) = crate::crdt_state::read_item(story_id) && matches!( item.stage(), crate::pipeline_state::Stage::Done { .. } | crate::pipeline_state::Stage::Archived { .. } ) { let stage_name = item.stage().dir_name().to_string(); return serde_json::to_string_pretty(&json!({ "story_id": story_id, "status": "completed", "success": true, "message": format!( "Story '{story_id}' is already in '{stage_name}' — no merge needed.", ), })) .map_err(|e| format!("Serialization error: {e}")); } let project_root = ctx.services.agents.get_project_root(&ctx.state)?; ctx.services .agents .start_merge_agent_work(&project_root, story_id)?; // Block until the merge completes instead of returning immediately. // Uses tokio::time::sleep so the async executor is not blocked. // This prevents the mergemaster from burning all its turns polling // get_merge_status in a tight loop. let sid = story_id.to_string(); let agents = ctx.services.agents.clone(); loop { tokio::time::sleep(std::time::Duration::from_secs(10)).await; if let Some(job) = agents.get_merge_status(&sid) { match &job.status { crate::agents::merge::MergeJobStatus::Running => continue, _ => break, } } else { return Err(format!("Merge job disappeared for '{sid}'.")); } } // Return the full result (same fields as get_merge_status) so the caller // has everything it needs without a second round-trip. tool_get_merge_status(args, ctx) } pub(super) fn tool_get_merge_status(args: &Value, ctx: &AppContext) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) .ok_or("Missing required argument: story_id")?; let job = ctx .services .agents .get_merge_status(story_id) .ok_or_else(|| { format!("No merge job found for story '{story_id}'. Call merge_agent_work first.") })?; match &job.status { crate::agents::merge::MergeJobStatus::Running => { serde_json::to_string_pretty(&json!({ "story_id": story_id, "status": "running", "message": "Merge pipeline is still running." })) .map_err(|e| format!("Serialization error: {e}")) } crate::agents::merge::MergeJobStatus::Completed(report) => { use crate::agents::merge::MergeResult; let status_msg = crate::service::merge::format_merge_status_message(report); let (success, had_conflicts, conflicts_resolved, conflict_details, gates_passed, gate_output) = match &report.result { MergeResult::Success { conflicts_resolved, conflict_details, gate_output } => { (true, *conflicts_resolved, *conflicts_resolved, conflict_details.clone(), true, gate_output.clone()) } MergeResult::Conflict { details, output } => { (false, true, false, details.clone(), false, output.clone()) } MergeResult::GateFailure { output, .. } => { (false, false, false, None, false, output.clone()) } MergeResult::NoCommits { output } => { (false, false, false, None, false, output.clone()) } MergeResult::Other { output, conflict_details } => { (false, false, false, conflict_details.clone(), false, output.clone()) } }; serde_json::to_string_pretty(&json!({ "story_id": story_id, "status": "completed", "success": success, "had_conflicts": had_conflicts, "conflicts_resolved": conflicts_resolved, "conflict_details": conflict_details, "gates_passed": gates_passed, "gate_output": gate_output, "worktree_cleaned_up": report.worktree_cleaned_up, "story_archived": report.story_archived, "message": status_msg, })) .map_err(|e| format!("Serialization error: {e}")) } crate::agents::merge::MergeJobStatus::Failed(err) => { serde_json::to_string_pretty(&json!({ "story_id": story_id, "status": "failed", "error": err, "message": format!("Merge pipeline failed: {err}. Call report_merge_failure to record the failure.") })) .map_err(|e| format!("Serialization error: {e}")) } } } pub(super) async fn tool_move_story_to_merge( args: &Value, ctx: &AppContext, ) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) .ok_or("Missing required argument: story_id")?; let agent_name = args .get("agent_name") .and_then(|v| v.as_str()) .unwrap_or("mergemaster"); let project_root = ctx.services.agents.get_project_root(&ctx.state)?; // Move story from work/2_current/ to work/4_merge/ move_story_to_merge(story_id)?; // Start the mergemaster agent on the story worktree let info = ctx .services .agents .start_agent(&project_root, story_id, Some(agent_name), None, None) .await?; serde_json::to_string_pretty(&json!({ "story_id": info.story_id, "agent_name": info.agent_name, "status": info.status.to_string(), "worktree_path": info.worktree_path, "message": format!( "Story '{story_id}' moved to work/4_merge/ and mergemaster agent '{}' started.", info.agent_name ), })) .map_err(|e| format!("Serialization error: {e}")) } pub(super) fn tool_report_merge_failure(args: &Value, ctx: &AppContext) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) .ok_or("Missing required argument: story_id")?; let reason = args .get("reason") .and_then(|v| v.as_str()) .ok_or("Missing required argument: reason")?; slog!("[mergemaster] Merge failure reported for '{story_id}': {reason}"); ctx.services.agents.set_merge_failure_reported(story_id); // The mergemaster provides a freeform reason string; use Other so the // auto-assigner does not re-spawn another mergemaster after this one fails. let kind = crate::pipeline_state::MergeFailureKind::Other(reason.to_string()); let display = kind.display_reason(); // Route the failure through the typed state machine (Merge → MergeFailure). // Only broadcast the notification when the stage actually changed; if the // story was already in MergeFailure (self-loop), suppress the duplicate. let should_notify = match crate::agents::lifecycle::transition_to_merge_failure(story_id, kind) { Ok(fired) => !matches!( fired.before, crate::pipeline_state::Stage::MergeFailure { .. } ), Err(e) => { slog_warn!("[mergemaster] Failed to transition '{story_id}' to MergeFailure: {e}"); true } }; if should_notify { let _ = ctx .watcher_tx .send(crate::io::watcher::WatcherEvent::MergeFailure { story_id: story_id.to_string(), reason: display, }); } Ok(format!( "Merge failure for '{story_id}' recorded. Reason: {reason}" )) } #[cfg(test)] mod tests { use super::*; use crate::http::test_helpers::test_ctx; fn setup_git_repo_in(dir: &std::path::Path) { std::process::Command::new("git") .args(["init"]) .current_dir(dir) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(dir) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(dir) .output() .unwrap(); std::process::Command::new("git") .args(["commit", "--allow-empty", "-m", "init"]) .current_dir(dir) .output() .unwrap(); } #[test] fn merge_agent_work_in_tools_list() { use super::super::handle_tools_list; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let tool = tools.iter().find(|t| t["name"] == "merge_agent_work"); assert!(tool.is_some(), "merge_agent_work missing from tools list"); let t = tool.unwrap(); assert!(t["description"].is_string()); let required = t["inputSchema"]["required"].as_array().unwrap(); let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); assert!(req_names.contains(&"story_id")); // agent_name is optional assert!(!req_names.contains(&"agent_name")); } #[test] fn move_story_to_merge_in_tools_list() { use super::super::handle_tools_list; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let tool = tools.iter().find(|t| t["name"] == "move_story_to_merge"); assert!( tool.is_some(), "move_story_to_merge missing from tools list" ); let t = tool.unwrap(); assert!(t["description"].is_string()); let required = t["inputSchema"]["required"].as_array().unwrap(); let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); assert!(req_names.contains(&"story_id")); // agent_name is optional assert!(!req_names.contains(&"agent_name")); } #[tokio::test] async fn tool_merge_agent_work_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_merge_agent_work(&json!({}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[tokio::test] async fn tool_merge_agent_work_already_done_returns_success() { crate::crdt_state::init_for_test(); crate::crdt_state::write_item_str( "99_story_already_done", "5_done", Some("Already done story"), None, None, None, None, None, None, ); let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_merge_agent_work(&json!({"story_id": "99_story_already_done"}), &ctx).await; assert!(result.is_ok(), "expected Ok, got: {result:?}"); let body = result.unwrap(); let v: serde_json::Value = serde_json::from_str(&body).unwrap(); assert_eq!(v["status"], "completed"); assert_eq!(v["success"], true); assert!(v["message"].as_str().unwrap().contains("done")); } #[tokio::test] async fn tool_merge_agent_work_already_archived_returns_success() { crate::crdt_state::init_for_test(); crate::crdt_state::write_item_str( "98_story_already_archived", "6_archived", Some("Already archived story"), None, None, None, None, None, None, ); let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_merge_agent_work(&json!({"story_id": "98_story_already_archived"}), &ctx).await; assert!(result.is_ok(), "expected Ok, got: {result:?}"); let body = result.unwrap(); let v: serde_json::Value = serde_json::from_str(&body).unwrap(); assert_eq!(v["status"], "completed"); assert_eq!(v["success"], true); assert!(v["message"].as_str().unwrap().contains("archived")); } #[tokio::test] async fn tool_move_story_to_merge_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_move_story_to_merge(&json!({}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[tokio::test] async fn tool_move_story_to_merge_moves_file() { let tmp = tempfile::tempdir().unwrap(); setup_git_repo_in(tmp.path()); let current_dir = tmp.path().join(".huskies/work/2_current"); std::fs::create_dir_all(¤t_dir).unwrap(); let content = "---\nname: Test\n---\n"; let story_file = current_dir.join("24_story_test.md"); std::fs::write(&story_file, content).unwrap(); crate::db::ensure_content_store(); crate::db::write_content(crate::db::ContentKey::Story("24_story_test"), content); std::process::Command::new("git") .args(["add", "."]) .current_dir(tmp.path()) .output() .unwrap(); std::process::Command::new("git") .args(["commit", "-m", "add story"]) .current_dir(tmp.path()) .output() .unwrap(); let ctx = test_ctx(tmp.path()); // The agent start will fail in test (no worktree/config), but the move should succeed let result = tool_move_story_to_merge(&json!({"story_id": "24_story_test"}), &ctx).await; // Content store should still have the item after the move assert!( crate::db::read_content(crate::db::ContentKey::Story("24_story_test")).is_some(), "content store should have the story after move" ); // Result is either Ok (agent started) or Err (agent failed - acceptable in tests) let _ = result; } // tool_merge_agent_work_returns_started removed: the function now blocks // in a poll loop until the merge completes, so it can't be tested without // a full merge pipeline. The blocking behaviour is tested via integration. #[test] fn tool_get_merge_status_no_job() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_get_merge_status(&json!({"story_id": "99_nonexistent"}), &ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("No merge job")); } // tool_get_merge_status_returns_running removed: depends on // tool_merge_agent_work which now blocks indefinitely in a poll loop. #[test] fn report_merge_failure_in_tools_list() { use super::super::handle_tools_list; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let tool = tools.iter().find(|t| t["name"] == "report_merge_failure"); assert!( tool.is_some(), "report_merge_failure missing from tools list" ); let t = tool.unwrap(); assert!(t["description"].is_string()); let required = t["inputSchema"]["required"].as_array().unwrap(); let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); assert!(req_names.contains(&"story_id")); assert!(req_names.contains(&"reason")); } #[test] fn tool_report_merge_failure_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_report_merge_failure(&json!({"reason": "conflicts"}), &ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[test] fn tool_report_merge_failure_missing_reason() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_report_merge_failure(&json!({"story_id": "42_story_foo"}), &ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("reason")); } #[test] fn tool_report_merge_failure_returns_confirmation() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_report_merge_failure( &json!({ "story_id": "42_story_foo", "reason": "Unresolvable merge conflicts in src/main.rs" }), &ctx, ); assert!(result.is_ok()); let msg = result.unwrap(); assert!(msg.contains("42_story_foo")); assert!(msg.contains("Unresolvable merge conflicts")); } }