bug-210: prevent pipeline from moving story to done when mergemaster reports failure

The pipeline advancement logic was ignoring report_merge_failure and
blindly trusting the server-owned completion gates_passed result. Now
report_merge_failure sets a flag on the agent entry that the pipeline
checks before advancing — stories stay in 4_merge/ when merge fails.

Squash merge of feature/story-210

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-26 16:12:23 +00:00
parent f2a1e72029
commit 774a731042
2 changed files with 106 additions and 14 deletions

View File

@@ -217,6 +217,12 @@ struct StoryAgent {
project_root: Option<PathBuf>, project_root: Option<PathBuf>,
/// UUID identifying the log file for this session. /// UUID identifying the log file for this session.
log_session_id: Option<String>, log_session_id: Option<String>,
/// Set to `true` when the agent calls `report_merge_failure`.
/// Prevents the pipeline from blindly advancing to `5_done/` after a
/// failed merge: the server-owned gate check runs in the feature-branch
/// worktree (which compiles fine) and returns `gates_passed=true` even
/// though the code was never squash-merged onto master.
merge_failure_reported: bool,
} }
/// Build an `AgentInfo` snapshot from a `StoryAgent` map entry. /// Build an `AgentInfo` snapshot from a `StoryAgent` map entry.
@@ -444,6 +450,7 @@ impl AgentPool {
completion: None, completion: None,
project_root: Some(project_root.to_path_buf()), project_root: Some(project_root.to_path_buf()),
log_session_id: Some(log_session_id.clone()), log_session_id: Some(log_session_id.clone()),
merge_failure_reported: false,
}, },
); );
} }
@@ -898,6 +905,7 @@ impl AgentPool {
completion: CompletionReport, completion: CompletionReport,
project_root: Option<PathBuf>, project_root: Option<PathBuf>,
worktree_path: Option<PathBuf>, worktree_path: Option<PathBuf>,
merge_failure_reported: bool,
) { ) {
let project_root = match project_root { let project_root = match project_root {
Some(p) => p, Some(p) => p,
@@ -1023,6 +1031,18 @@ impl AgentPool {
} }
} }
PipelineStage::Mergemaster => { PipelineStage::Mergemaster => {
// Block advancement if the mergemaster explicitly reported a failure.
// The server-owned gate check runs in the feature-branch worktree (not
// master), so `gates_passed=true` is misleading when no code was merged.
if merge_failure_reported {
slog!(
"[pipeline] Pipeline advancement blocked for '{story_id}': \
mergemaster explicitly reported a merge failure. \
Story stays in 4_merge/ for human review."
);
return;
}
// Run script/test on master (project_root) as the post-merge verification. // Run script/test on master (project_root) as the post-merge verification.
slog!( slog!(
"[pipeline] Mergemaster completed for '{story_id}'. Running post-merge tests on master." "[pipeline] Mergemaster completed for '{story_id}'. Running post-merge tests on master."
@@ -1155,7 +1175,7 @@ impl AgentPool {
// Extract data for pipeline advance, then remove the entry so // Extract data for pipeline advance, then remove the entry so
// completed agents never appear in list_agents. // completed agents never appear in list_agents.
let (tx, session_id, project_root_for_advance, wt_path_for_advance) = { let (tx, session_id, project_root_for_advance, wt_path_for_advance, merge_failure_reported_for_advance) = {
let mut agents = self.agents.lock().map_err(|e| e.to_string())?; let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
let agent = agents.get_mut(&key).ok_or_else(|| { let agent = agents.get_mut(&key).ok_or_else(|| {
format!("Agent '{agent_name}' for story '{story_id}' disappeared during gate check") format!("Agent '{agent_name}' for story '{story_id}' disappeared during gate check")
@@ -1165,8 +1185,9 @@ impl AgentPool {
let sid = agent.session_id.clone(); let sid = agent.session_id.clone();
let pr = agent.project_root.clone(); let pr = agent.project_root.clone();
let wt = agent.worktree_info.as_ref().map(|w| w.path.clone()); let wt = agent.worktree_info.as_ref().map(|w| w.path.clone());
let mfr = agent.merge_failure_reported;
agents.remove(&key); agents.remove(&key);
(tx, sid, pr, wt) (tx, sid, pr, wt, mfr)
}; };
// Emit Done so wait_for_agent unblocks. // Emit Done so wait_for_agent unblocks.
@@ -1197,6 +1218,7 @@ impl AgentPool {
report_for_advance, report_for_advance,
project_root_for_advance, project_root_for_advance,
wt_path_for_advance, wt_path_for_advance,
merge_failure_reported_for_advance,
) )
.await; .await;
}); });
@@ -1303,6 +1325,48 @@ impl AgentPool {
Some((session_id, project_root)) Some((session_id, project_root))
} }
/// Record that the mergemaster agent for `story_id` explicitly reported a
/// merge failure via the `report_merge_failure` MCP tool.
///
/// Sets `merge_failure_reported = true` on the active mergemaster agent so
/// that `run_pipeline_advance` can block advancement to `5_done/` even when
/// the server-owned gate check returns `gates_passed=true` (those gates run
/// in the feature-branch worktree, not on master).
pub fn set_merge_failure_reported(&self, story_id: &str) {
match self.agents.lock() {
Ok(mut lock) => {
let found = lock.iter_mut().find(|(key, agent)| {
let key_story_id = key
.rsplit_once(':')
.map(|(sid, _)| sid)
.unwrap_or(key.as_str());
key_story_id == story_id
&& pipeline_stage(&agent.agent_name) == PipelineStage::Mergemaster
});
match found {
Some((_, agent)) => {
agent.merge_failure_reported = true;
slog!(
"[pipeline] Merge failure flag set for '{story_id}:{}'",
agent.agent_name
);
}
None => {
slog_warn!(
"[pipeline] set_merge_failure_reported: no running mergemaster found \
for story '{story_id}' — flag not set"
);
}
}
}
Err(e) => {
slog_error!(
"[pipeline] set_merge_failure_reported: could not lock agents: {e}"
);
}
}
}
/// Test helper: inject a pre-built agent entry so unit tests can exercise /// Test helper: inject a pre-built agent entry so unit tests can exercise
/// wait/subscribe logic without spawning a real process. /// wait/subscribe logic without spawning a real process.
#[cfg(test)] #[cfg(test)]
@@ -1328,6 +1392,7 @@ impl AgentPool {
completion: None, completion: None,
project_root: None, project_root: None,
log_session_id: None, log_session_id: None,
merge_failure_reported: false,
}, },
); );
tx tx
@@ -1363,6 +1428,7 @@ impl AgentPool {
completion: None, completion: None,
project_root: None, project_root: None,
log_session_id: None, log_session_id: None,
merge_failure_reported: false,
}, },
); );
tx tx
@@ -1705,6 +1771,7 @@ impl AgentPool {
completion: Some(completion), completion: Some(completion),
project_root: Some(project_root), project_root: Some(project_root),
log_session_id: None, log_session_id: None,
merge_failure_reported: false,
}, },
); );
tx tx
@@ -1736,6 +1803,7 @@ impl AgentPool {
completion: None, completion: None,
project_root: None, project_root: None,
log_session_id: None, log_session_id: None,
merge_failure_reported: false,
}, },
); );
tx tx
@@ -2037,7 +2105,7 @@ async fn run_server_owned_completion(
// Store completion report, extract data for pipeline advance, then // Store completion report, extract data for pipeline advance, then
// remove the entry so completed agents never appear in list_agents. // remove the entry so completed agents never appear in list_agents.
let (tx, project_root_for_advance, wt_path_for_advance) = { let (tx, project_root_for_advance, wt_path_for_advance, merge_failure_reported_for_advance) = {
let mut lock = match agents.lock() { let mut lock = match agents.lock() {
Ok(a) => a, Ok(a) => a,
Err(_) => return, Err(_) => return,
@@ -2051,8 +2119,9 @@ async fn run_server_owned_completion(
let tx = agent.tx.clone(); let tx = agent.tx.clone();
let pr = agent.project_root.clone(); let pr = agent.project_root.clone();
let wt = agent.worktree_info.as_ref().map(|w| w.path.clone()); let wt = agent.worktree_info.as_ref().map(|w| w.path.clone());
let mfr = agent.merge_failure_reported;
lock.remove(&key); lock.remove(&key);
(tx, pr, wt) (tx, pr, wt, mfr)
}; };
// Emit Done so wait_for_agent unblocks. // Emit Done so wait_for_agent unblocks.
@@ -2075,6 +2144,7 @@ async fn run_server_owned_completion(
project_root_for_advance, project_root_for_advance,
wt_path_for_advance, wt_path_for_advance,
watcher_tx, watcher_tx,
merge_failure_reported_for_advance,
); );
} }
@@ -2092,6 +2162,7 @@ fn spawn_pipeline_advance(
project_root: Option<PathBuf>, project_root: Option<PathBuf>,
worktree_path: Option<PathBuf>, worktree_path: Option<PathBuf>,
watcher_tx: broadcast::Sender<WatcherEvent>, watcher_tx: broadcast::Sender<WatcherEvent>,
merge_failure_reported: bool,
) { ) {
let sid = story_id.to_string(); let sid = story_id.to_string();
let aname = agent_name.to_string(); let aname = agent_name.to_string();
@@ -2102,7 +2173,14 @@ fn spawn_pipeline_advance(
child_killers: Arc::new(Mutex::new(HashMap::new())), child_killers: Arc::new(Mutex::new(HashMap::new())),
watcher_tx, watcher_tx,
}; };
pool.run_pipeline_advance(&sid, &aname, completion, project_root, worktree_path) pool.run_pipeline_advance(
&sid,
&aname,
completion,
project_root,
worktree_path,
merge_failure_reported,
)
.await; .await;
}); });
} }
@@ -3949,6 +4027,7 @@ mod tests {
}, },
Some(root.to_path_buf()), Some(root.to_path_buf()),
None, None,
false,
) )
.await; .await;
@@ -3986,6 +4065,7 @@ mod tests {
}, },
Some(root.to_path_buf()), Some(root.to_path_buf()),
None, None,
false,
) )
.await; .await;
@@ -4021,6 +4101,7 @@ mod tests {
}, },
Some(root.to_path_buf()), Some(root.to_path_buf()),
None, None,
false,
) )
.await; .await;
@@ -6569,6 +6650,7 @@ stage = "qa"
}, },
Some(root.to_path_buf()), Some(root.to_path_buf()),
None, None,
false,
) )
.await; .await;

