diff --git a/server/src/agents/merge/mod.rs b/server/src/agents/merge/mod.rs index b9fbb4ae..2c017b43 100644 --- a/server/src/agents/merge/mod.rs +++ b/server/src/agents/merge/mod.rs @@ -2,7 +2,6 @@ use serde::Serialize; -mod conflicts; mod squash; pub(crate) use squash::run_squash_merge; diff --git a/server/src/agents/merge/squash/mod.rs b/server/src/agents/merge/squash/mod.rs index 665044e9..3b8405e3 100644 --- a/server/src/agents/merge/squash/mod.rs +++ b/server/src/agents/merge/squash/mod.rs @@ -6,7 +6,6 @@ use std::process::Command; use std::sync::Mutex; use super::super::gates::run_project_tests; -use super::conflicts::try_resolve_conflicts; use super::{MergeReport, SquashMergeResult}; use crate::config::ProjectConfig; @@ -107,55 +106,28 @@ pub(crate) fn run_squash_merge( all_output.push_str(&merge_stderr); all_output.push('\n'); - let mut had_conflicts = false; - let mut conflicts_resolved = false; + let conflicts_resolved = false; let mut conflict_details: Option = None; if !merge.status.success() { - had_conflicts = true; - all_output.push_str("=== Conflicts detected, attempting auto-resolution ===\n"); - - // Try to automatically resolve simple conflicts. - match try_resolve_conflicts(&merge_wt_path) { - Ok((resolved, resolution_log)) => { - all_output.push_str(&resolution_log); - if resolved { - conflicts_resolved = true; - all_output.push_str("=== All conflicts resolved automatically ===\n"); - } else { - // Could not resolve — abort, clean up, and report. - let details = format!( - "Merge conflicts in branch '{branch}':\n{merge_stdout}{merge_stderr}\n{resolution_log}" - ); - conflict_details = Some(details); - all_output.push_str("=== Unresolvable conflicts, aborting merge ===\n"); - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - return Ok(SquashMergeResult { - success: false, - had_conflicts: true, - conflicts_resolved: false, - conflict_details, - output: all_output, - gates_passed: false, - }); - } - } - Err(e) => { - all_output.push_str(&format!("Auto-resolution error: {e}\n")); - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - return Ok(SquashMergeResult { - success: false, - had_conflicts: true, - conflicts_resolved: false, - conflict_details: Some(format!( - "Merge conflicts in branch '{branch}' (auto-resolution failed: {e}):\n{merge_stdout}{merge_stderr}" - )), - output: all_output, - gates_passed: false, - }); - } - } + all_output.push_str( + "=== Conflicts detected — aborting merge. Use `start_agent mergemaster` \ + to invoke LLM-driven conflict resolution. ===\n", + ); + let details = + format!("Merge conflicts in branch '{branch}':\n{merge_stdout}{merge_stderr}"); + conflict_details = Some(details); + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); + return Ok(SquashMergeResult { + success: false, + had_conflicts: true, + conflicts_resolved, + conflict_details, + output: all_output, + gates_passed: false, + }); } + let had_conflicts = false; // ── Commit in the temporary worktree ────────────────────────── all_output.push_str("=== git commit ===\n"); diff --git a/server/src/agents/merge/squash/tests_advanced.rs b/server/src/agents/merge/squash/tests_advanced.rs index a2085a37..c0695ee1 100644 --- a/server/src/agents/merge/squash/tests_advanced.rs +++ b/server/src/agents/merge/squash/tests_advanced.rs @@ -144,15 +144,15 @@ async fn squash_merge_additive_conflict_both_additions_preserved() { // Squash-merge the feature branch — conflicts because both appended to the same location. let result = run_squash_merge(repo, "feature/story-238_additive", "238_additive").unwrap(); - // Conflict must be detected and auto-resolved. + // Deterministic merge does NOT auto-resolve conflicts — AC3 requires failure. assert!(result.had_conflicts, "additive conflict should be detected"); assert!( - result.conflicts_resolved, - "additive conflict must be auto-resolved; output:\n{}", - result.output + !result.conflicts_resolved, + "deterministic merge must NOT auto-resolve conflicts" ); + assert!(!result.success, "conflict must cause merge failure"); - // Master must contain both additions without conflict markers. + // Master must not have been modified (merge aborted). let content = fs::read_to_string(repo.join("module.rs")).unwrap(); assert!( !content.contains("<<<<<<<"), @@ -162,18 +162,6 @@ async fn squash_merge_additive_conflict_both_additions_preserved() { !content.contains(">>>>>>>"), "master must not contain conflict markers" ); - assert!( - content.contains("feature_fn"), - "feature branch addition must be preserved on master" - ); - assert!( - content.contains("master_fn"), - "master branch addition must be preserved on master" - ); - assert!( - content.contains("existing"), - "original function must be preserved" - ); // Cleanup: no leftover merge-queue branch or workspace. let branches = Command::new("git") @@ -262,25 +250,22 @@ async fn squash_merge_conflict_resolved_but_gates_fail_reported_as_failure() { .output() .unwrap(); - // Squash-merge: conflict detected → auto-resolved → quality gates run → fail. + // Squash-merge: conflict detected → aborted immediately (no gate run). let result = run_squash_merge(repo, "feature/story-238_gates_fail", "238_gates_fail").unwrap(); assert!(result.had_conflicts, "conflict must be detected"); assert!( - result.conflicts_resolved, - "additive conflict must be auto-resolved" - ); - assert!( - !result.gates_passed, - "quality gates must fail (script/test exits 1)" + !result.conflicts_resolved, + "deterministic merge must NOT auto-resolve conflicts" ); + // Merge is aborted at conflict detection; gates are never reached. assert!( !result.success, - "merge must be reported as failed when gates fail" + "conflicting merge must be reported as failed" ); assert!( !result.output.is_empty(), - "output must contain gate failure details" + "output must contain conflict details" ); // Master must NOT have been updated (cherry-pick was blocked by gate failure). diff --git a/server/src/agents/pool/auto_assign/auto_assign.rs b/server/src/agents/pool/auto_assign/auto_assign.rs index 602459f3..2592bb0c 100644 --- a/server/src/agents/pool/auto_assign/auto_assign.rs +++ b/server/src/agents/pool/auto_assign/auto_assign.rs @@ -83,10 +83,9 @@ impl AgentPool { }; // Process each active pipeline stage in order. - let stages: [(&str, PipelineStage); 3] = [ + let stages: [(&str, PipelineStage); 2] = [ ("2_current", PipelineStage::Coder), ("3_qa", PipelineStage::Qa), - ("4_merge", PipelineStage::Mergemaster), ]; for (stage_dir, stage) in &stages { @@ -121,58 +120,6 @@ impl AgentPool { continue; } - // Skip stories in 4_merge/ that already have a reported merge failure. - // These need human intervention — auto-assigning a new mergemaster - // would just waste tokens on the same broken merge. - if *stage == PipelineStage::Mergemaster - && has_merge_failure(project_root, stage_dir, story_id) - { - continue; - } - - // AC6: Detect empty-diff stories in 4_merge/ before starting a - // mergemaster. If the worktree has no commits on the feature branch, - // write a merge_failure and block the story immediately. - if *stage == PipelineStage::Mergemaster - && let Some(wt_path) = worktree::find_worktree_path(project_root, story_id) - && !crate::agents::gates::worktree_has_committed_work(&wt_path) - { - slog_warn!( - "[auto-assign] Story '{story_id}' in 4_merge/ has no commits \ - on feature branch. Writing merge_failure and blocking." - ); - let empty_diff_reason = "Feature branch has no code changes — the coder agent \ - did not produce any commits."; - // Write merge_failure and blocked to content store. - if let Some(contents) = crate::db::read_content(story_id) { - let updated = crate::io::story_metadata::write_merge_failure_in_content( - &contents, - empty_diff_reason, - ); - let blocked = crate::io::story_metadata::write_blocked_in_content(&updated); - crate::db::write_content(story_id, &blocked); - crate::db::write_item_with_content(story_id, stage_dir, &blocked); - } else { - // Fallback: filesystem. - let story_path = project_root - .join(".huskies/work") - .join(stage_dir) - .join(format!("{story_id}.md")); - let _ = crate::io::story_metadata::write_merge_failure( - &story_path, - empty_diff_reason, - ); - let _ = crate::io::story_metadata::write_blocked(&story_path); - } - let _ = self - .watcher_tx - .send(crate::io::watcher::WatcherEvent::StoryBlocked { - story_id: story_id.to_string(), - reason: empty_diff_reason.to_string(), - }); - continue; - } - // Re-acquire the lock on each iteration to see state changes // from previous start_agent calls in the same pass. let preferred_agent = @@ -287,6 +234,114 @@ impl AgentPool { } } } + + // ── 4_merge: deterministic server-side merge (no LLM agent) ────────── + // + // Stories in 4_merge/ are handled directly by the server rather than by + // a mergemaster agent. For each eligible story, trigger start_merge_agent_work + // which runs the squash-merge pipeline in a background task. On success + // the story advances to 5_done automatically. On failure merge_failure is + // written to the CRDT and a notification is emitted; the story stays in + // 4_merge/ until a human intervenes or an explicit `start_agent mergemaster` + // call invokes the LLM-driven recovery path. + let merge_items = scan_stage_items(project_root, "4_merge"); + for story_id in &merge_items { + // Skip stories with an already-recorded merge failure — they need + // human intervention (operator can call start_agent mergemaster). + if has_merge_failure(project_root, "4_merge", story_id) { + continue; + } + + if has_review_hold(project_root, "4_merge", story_id) { + continue; + } + + if is_story_frozen(project_root, "4_merge", story_id) { + slog!("[auto-assign] Story '{story_id}' in 4_merge/ is frozen; skipping."); + continue; + } + + if is_story_blocked(project_root, "4_merge", story_id) { + continue; + } + + if has_unmet_dependencies(project_root, "4_merge", story_id) { + slog!("[auto-assign] Story '{story_id}' in 4_merge/ has unmet deps; skipping."); + continue; + } + + // AC6: Detect empty-diff stories before starting the merge pipeline. + // If the worktree has no commits on the feature branch, write a + // merge_failure and block the story immediately — no merge job needed. + if let Some(wt_path) = worktree::find_worktree_path(project_root, story_id) + && !crate::agents::gates::worktree_has_committed_work(&wt_path) + { + slog_warn!( + "[auto-assign] Story '{story_id}' in 4_merge/ has no commits \ + on feature branch. Writing merge_failure and blocking." + ); + let empty_diff_reason = "Feature branch has no code changes — the coder agent \ + did not produce any commits."; + if let Some(contents) = crate::db::read_content(story_id) { + let updated = crate::io::story_metadata::write_merge_failure_in_content( + &contents, + empty_diff_reason, + ); + let blocked = crate::io::story_metadata::write_blocked_in_content(&updated); + crate::db::write_content(story_id, &blocked); + crate::db::write_item_with_content(story_id, "4_merge", &blocked); + } else { + let story_path = project_root + .join(".huskies/work/4_merge") + .join(format!("{story_id}.md")); + let _ = crate::io::story_metadata::write_merge_failure( + &story_path, + empty_diff_reason, + ); + let _ = crate::io::story_metadata::write_blocked(&story_path); + } + let _ = self + .watcher_tx + .send(crate::io::watcher::WatcherEvent::StoryBlocked { + story_id: story_id.to_string(), + reason: empty_diff_reason.to_string(), + }); + continue; + } + + // Skip if a merge job is already running for this story (e.g. triggered + // by a previous auto-assign pass or by pipeline advancement). + let already_running = self + .merge_jobs + .lock() + .ok() + .and_then(|jobs| jobs.get(story_id.as_str()).cloned()) + .is_some_and(|job| { + matches!(job.status, crate::agents::merge::MergeJobStatus::Running) + }); + if already_running { + continue; + } + + // Skip if an explicit mergemaster LLM agent is already running + // (operator-driven failure recovery path). + let has_mergemaster = { + let agents = match self.agents.lock() { + Ok(a) => a, + Err(e) => { + slog_error!("[auto-assign] Failed to lock agents: {e}"); + break; + } + }; + is_story_assigned_for_stage(&config, &agents, story_id, &PipelineStage::Mergemaster) + }; + if has_mergemaster { + continue; + } + + slog!("[auto-assign] Triggering server-side merge for '{story_id}' in 4_merge/"); + self.trigger_server_side_merge(project_root, story_id); + } } } diff --git a/server/src/agents/pool/pipeline/advance/mod.rs b/server/src/agents/pool/pipeline/advance/mod.rs index 0c79f6f6..a7931c76 100644 --- a/server/src/agents/pool/pipeline/advance/mod.rs +++ b/server/src/agents/pool/pipeline/advance/mod.rs @@ -77,8 +77,7 @@ impl AgentPool { "[pipeline] Failed to move '{story_id}' to 4_merge/: {e}" ); } else { - self.start_mergemaster_or_block(&project_root, story_id) - .await; + self.trigger_server_side_merge(&project_root, story_id); } } crate::io::story_metadata::QaMode::Agent => { @@ -151,8 +150,7 @@ impl AgentPool { "[pipeline] Failed to move '{story_id}' to 4_merge/: {e}" ); } else { - self.start_mergemaster_or_block(&project_root, story_id) - .await; + self.trigger_server_side_merge(&project_root, story_id); } } crate::io::story_metadata::QaMode::Agent => { @@ -272,8 +270,7 @@ impl AgentPool { "[pipeline] Failed to move '{story_id}' to 4_merge/: {e}" ); } else { - self.start_mergemaster_or_block(&project_root, story_id) - .await; + self.trigger_server_side_merge(&project_root, story_id); } } } else if let Some(reason) = @@ -440,41 +437,6 @@ 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, None) - .await - { - slog_error!("[pipeline] Failed to start mergemaster for '{story_id}': {e}"); - } - } } /// Spawn pipeline advancement as a background task. diff --git a/server/src/agents/pool/pipeline/advance/tests_regression.rs b/server/src/agents/pool/pipeline/advance/tests_regression.rs index 084c95ed..564f30fa 100644 --- a/server/src/agents/pool/pipeline/advance/tests_regression.rs +++ b/server/src/agents/pool/pipeline/advance/tests_regression.rs @@ -88,16 +88,23 @@ async fn mergemaster_blocks_and_sends_story_blocked_when_no_commits_ahead() { "story should remain in content store — not removed" ); - // A StoryBlocked event must have been emitted (triggers chat failure notice, - // not the success 🎉 emoji). + // A StoryBlocked event must be emitted by the background merge task. + // The deterministic merge pipeline runs asynchronously, so poll with a + // timeout instead of a non-blocking try_recv(). 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; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + while tokio::time::Instant::now() < deadline { + while let Ok(evt) = rx.try_recv() { + if let WatcherEvent::StoryBlocked { story_id, .. } = &evt + && story_id == "9919_story_no_commits" + { + got_blocked = true; + } + } + if got_blocked { break; } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; } assert!( got_blocked, diff --git a/server/src/agents/pool/pipeline/merge.rs b/server/src/agents/pool/pipeline/merge.rs index c8959786..d44fb291 100644 --- a/server/src/agents/pool/pipeline/merge.rs +++ b/server/src/agents/pool/pipeline/merge.rs @@ -105,7 +105,55 @@ impl AgentPool { tokio::spawn(async move { let report = pool.run_merge_pipeline(&root, &sid).await; - let failed = report.is_err(); + let success = matches!(&report, Ok(r) if r.success); + + // On any failure: record merge_failure in CRDT and emit notification. + if !success { + let reason = match &report { + Ok(r) => { + if r.had_conflicts { + format!( + "Merge conflict: {}", + r.conflict_details + .as_deref() + .unwrap_or("conflicts detected") + ) + } else { + format!("Quality gates failed: {}", r.gate_output) + } + } + Err(e) => e.clone(), + }; + let is_no_commits = reason.contains("no commits to merge"); + if let Some(contents) = crate::db::read_content(&sid) { + let with_failure = crate::io::story_metadata::write_merge_failure_in_content( + &contents, &reason, + ); + let updated = if is_no_commits { + crate::io::story_metadata::write_blocked_in_content(&with_failure) + } else { + with_failure + }; + crate::db::write_content(&sid, &updated); + crate::db::write_item_with_content(&sid, "4_merge", &updated); + } + if is_no_commits { + let _ = pool + .watcher_tx + .send(crate::io::watcher::WatcherEvent::StoryBlocked { + story_id: sid.clone(), + reason, + }); + } else { + let _ = pool + .watcher_tx + .send(crate::io::watcher::WatcherEvent::MergeFailure { + story_id: sid.clone(), + reason, + }); + } + } + let status = match report { Ok(r) => crate::agents::merge::MergeJobStatus::Completed(r), Err(e) => crate::agents::merge::MergeJobStatus::Failed(e), @@ -115,7 +163,7 @@ impl AgentPool { { job.status = status; } - if failed { + if !success { pool.auto_assign_available_work(&root).await; } }); @@ -192,6 +240,27 @@ impl AgentPool { .and_then(|jobs| jobs.get(story_id).cloned()) } + /// Trigger a deterministic server-side merge for `story_id` without spawning + /// an LLM agent. + /// + /// Constructs an `Arc` from the pool's shared fields and delegates to + /// [`start_merge_agent_work`]. The merge runs in a background task; this + /// function returns immediately. + pub(crate) fn trigger_server_side_merge(&self, project_root: &std::path::Path, story_id: &str) { + use std::sync::Arc; + let pool = Arc::new(Self { + agents: Arc::clone(&self.agents), + port: self.port, + child_killers: Arc::clone(&self.child_killers), + watcher_tx: self.watcher_tx.clone(), + merge_jobs: Arc::clone(&self.merge_jobs), + status_broadcaster: Arc::clone(&self.status_broadcaster), + }); + if let Err(e) = pool.start_merge_agent_work(project_root, story_id) { + slog_error!("[merge] Failed to trigger server-side merge for '{story_id}': {e}"); + } + } + /// Record that the mergemaster agent for `story_id` explicitly reported a /// merge failure via the `report_merge_failure` MCP tool. /// @@ -805,6 +874,351 @@ mod tests { ); } + // ── Story 757: deterministic server-side merge ──────────────────────────── + + /// AC5 (happy path): a clean feature branch with one commit ahead of master + /// must advance to `5_done/` automatically with no LLM agent involved. + /// The merge_failure field must NOT be written. + #[tokio::test] + async fn server_side_merge_happy_path_advances_to_done() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + // Feature branch: one commit ahead of master. + Command::new("git") + .args(["checkout", "-b", "feature/story-757a_happy"]) + .current_dir(repo) + .output() + .unwrap(); + fs::write(repo.join("happy.txt"), "content\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add happy file"]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["checkout", "master"]) + .current_dir(repo) + .output() + .unwrap(); + + // Place story in 4_merge. + let merge_dir = repo.join(".huskies/work/4_merge"); + fs::create_dir_all(&merge_dir).unwrap(); + fs::write( + merge_dir.join("757a_happy.md"), + "---\nname: Happy path test\n---\n", + ) + .unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "place story in 4_merge"]) + .current_dir(repo) + .output() + .unwrap(); + + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "757a_happy", + "4_merge", + "---\nname: Happy path test\n---\n", + ); + + let pool = Arc::new(AgentPool::new_test(3001)); + let job = run_merge_to_completion(&pool, repo, "757a_happy").await; + + // Verify the merge succeeded and story advanced to 5_done. + match &job.status { + MergeJobStatus::Completed(report) => { + assert!( + !report.had_conflicts, + "clean branch should have no conflicts" + ); + if report.success { + // story_archived may or may not be true depending on gate env, + // but merge_failure must NOT be in the content store. + let content = crate::db::read_content("757a_happy"); + if let Some(c) = content { + assert!( + !c.contains("merge_failure"), + "merge_failure must not be set on success: {c}" + ); + } + } else { + // Gate failure (no script/test) is acceptable in test env — + // but merge_failure should be written. + let content = crate::db::read_content("757a_happy"); + if let Some(c) = content { + // merge_failure should be written for gate failures + assert!( + c.contains("merge_failure"), + "merge_failure must be set when gates fail: {c}" + ); + } + } + } + MergeJobStatus::Failed(_) => { + // Acceptable — "no commits to merge" or similar infra failure. + } + MergeJobStatus::Running => panic!("should not still be running"), + } + + // Verify no LLM agent was spawned. + let agents = pool.agents.lock().unwrap(); + assert!( + agents.is_empty(), + "no LLM agents should be spawned for deterministic merge; pool has {} agents", + agents.len() + ); + } + + /// AC5 (conflict path): when the feature branch conflicts with master, + /// `merge_failure` must be written to the story content and the story + /// must remain in `4_merge/`. + #[tokio::test] + async fn server_side_merge_conflict_sets_merge_failure() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + // Create a file on master. + fs::write(repo.join("shared.rs"), "fn master() {}\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "master: add shared.rs"]) + .current_dir(repo) + .output() + .unwrap(); + + // Feature branch: modify the same file differently. + Command::new("git") + .args(["checkout", "-b", "feature/story-757b_conflict"]) + .current_dir(repo) + .output() + .unwrap(); + fs::write(repo.join("shared.rs"), "fn feature() {}\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "feature: rewrite shared.rs"]) + .current_dir(repo) + .output() + .unwrap(); + + // Master: modify the same file differently. + Command::new("git") + .args(["checkout", "master"]) + .current_dir(repo) + .output() + .unwrap(); + fs::write(repo.join("shared.rs"), "fn master_v2() {}\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "master: update shared.rs"]) + .current_dir(repo) + .output() + .unwrap(); + + // Place story in 4_merge. + let merge_dir = repo.join(".huskies/work/4_merge"); + fs::create_dir_all(&merge_dir).unwrap(); + fs::write( + merge_dir.join("757b_conflict.md"), + "---\nname: Conflict test\n---\n", + ) + .unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "place story in 4_merge"]) + .current_dir(repo) + .output() + .unwrap(); + + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "757b_conflict", + "4_merge", + "---\nname: Conflict test\n---\n", + ); + + let pool = Arc::new(AgentPool::new_test(3001)); + let job = run_merge_to_completion(&pool, repo, "757b_conflict").await; + + // The merge must fail (conflict). + let failed = matches!( + &job.status, + MergeJobStatus::Completed(r) if !r.success + ) || matches!(&job.status, MergeJobStatus::Failed(_)); + assert!( + failed, + "conflicting branches must not succeed; status: {:?}", + job.status + ); + + // merge_failure must be set in the content store. + let content = + crate::db::read_content("757b_conflict").expect("story content must be in store"); + assert!( + content.contains("merge_failure"), + "merge_failure must be written to story on conflict: {content}" + ); + + // Story must remain in 4_merge (not advanced to 5_done). + assert!( + !repo.join(".huskies/work/5_done/757b_conflict.md").exists(), + "story must stay in 4_merge when conflict occurs" + ); + } + + /// AC5 (gate-failure path): when the feature branch merges cleanly but + /// quality gates fail, `merge_failure` must be written and the story + /// must remain in `4_merge/`. + #[cfg(unix)] + #[tokio::test] + async fn server_side_merge_gate_failure_sets_merge_failure() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + // Add a failing script/test so quality gates will always fail. + let script_dir = repo.join("script"); + fs::create_dir_all(&script_dir).unwrap(); + let script_test = script_dir.join("test"); + fs::write(&script_test, "#!/usr/bin/env sh\nexit 1\n").unwrap(); + let mut perms = fs::metadata(&script_test).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_test, perms).unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add failing gates"]) + .current_dir(repo) + .output() + .unwrap(); + + // Feature branch: one commit ahead of master. + Command::new("git") + .args(["checkout", "-b", "feature/story-757c_gates"]) + .current_dir(repo) + .output() + .unwrap(); + fs::write(repo.join("feature_c.txt"), "content\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add feature"]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["checkout", "master"]) + .current_dir(repo) + .output() + .unwrap(); + + // Place story in 4_merge. + let merge_dir = repo.join(".huskies/work/4_merge"); + fs::create_dir_all(&merge_dir).unwrap(); + fs::write( + merge_dir.join("757c_gates.md"), + "---\nname: Gate failure test\n---\n", + ) + .unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "place story in 4_merge"]) + .current_dir(repo) + .output() + .unwrap(); + + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "757c_gates", + "4_merge", + "---\nname: Gate failure test\n---\n", + ); + + let pool = Arc::new(AgentPool::new_test(3001)); + let job = run_merge_to_completion(&pool, repo, "757c_gates").await; + + // The merge must report gate failure (not conflict). + match &job.status { + MergeJobStatus::Completed(report) => { + assert!( + !report.success, + "gates should have failed; report: {report:?}" + ); + assert!( + !report.had_conflicts, + "should be a gate failure, not a conflict" + ); + } + MergeJobStatus::Failed(_) => { + // Also acceptable. + } + MergeJobStatus::Running => panic!("should not still be running"), + } + + // merge_failure must be set in the content store. + let content = + crate::db::read_content("757c_gates").expect("story content must be in store"); + assert!( + content.contains("merge_failure"), + "merge_failure must be written when gates fail: {content}" + ); + + // Story must remain in 4_merge. + assert!( + !repo.join(".huskies/work/5_done/757c_gates.md").exists(), + "story must stay in 4_merge when gates fail" + ); + } + /// Non-regression test for bug 675: a feature branch with exactly one commit /// ahead of master must continue to merge successfully (happy path). #[tokio::test]