//! 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 { .. } | crate::pipeline_state::Stage::Abandoned { .. } | crate::pipeline_state::Stage::Superseded { .. } | crate::pipeline_state::Stage::Rejected { .. } ) { 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 => { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs_f64(); let elapsed_seconds = (now - job.started_at).max(0.0); serde_json::to_string_pretty(&json!({ "story_id": story_id, "status": "running", "elapsed_seconds": elapsed_seconds, "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) = match &report.result { MergeResult::Success { conflicts_resolved, conflict_details, .. } => { (true, *conflicts_resolved, *conflicts_resolved, conflict_details.clone(), true) } MergeResult::Conflict { details, .. } => { (false, true, false, details.clone(), false) } MergeResult::GateFailure { .. } => { (false, false, false, None, false) } MergeResult::NoCommits { .. } => { (false, false, false, None, false) } MergeResult::Other { conflict_details, .. } => { (false, false, false, conflict_details.clone(), false) } }; // AC1: condensed digest instead of the full (potentially 35KB+) // gate_output — successful merges carry no summary at all. let gate_summary = crate::service::merge::summarize_merge_result(&report.result); 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_summary": if gate_summary.is_empty() { serde_json::Value::Null } else { json!(gate_summary) }, "report_path": report.report_path, "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 req = crate::validation::MoveStoryToMergeRequest::from_json(args)?; let story_id = req.story_id.as_str(); let agent_name = req.resolved_agent_name(); 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}")) } /// Record a merge failure reported by the mergemaster agent. pub(super) async 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) .await; // 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, ); 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, ); 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")); } /// AC3: a still-running merge job returns status and elapsed time only — /// no gate data of any kind belongs on a Running response. #[test] fn tool_get_merge_status_running_includes_elapsed_seconds_only() { crate::crdt_state::init_for_test(); crate::crdt_state::write_merge_job( "50_story_running", "running", 0.0, None, Some("{\"server_start\":0.0}"), ); let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_get_merge_status(&json!({"story_id": "50_story_running"}), &ctx); assert!(result.is_ok(), "expected Ok, got: {result:?}"); let v: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); assert_eq!(v["status"], "running"); assert!( v["elapsed_seconds"].as_f64().unwrap() > 0.0, "elapsed_seconds must reflect time since started_at=0.0: {v}" ); assert!( v.get("gate_output").is_none() && v.get("gate_summary").is_none(), "a running job must carry no gate data: {v}" ); } /// AC1: a successful merge's response carries no gate summary at all. #[test] fn tool_get_merge_status_completed_success_has_no_gate_summary() { crate::crdt_state::init_for_test(); let report = crate::agents::merge::MergeReport { story_id: "51_story_ok".to_string(), result: crate::agents::merge::MergeResult::Success { conflicts_resolved: false, conflict_details: None, gate_output: "all tests passed".to_string(), }, worktree_cleaned_up: true, story_archived: true, report_path: Some(".huskies/merge_reports/51_story_ok-1.log".to_string()), }; let report_json = serde_json::to_string(&report).unwrap(); crate::crdt_state::write_merge_job( "51_story_ok", "completed", 1.0, Some(2.0), Some(&report_json), ); let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_get_merge_status(&json!({"story_id": "51_story_ok"}), &ctx).unwrap(); let v: serde_json::Value = serde_json::from_str(&result).unwrap(); assert!( v["gate_summary"].is_null(), "success must carry no gate summary: {v}" ); assert_eq!(v["report_path"], ".huskies/merge_reports/51_story_ok-1.log"); } /// AC1/AC2: a gate-failure response carries a condensed summary (failing /// gate + parsed test names + tail), plus the full-report path — not the /// raw multi-KB output. #[test] fn tool_get_merge_status_completed_gate_failure_has_condensed_summary() { crate::crdt_state::init_for_test(); let output = "running 1 test\ntest foo::bar ... FAILED\n\n\ failures:\n foo::bar\n\ntest result: FAILED. 0 passed; 1 failed\n" .to_string(); let report = crate::agents::merge::MergeReport { story_id: "52_story_fail".to_string(), result: crate::agents::merge::MergeResult::GateFailure { output, failure_kind: Some(crate::agents::gates::GateFailureKind::Test), }, worktree_cleaned_up: false, story_archived: false, report_path: Some(".huskies/merge_reports/52_story_fail-1.log".to_string()), }; let report_json = serde_json::to_string(&report).unwrap(); crate::crdt_state::write_merge_job( "52_story_fail", "completed", 1.0, Some(2.0), Some(&report_json), ); let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_get_merge_status(&json!({"story_id": "52_story_fail"}), &ctx).unwrap(); let v: serde_json::Value = serde_json::from_str(&result).unwrap(); let summary = v["gate_summary"] .as_str() .expect("gate_summary must be present on failure"); assert!( summary.contains("Failing gate: tests"), "summary: {summary}" ); assert!( summary.contains("Failing tests: foo::bar"), "summary: {summary}" ); assert_eq!( v["report_path"], ".huskies/merge_reports/52_story_fail-1.log" ); } // 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")); } #[tokio::test] async 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).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[tokio::test] async 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).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("reason")); } #[tokio::test] async 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, ) .await; assert!(result.is_ok()); let msg = result.unwrap(); assert!(msg.contains("42_story_foo")); assert!(msg.contains("Unresolvable merge conflicts")); } }