461 lines
18 KiB
Rust
461 lines
18 KiB
Rust
//! 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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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"));
|
|
}
|
|
}
|