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:
Dave
2026-03-17 12:15:42 +00:00
parent a85d1a1170
commit 7c6e1b445d
4 changed files with 289 additions and 87 deletions

View File

@@ -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]