From cbe016d7a230e8155d731ad913bb36c1ad84a823 Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 9 Apr 2026 22:05:52 +0000 Subject: [PATCH] huskies: merge 519_story_mergemaster_should_detect_no_commits_ahead_of_master_and_fail_loudly_instead_of_exiting_silently --- server/src/agents/pool/pipeline/advance.rs | 138 ++++++++++++++++++--- 1 file changed, 124 insertions(+), 14 deletions(-) diff --git a/server/src/agents/pool/pipeline/advance.rs b/server/src/agents/pool/pipeline/advance.rs index 6671d875..b5473feb 100644 --- a/server/src/agents/pool/pipeline/advance.rs +++ b/server/src/agents/pool/pipeline/advance.rs @@ -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(¤t).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]