View File

@@ -899,7 +899,7 @@ async fn handle_tools_call(
// Mergemaster tools // Mergemaster tools
"merge_agent_work" => tool_merge_agent_work(&args, ctx).await, "merge_agent_work" => tool_merge_agent_work(&args, ctx).await,
"move_story_to_merge" => tool_move_story_to_merge(&args, ctx).await, "move_story_to_merge" => tool_move_story_to_merge(&args, ctx).await,
"report_merge_failure" => tool_report_merge_failure(&args), "report_merge_failure" => tool_report_merge_failure(&args, ctx),
// QA tools // QA tools
"request_qa" => tool_request_qa(&args, ctx).await, "request_qa" => tool_request_qa(&args, ctx).await,
// Diagnostics // Diagnostics
@@ -1645,7 +1645,7 @@ async fn tool_move_story_to_merge(args: &Value, ctx: &AppContext) -> Result<Stri
.map_err(|e| format!("Serialization error: {e}")) .map_err(|e| format!("Serialization error: {e}"))
} }
fn tool_report_merge_failure(args: &Value) -> Result<String, String> { fn tool_report_merge_failure(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args let story_id = args
.get("story_id") .get("story_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -1656,6 +1656,7 @@ fn tool_report_merge_failure(args: &Value) -> Result<String, String> {
.ok_or("Missing required argument: reason")?; .ok_or("Missing required argument: reason")?;
slog!("[mergemaster] Merge failure reported for '{story_id}': {reason}"); slog!("[mergemaster] Merge failure reported for '{story_id}': {reason}");
ctx.agents.set_merge_failure_reported(story_id);
Ok(format!( Ok(format!(
"Merge failure for '{story_id}' recorded. Story remains in work/4_merge/. Reason: {reason}" "Merge failure for '{story_id}' recorded. Story remains in work/4_merge/. Reason: {reason}"
@@ -2638,24 +2639,33 @@ mod tests {
#[test] #[test]
fn tool_report_merge_failure_missing_story_id() { fn tool_report_merge_failure_missing_story_id() {
let result = tool_report_merge_failure(&json!({"reason": "conflicts"})); 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.is_err());
assert!(result.unwrap_err().contains("story_id")); assert!(result.unwrap_err().contains("story_id"));
} }
#[test] #[test]
fn tool_report_merge_failure_missing_reason() { fn tool_report_merge_failure_missing_reason() {
let result = tool_report_merge_failure(&json!({"story_id": "42_story_foo"})); 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.is_err());
assert!(result.unwrap_err().contains("reason")); assert!(result.unwrap_err().contains("reason"));
} }
#[test] #[test]
fn tool_report_merge_failure_returns_confirmation() { fn tool_report_merge_failure_returns_confirmation() {
let result = tool_report_merge_failure(&json!({ let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_report_merge_failure(
&json!({
"story_id": "42_story_foo", "story_id": "42_story_foo",
"reason": "Unresolvable merge conflicts in src/main.rs" "reason": "Unresolvable merge conflicts in src/main.rs"
})); }),
&ctx,
);
assert!(result.is_ok()); assert!(result.is_ok());
let msg = result.unwrap(); let msg = result.unwrap();
assert!(msg.contains("42_story_foo")); assert!(msg.contains("42_story_foo"));