fix: replace fast-forward with cherry-pick in mergemaster squash-merge

The mergemaster pipeline used git merge --ff-only to apply the squash
commit from a merge-queue branch onto master. This raced with the
filesystem watcher which auto-commits pipeline file moves to master,
causing the fast-forward to fail. The mergemaster agent would then
improvise by manually moving stories to done without the code merge.

- Replace --ff-only with cherry-pick so concurrent watcher commits
  don't block the merge
- Add report_merge_failure MCP tool for explicit failure handling
- Update mergemaster prompt to forbid manual file moves
- Fix cleanup_merge_workspace to handle stale directories

Squash merge of feature/story-205

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-26 14:16:35 +00:00
parent c435d86d1a
commit 81065a3ada
3 changed files with 355 additions and 24 deletions

View File

@@ -1,6 +1,7 @@
use crate::agents::{close_bug_to_archive, move_story_to_archived, move_story_to_merge, move_story_to_qa, PipelineStage};
use crate::config::ProjectConfig;
use crate::log_buffer;
use crate::slog;
use crate::slog_warn;
use crate::http::context::AppContext;
use crate::http::settings::get_editor_command_from_store;
@@ -768,6 +769,24 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
"required": ["story_id"]
}
},
{
"name": "report_merge_failure",
"description": "Report that a merge failed for a story. Leaves the story in work/4_merge/ and logs the failure reason. Use this when merge_agent_work returns success=false instead of manually moving the story file.",
"inputSchema": {
"type": "object",
"properties": {
"story_id": {
"type": "string",
"description": "Story identifier (e.g. '52_story_mergemaster_agent_role')"
},
"reason": {
"type": "string",
"description": "Human-readable explanation of why the merge failed"
}
},
"required": ["story_id", "reason"]
}
},
{
"name": "request_qa",
"description": "Trigger QA review of a completed story worktree: moves the item from work/2_current/ to work/3_qa/ and starts the qa agent to run quality gates, tests, and generate a manual testing plan.",
@@ -880,6 +899,7 @@ async fn handle_tools_call(
// Mergemaster tools
"merge_agent_work" => tool_merge_agent_work(&args, ctx).await,
"move_story_to_merge" => tool_move_story_to_merge(&args, ctx).await,
"report_merge_failure" => tool_report_merge_failure(&args),
// QA tools
"request_qa" => tool_request_qa(&args, ctx).await,
// Diagnostics
@@ -1568,11 +1588,11 @@ async fn tool_merge_agent_work(args: &Value, ctx: &AppContext) -> Result<String,
} else if report.success && report.gates_passed {
"Merge complete: all quality gates passed. Story moved to done and worktree cleaned up."
} else if report.had_conflicts && !report.conflicts_resolved {
"Merge failed: conflicts detected that could not be auto-resolved. Merge was aborted — master is untouched. Report the conflict details so the human can resolve them."
"Merge failed: conflicts detected that could not be auto-resolved. Merge was aborted — master is untouched. Call report_merge_failure with the conflict details so the human can resolve them. Do NOT manually move the story file or call accept_story."
} else if report.success && !report.gates_passed {
"Merge committed but quality gates failed. Review gate_output and fix issues before re-running."
} else {
"Merge failed. Review gate_output for details."
"Merge failed. Review gate_output for details. Call report_merge_failure to record the failure. Do NOT manually move the story file or call accept_story."
};
serde_json::to_string_pretty(&json!({
@@ -1625,6 +1645,23 @@ async fn tool_move_story_to_merge(args: &Value, ctx: &AppContext) -> Result<Stri
.map_err(|e| format!("Serialization error: {e}"))
}
fn tool_report_merge_failure(args: &Value) -> 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}");
Ok(format!(
"Merge failure for '{story_id}' recorded. Story remains in work/4_merge/. Reason: {reason}"
))
}
// ── QA tool implementations ───────────────────────────────────────
async fn tool_request_qa(args: &Value, ctx: &AppContext) -> Result<String, String> {
@@ -1895,10 +1932,11 @@ mod tests {
assert!(names.contains(&"close_bug"));
assert!(names.contains(&"merge_agent_work"));
assert!(names.contains(&"move_story_to_merge"));
assert!(names.contains(&"report_merge_failure"));
assert!(names.contains(&"request_qa"));
assert!(names.contains(&"get_server_logs"));
assert!(names.contains(&"prompt_permission"));
assert_eq!(tools.len(), 30);
assert_eq!(tools.len(), 31);
}
#[test]
@@ -2579,6 +2617,52 @@ mod tests {
assert!(parsed.get("message").is_some());
}
// ── report_merge_failure tool tests ─────────────────────────────
#[test]
fn report_merge_failure_in_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 result = tool_report_merge_failure(&json!({"reason": "conflicts"}));
assert!(result.is_err());
assert!(result.unwrap_err().contains("story_id"));
}
#[test]
fn tool_report_merge_failure_missing_reason() {
let result = tool_report_merge_failure(&json!({"story_id": "42_story_foo"}));
assert!(result.is_err());
assert!(result.unwrap_err().contains("reason"));
}
#[test]
fn tool_report_merge_failure_returns_confirmation() {
let result = tool_report_merge_failure(&json!({
"story_id": "42_story_foo",
"reason": "Unresolvable merge conflicts in src/main.rs"
}));
assert!(result.is_ok());
let msg = result.unwrap();
assert!(msg.contains("42_story_foo"));
assert!(msg.contains("work/4_merge/"));
assert!(msg.contains("Unresolvable merge conflicts"));
}
// ── HTTP handler tests (TestClient) ───────────────────────────
fn test_mcp_app(ctx: std::sync::Arc<AppContext>) -> impl poem::Endpoint {