Make merge_agent_work async to avoid MCP 60-second tool timeout
The merge pipeline (squash merge + quality gates) takes well over 60 seconds. Claude Code's MCP HTTP transport times out at 60s, causing "completed with no output" — the mergemaster retries fruitlessly. merge_agent_work now starts the pipeline as a background task and returns immediately. A new get_merge_status tool lets the mergemaster poll until the job reaches a terminal state. Also adds a double-start guard so concurrent calls for the same story are rejected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -766,7 +766,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
},
|
||||
{
|
||||
"name": "merge_agent_work",
|
||||
"description": "Trigger the mergemaster pipeline for a completed story: squash-merge the feature branch into master, run quality gates (cargo clippy, cargo test, pnpm build, pnpm test), move the story from work/4_merge/ or work/2_current/ to work/5_done/, and clean up the worktree and branch. Reports success/failure with details including any conflicts found and gate output.",
|
||||
"description": "Start the mergemaster pipeline for a completed story as a background job. Returns immediately — poll get_merge_status(story_id) until the merge completes or fails. The pipeline squash-merges the feature branch into master, runs quality gates, moves the story to done, and cleans up.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -782,6 +782,20 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
"required": ["story_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_merge_status",
|
||||
"description": "Check the status of a merge_agent_work background job. Returns running/completed/failed. When completed, includes the full merge report with conflict details, gate output, and whether the story was archived.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"story_id": {
|
||||
"type": "string",
|
||||
"description": "Story identifier (same as passed to merge_agent_work)"
|
||||
}
|
||||
},
|
||||
"required": ["story_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "move_story_to_merge",
|
||||
"description": "Move a story or bug from work/2_current/ to work/4_merge/ to queue it for the mergemaster pipeline and automatically spawn the mergemaster agent to squash-merge, run quality gates, and archive.",
|
||||
@@ -931,7 +945,8 @@ async fn handle_tools_call(
|
||||
"create_refactor" => tool_create_refactor(&args, ctx),
|
||||
"list_refactors" => tool_list_refactors(ctx),
|
||||
// Mergemaster tools
|
||||
"merge_agent_work" => tool_merge_agent_work(&args, ctx).await,
|
||||
"merge_agent_work" => tool_merge_agent_work(&args, ctx),
|
||||
"get_merge_status" => tool_get_merge_status(&args, ctx),
|
||||
"move_story_to_merge" => tool_move_story_to_merge(&args, ctx).await,
|
||||
"report_merge_failure" => tool_report_merge_failure(&args, ctx),
|
||||
// QA tools
|
||||
@@ -1651,54 +1666,81 @@ fn tool_list_refactors(ctx: &AppContext) -> Result<String, String> {
|
||||
|
||||
// ── Mergemaster tool implementations ─────────────────────────────
|
||||
|
||||
async fn tool_merge_agent_work(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
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")?;
|
||||
let agent_name = args.get("agent_name").and_then(|v| v.as_str());
|
||||
|
||||
// TRACE:MERGE-DEBUG — remove once root cause is found
|
||||
crate::slog!(
|
||||
"[MERGE-DEBUG] tool_merge_agent_work called for story_id={:?}, agent_name={:?}",
|
||||
story_id,
|
||||
agent_name
|
||||
);
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
crate::slog!(
|
||||
"[MERGE-DEBUG] tool_merge_agent_work: project_root resolved to {:?}",
|
||||
project_root
|
||||
);
|
||||
let report = ctx.agents.merge_agent_work(&project_root, story_id).await?;
|
||||
|
||||
let status_msg = if report.success && report.gates_passed && report.conflicts_resolved {
|
||||
"Merge complete: conflicts were auto-resolved and all quality gates passed. Story moved to done and worktree cleaned up."
|
||||
} 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. 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. Call report_merge_failure to record the failure. Do NOT manually move the story file or call accept_story."
|
||||
};
|
||||
ctx.agents.start_merge_agent_work(&project_root, story_id)?;
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": story_id,
|
||||
"agent_name": agent_name,
|
||||
"success": report.success,
|
||||
"had_conflicts": report.had_conflicts,
|
||||
"conflicts_resolved": report.conflicts_resolved,
|
||||
"conflict_details": report.conflict_details,
|
||||
"gates_passed": report.gates_passed,
|
||||
"gate_output": report.gate_output,
|
||||
"worktree_cleaned_up": report.worktree_cleaned_up,
|
||||
"story_archived": report.story_archived,
|
||||
"message": status_msg,
|
||||
"status": "started",
|
||||
"message": "Merge pipeline started. Poll get_merge_status(story_id) every 10-15 seconds until status is 'completed' or 'failed'."
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
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.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. Poll again in 10-15 seconds."
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
crate::agents::merge::MergeJobStatus::Completed(report) => {
|
||||
let status_msg = if report.success && report.gates_passed && report.conflicts_resolved {
|
||||
"Merge complete: conflicts were auto-resolved and all quality gates passed. Story moved to done and worktree cleaned up."
|
||||
} 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. 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. 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!({
|
||||
"story_id": story_id,
|
||||
"status": "completed",
|
||||
"success": report.success,
|
||||
"had_conflicts": report.had_conflicts,
|
||||
"conflicts_resolved": report.conflicts_resolved,
|
||||
"conflict_details": report.conflict_details,
|
||||
"gates_passed": report.gates_passed,
|
||||
"gate_output": report.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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn tool_move_story_to_merge(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
@@ -2147,12 +2189,13 @@ mod tests {
|
||||
assert!(names.contains(&"create_refactor"));
|
||||
assert!(names.contains(&"list_refactors"));
|
||||
assert!(names.contains(&"merge_agent_work"));
|
||||
assert!(names.contains(&"get_merge_status"));
|
||||
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(), 33);
|
||||
assert_eq!(tools.len(), 34);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2787,11 +2830,11 @@ mod tests {
|
||||
assert!(!req_names.contains(&"agent_name"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_merge_agent_work_missing_story_id() {
|
||||
#[test]
|
||||
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;
|
||||
let result = tool_merge_agent_work(&json!({}), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
@@ -2838,28 +2881,54 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_merge_agent_work_returns_coherent_report() {
|
||||
async fn tool_merge_agent_work_returns_started() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo_in(tmp.path());
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// Try to merge a non-existent branch — should return a report (not panic)
|
||||
let result = tool_merge_agent_work(
|
||||
&json!({"story_id": "99_nonexistent", "agent_name": "coder-1"}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["story_id"], "99_nonexistent");
|
||||
assert_eq!(parsed["agent_name"], "coder-1");
|
||||
assert!(parsed.get("success").is_some());
|
||||
assert!(parsed.get("had_conflicts").is_some());
|
||||
assert!(parsed.get("gates_passed").is_some());
|
||||
assert!(parsed.get("gate_output").is_some());
|
||||
assert_eq!(parsed["status"], "started");
|
||||
assert!(parsed.get("message").is_some());
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_get_merge_status_returns_running() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo_in(tmp.path());
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// Start a merge (it will run in background)
|
||||
tool_merge_agent_work(
|
||||
&json!({"story_id": "99_nonexistent"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Immediately check — should be running (or already finished if very fast)
|
||||
let result = tool_get_merge_status(&json!({"story_id": "99_nonexistent"}), &ctx).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
let status = parsed["status"].as_str().unwrap();
|
||||
assert!(
|
||||
status == "running" || status == "completed" || status == "failed",
|
||||
"unexpected status: {status}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── report_merge_failure tool tests ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user