huskies: merge 519_story_mergemaster_should_detect_no_commits_ahead_of_master_and_fail_loudly_instead_of_exiting_silently

This commit is contained in:
dave
2026-04-09 22:05:52 +00:00
parent 6f6d37e955
commit cbe016d7a2
+124 -14
View File
@@ -69,13 +69,8 @@ impl AgentPool {
slog_error!(
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
);
} else if let Err(e) = self
.start_agent(&project_root, story_id, Some("mergemaster"), None)
.await
{
slog_error!(
"[pipeline] Failed to start mergemaster for '{story_id}': {e}"
);
} else {
self.start_mergemaster_or_block(&project_root, story_id).await;
}
}
crate::io::story_metadata::QaMode::Agent => {
@@ -186,13 +181,8 @@ impl AgentPool {
slog_error!(
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
);
} else if let Err(e) = self
.start_agent(&project_root, story_id, Some("mergemaster"), None)
.await
{
slog_error!(
"[pipeline] Failed to start mergemaster for '{story_id}': {e}"
);
} else {
self.start_mergemaster_or_block(&project_root, story_id).await;
}
}
} else if let Some(reason) = should_block_story(story_id, config.max_retries, "qa-coverage") {
@@ -333,6 +323,41 @@ impl AgentPool {
// become available (bug 295).
self.auto_assign_available_work(&project_root).await;
}
/// Start the mergemaster agent for `story_id`, but only if the feature
/// branch has commits that are not yet on master.
///
/// If the branch has zero commits ahead of master, this logs an error and
/// sends a [`WatcherEvent::StoryBlocked`] instead of spawning a Claude
/// session. A no-op merge session was observed spending $0.82 in the
/// 2026-04-09 incident (story 519).
async fn start_mergemaster_or_block(&self, project_root: &Path, story_id: &str) {
let branch = format!("feature/story-{story_id}");
if !crate::agents::lifecycle::feature_branch_has_unmerged_changes(project_root, story_id) {
slog_error!(
"[mergemaster] Branch '{branch}' has no commits ahead of master — \
refusing to spawn merge session. \
Likely cause: the worktree was reset to master after the feature \
branch's commits were created. Investigate the worktree's git state \
before retrying. Story '{story_id}' stays in 4_merge/ for human review."
);
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
story_id: story_id.to_string(),
reason: format!(
"Feature branch '{branch}' has no commits ahead of master — nothing to merge. \
The worktree may have been reset to master. \
Check the worktree's git state and retry manually."
),
});
return;
}
if let Err(e) = self
.start_agent(project_root, story_id, Some("mergemaster"), None)
.await
{
slog_error!("[pipeline] Failed to start mergemaster for '{story_id}': {e}");
}
}
}
/// Spawn pipeline advancement as a background task.
@@ -693,6 +718,91 @@ stage = "qa"
);
}
// ── story 519: mergemaster pre-flight blocks when no commits ahead ──
/// Regression test for story 519: when the feature branch has zero commits
/// ahead of master, mergemaster must not spawn a Claude session. A no-op
/// session spent $0.82 in the 2026-04-09 incident because the worktree was
/// reset to master before mergemaster ran.
#[tokio::test]
async fn mergemaster_blocks_and_sends_story_blocked_when_no_commits_ahead() {
use std::fs;
use std::process::Command;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Init a bare git repo on master with one empty commit.
Command::new("git").args(["init"]).current_dir(root).output().unwrap();
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(root).output().unwrap();
Command::new("git").args(["config", "user.name", "Test"]).current_dir(root).output().unwrap();
Command::new("git").args(["commit", "--allow-empty", "-m", "init"]).current_dir(root).output().unwrap();
// Create a feature branch that points at master HEAD (zero commits ahead).
// This replicates the incident where the worktree was reset to master.
Command::new("git")
.args(["branch", "feature/story-9919_story_no_commits"])
.current_dir(root)
.output()
.unwrap();
// Set up pipeline dirs and story file.
let current = root.join(".huskies/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::create_dir_all(root.join(".huskies/work/4_merge")).unwrap();
fs::write(current.join("9919_story_no_commits.md"), "---\nname: Test\n---\n").unwrap();
let pool = AgentPool::new_test(3001);
let mut rx = pool.watcher_tx.subscribe();
// Simulate coder completing with gates passed (qa: server → goes to merge).
pool.run_pipeline_advance(
"9919_story_no_commits",
"coder-1",
CompletionReport {
summary: "done".to_string(),
gates_passed: true,
gate_output: String::new(),
},
Some(root.to_path_buf()),
None,
false,
)
.await;
// Story should be in 4_merge/ (pipeline moved it there before the block).
assert!(
root.join(".huskies/work/4_merge/9919_story_no_commits.md").exists(),
"story should remain in 4_merge/ — not moved to done"
);
// A StoryBlocked event must have been emitted (triggers chat failure notice,
// not the success 🎉 emoji).
let mut got_blocked = false;
while let Ok(evt) = rx.try_recv() {
if let WatcherEvent::StoryBlocked { story_id, .. } = &evt
&& story_id == "9919_story_no_commits"
{
got_blocked = true;
break;
}
}
assert!(
got_blocked,
"StoryBlocked event must be sent when feature branch has no commits ahead of master"
);
// No mergemaster agent should have been started.
let agents = pool.agents.lock().unwrap();
let mergemaster_started = agents
.values()
.any(|a| a.agent_name.contains("mergemaster"));
assert!(
!mergemaster_started,
"mergemaster agent must NOT be started when no commits ahead of master"
);
}
// ── bug 295: pipeline advance picks up waiting QA stories ──────────
#[tokio::test]