Files
huskies/server/src/agents/pool/pipeline/merge.rs
T
dave 2a77f73ba4 fix(merge): use server-start-time, not pid, for stale-merge detection
The merge_jobs cleanup encoded the server's pid in the CRDT and checked
`kill(pid, 0)` to decide whether a "running" entry was stale. Two problems:

  1. The cleanup runs *inside* the server, so checking whether the
     server's own pid is alive is tautological — kill(self_pid, 0)
     always succeeds.
  2. `rebuild_and_restart` does an `execve()` re-exec, which keeps the
     same pid. After re-exec, merge_jobs from the previous server
     instance still encode "the current pid" — so the cleanup never
     fires, and stories like 799/800 sit forever with status="running"
     while no actual merge runs.

Switch to a per-process server-start-time captured lazily in a
`OnceLock<f64>` (reset by execve, so the new instance sees a fresh
boot-time). A merge_job's recorded start-time < current boot-time means
it came from a previous instance: stale, delete it.

Legacy pid-encoded entries decode to None and are also treated as stale.

MergeJob.pid → MergeJob.server_start_time. Tests updated.
2026-04-28 20:41:32 +00:00

1350 lines
49 KiB
Rust

//! Pipeline merge step — orchestrates the merge-to-master flow for completed stories.
use crate::slog;
use crate::slog_error;
use crate::slog_warn;
use crate::worktree;
use std::path::Path;
use std::sync::Arc;
use super::super::super::PipelineStage;
use super::super::super::pipeline_stage;
use super::super::AgentPool;
/// Returns `true` if the process with the given PID is currently alive.
///
/// On Unix this sends signal 0 to the PID (no actual signal delivered, but
/// the kernel validates whether the process exists and is reachable).
/// Returns `false` for any error, including ESRCH (no such process).
/// Wall-clock time captured the first time this server process touches the
/// merge subsystem. Used to detect merge_jobs left over from a previous
/// server instance: a re-exec on `rebuild_and_restart` keeps the same PID,
/// so PID alone cannot distinguish "current" vs "previous" server. This
/// timestamp is fresh per-process (the static is reset by execve) and is
/// the source of truth for stale-merge detection.
static SERVER_START_TIME: std::sync::OnceLock<f64> = std::sync::OnceLock::new();
/// Return this server process's start time (lazily captured on first call).
pub(crate) fn server_start_time() -> f64 {
*SERVER_START_TIME.get_or_init(unix_now)
}
/// Encode the current server's start-time into the CRDT `error` field for
/// a Running merge job.
fn encode_server_start_time(t: f64) -> String {
format!("{{\"server_start\":{t}}}")
}
/// Decode the server-start-time from a Running merge job's `error` field.
/// Returns `None` for legacy entries (which encoded `pid` instead) — those
/// are treated as stale by the cleanup pass.
fn decode_server_start_time(error: Option<&str>) -> Option<f64> {
error
.and_then(|e| serde_json::from_str::<serde_json::Value>(e).ok())
.and_then(|v| v["server_start"].as_f64())
}
/// Current Unix timestamp in seconds as `f64`.
fn unix_now() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
impl AgentPool {
/// Start the merge pipeline as a background task.
///
/// Returns immediately so the MCP tool call doesn't time out (the full
/// pipeline — squash merge + quality gates — takes well over 60 seconds,
/// exceeding Claude Code's MCP tool-call timeout).
///
/// The mergemaster agent should poll [`get_merge_status`](Self::get_merge_status)
/// until the job reaches a terminal state.
pub fn start_merge_agent_work(
self: &Arc<Self>,
project_root: &Path,
story_id: &str,
) -> Result<(), String> {
// Sweep stale Running entries left behind by dead processes before
// applying the double-start guard. This handles the case where the
// server crashed mid-merge: the next attempt finds a Running entry
// whose owning process is gone and clears it automatically.
if let Some(jobs) = crate::crdt_state::read_all_merge_jobs() {
let current_boot = server_start_time();
for job in jobs {
if job.status != "running" {
continue;
}
let stale = match decode_server_start_time(job.error.as_deref()) {
Some(t) => t < current_boot,
None => true, // Legacy (pid-encoded) or malformed: stale
};
if stale {
slog!(
"[merge] Cleared stale Running merge job for '{}' (server restarted)",
job.story_id
);
crate::crdt_state::delete_merge_job(&job.story_id);
}
}
}
// Guard against double-starts; clear any completed/failed entry so the
// caller can retry without needing to call a separate cleanup step.
if let Some(job) = crate::crdt_state::read_merge_job(story_id) {
match job.status.as_str() {
"running" => {
return Err(format!(
"Merge already in progress for '{story_id}'. \
Use get_merge_status to poll for completion."
));
}
// Completed or Failed: clear stale entry so we can start fresh.
_ => {
crate::crdt_state::delete_merge_job(story_id);
}
}
}
// Insert Running job into CRDT.
let started_at = unix_now();
crate::crdt_state::write_merge_job(
story_id,
"running",
started_at,
None,
Some(&encode_server_start_time(server_start_time())),
);
let pool = Arc::clone(self);
let root = project_root.to_path_buf();
let sid = story_id.to_string();
tokio::spawn(async move {
let report = pool.run_merge_pipeline(&root, &sid).await;
let success = matches!(&report, Ok(r) if r.success);
let finished_at = unix_now();
// 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,
});
}
}
// Update CRDT with terminal status.
match &report {
Ok(r) => {
let report_json = serde_json::to_string(r).unwrap_or_else(|_| String::new());
crate::crdt_state::write_merge_job(
&sid,
"completed",
started_at,
Some(finished_at),
Some(&report_json),
);
}
Err(e) => {
crate::crdt_state::write_merge_job(
&sid,
"failed",
started_at,
Some(finished_at),
Some(e),
);
}
}
if !success {
pool.auto_assign_available_work(&root).await;
}
});
Ok(())
}
/// The actual merge pipeline, run inside a background task.
async fn run_merge_pipeline(
self: &Arc<Self>,
project_root: &Path,
story_id: &str,
) -> Result<crate::agents::merge::MergeReport, String> {
let branch = format!("feature/story-{story_id}");
let wt_path = worktree::worktree_path(project_root, story_id);
let root = project_root.to_path_buf();
let sid = story_id.to_string();
let br = branch.clone();
let merge_result = tokio::task::spawn_blocking(move || {
crate::agents::merge::run_squash_merge(&root, &br, &sid)
})
.await
.map_err(|e| format!("Merge task panicked: {e}"))??;
if !merge_result.success {
return Ok(crate::agents::merge::MergeReport {
story_id: story_id.to_string(),
success: false,
had_conflicts: merge_result.had_conflicts,
conflicts_resolved: merge_result.conflicts_resolved,
conflict_details: merge_result.conflict_details,
gates_passed: merge_result.gates_passed,
gate_output: merge_result.output,
worktree_cleaned_up: false,
story_archived: false,
});
}
let story_archived = crate::agents::lifecycle::move_story_to_done(story_id).is_ok();
if story_archived {
self.remove_agents_for_story(story_id);
}
let worktree_cleaned_up = if wt_path.exists() {
let config = crate::config::ProjectConfig::load(project_root).unwrap_or_default();
worktree::remove_worktree_by_story_id(project_root, story_id, &config)
.await
.is_ok()
} else {
false
};
self.auto_assign_available_work(project_root).await;
Ok(crate::agents::merge::MergeReport {
story_id: story_id.to_string(),
success: true,
had_conflicts: merge_result.had_conflicts,
conflicts_resolved: merge_result.conflicts_resolved,
conflict_details: merge_result.conflict_details,
gates_passed: true,
gate_output: merge_result.output,
worktree_cleaned_up,
story_archived,
})
}
/// Check the status of a background merge job.
///
/// Reads from the CRDT `merge_jobs` collection and reconstructs the full
/// [`MergeJob`] struct. The CRDT `error` field encodes the `pid` for
/// Running jobs (as `{"pid":N}`) and the serialised [`MergeReport`] for
/// Completed jobs.
pub fn get_merge_status(&self, story_id: &str) -> Option<crate::agents::merge::MergeJob> {
let view = crate::crdt_state::read_merge_job(story_id)?;
let (status, server_start_time) = match view.status.as_str() {
"running" => {
let t = decode_server_start_time(view.error.as_deref()).unwrap_or(0.0);
(crate::agents::merge::MergeJobStatus::Running, t)
}
"completed" => {
let report = view
.error
.as_deref()
.and_then(|e| serde_json::from_str::<crate::agents::merge::MergeReport>(e).ok())
.unwrap_or_else(|| crate::agents::merge::MergeReport {
story_id: story_id.to_string(),
success: false,
had_conflicts: false,
conflicts_resolved: false,
conflict_details: None,
gates_passed: false,
gate_output: String::new(),
worktree_cleaned_up: false,
story_archived: false,
});
(crate::agents::merge::MergeJobStatus::Completed(report), 0.0)
}
_ => {
let err = view.error.unwrap_or_else(|| "Unknown error".to_string());
(crate::agents::merge::MergeJobStatus::Failed(err), 0.0)
}
};
Some(crate::agents::merge::MergeJob {
story_id: story_id.to_string(),
status,
server_start_time,
})
}
/// Trigger a deterministic server-side merge for `story_id` without spawning
/// an LLM agent.
///
/// Constructs an `Arc<Self>` 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(),
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.
///
/// 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}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::super::super::AgentPool;
use super::*;
use crate::agents::merge::{MergeJob, MergeJobStatus};
use std::process::Command;
fn init_git_repo(repo: &std::path::Path) {
Command::new("git")
.args(["init"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(repo)
.output()
.unwrap();
}
// ── bug 498: stale Running job blocks retry ───────────────────────────────
/// Regression test for bug 498: a Running merge job left behind by a killed
/// mergemaster must not block the next call to start_merge_agent_work.
///
/// Before the fix: start_merge_agent_work would return "Merge already in
/// progress" when a Running entry existed, even after the mergemaster died.
/// After the fix: the entry is cleared when the mergemaster exits, so a new
/// call succeeds.
#[tokio::test]
async fn stale_running_merge_job_is_cleared_and_retry_succeeds() {
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let pool = Arc::new(AgentPool::new_test(3001));
// Inject a stale Running entry via CRDT, simulating a mergemaster that
// died before the merge pipeline completed. Use the current process PID
// so the stale-lock sweep does NOT auto-remove it — this test verifies
// the double-start guard path.
crate::crdt_state::write_merge_job(
"77_story_stale",
"running",
1.0,
None,
Some(&encode_server_start_time(server_start_time())),
);
// With a stale Running entry, start_merge_agent_work must be blocked.
let blocked = pool.start_merge_agent_work(repo, "77_story_stale");
assert!(
blocked.is_err(),
"start_merge_agent_work must be blocked while Running job exists"
);
let err_msg = blocked.unwrap_err();
assert!(
err_msg.contains("already in progress"),
"unexpected error: {err_msg}"
);
// Simulate the mergemaster exit path: clear the stale Running entry.
crate::crdt_state::delete_merge_job("77_story_stale");
// After clearing, start_merge_agent_work must succeed (it will fail
// the pipeline because there's no feature branch, but it must not be
// blocked by "Merge already in progress").
let result = pool.start_merge_agent_work(repo, "77_story_stale");
assert!(
result.is_ok(),
"start_merge_agent_work must succeed after stale Running job is cleared; got: {result:?}"
);
}
// ── story 719: stale-lock recovery on new merge attempts ─────────────────
/// AC1/AC2/AC3: seeding merge_jobs with an entry whose PID is dead, then
/// triggering a new merge for a *different* story, must automatically remove
/// the stale entry (AC1/AC3) and log at INFO (AC2 — verified structurally
/// because the log path is exercised when the entry is removed).
#[cfg(unix)]
#[tokio::test]
async fn stale_merge_job_with_dead_pid_is_swept_on_new_merge_attempt() {
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let pool = Arc::new(AgentPool::new_test(3001));
// Seed CRDT merge_jobs with a Running entry whose recorded server-start
// time is older than the current server (legacy / previous instance).
crate::crdt_state::write_merge_job(
"719_stale_other",
"running",
1.0,
None,
Some(&encode_server_start_time(0.0)), // legacy/older boot — should be cleaned up
);
// Verify the entry is present before the sweep.
assert!(
crate::crdt_state::read_merge_job("719_stale_other").is_some(),
"stale entry should exist before new merge attempt"
);
// Trigger a new merge for a *different* story. The sweep runs at the
// top of start_merge_agent_work and must remove the dead-PID entry.
let _ = pool.start_merge_agent_work(repo, "719_trigger_story");
// The stale entry must have been cleared.
assert!(
crate::crdt_state::read_merge_job("719_stale_other").is_none(),
"stale entry with dead pid must be removed when a new merge attempt starts"
);
}
// ── merge_agent_work tests ────────────────────────────────────────────────
/// Helper: start a merge and poll until terminal state.
async fn run_merge_to_completion(
pool: &Arc<AgentPool>,
repo: &std::path::Path,
story_id: &str,
) -> MergeJob {
pool.start_merge_agent_work(repo, story_id).unwrap();
loop {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
if let Some(job) = pool.get_merge_status(story_id)
&& !matches!(job.status, MergeJobStatus::Running)
{
return job;
}
}
}
#[tokio::test]
async fn merge_agent_work_returns_error_when_branch_not_found() {
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let pool = Arc::new(AgentPool::new_test(3001));
let job = run_merge_to_completion(&pool, repo, "99_nonexistent").await;
match &job.status {
MergeJobStatus::Completed(report) => {
assert!(!report.success, "should fail when branch missing");
}
MergeJobStatus::Failed(_) => {
// Also acceptable — the pipeline errored out
}
MergeJobStatus::Running => {
panic!("should not still be running");
}
}
}
#[tokio::test]
async fn merge_agent_work_succeeds_on_clean_branch() {
use std::fs;
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Create a feature branch with a commit
Command::new("git")
.args(["checkout", "-b", "feature/story-23_test"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("feature.txt"), "feature content").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add feature"])
.current_dir(repo)
.output()
.unwrap();
// Switch back to master (initial branch)
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
// Create the story file in 4_merge/ so we can test archival
let merge_dir = repo.join(".huskies/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap();
let story_file = merge_dir.join("23_test.md");
fs::write(&story_file, "---\nname: Test\n---\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add story in merge"])
.current_dir(repo)
.output()
.unwrap();
let pool = Arc::new(AgentPool::new_test(3001));
let job = run_merge_to_completion(&pool, repo, "23_test").await;
match &job.status {
MergeJobStatus::Completed(report) => {
assert!(!report.had_conflicts, "should have no conflicts");
assert!(
report.success
|| report.gate_output.contains("Failed to run")
|| !report.gates_passed,
"report should be coherent: {report:?}"
);
if report.story_archived {
let done = repo.join(".huskies/work/5_done/23_test.md");
assert!(done.exists(), "done file should exist");
}
}
MergeJobStatus::Failed(e) => {
// Gate failures are acceptable in test env
assert!(
e.contains("Failed") || e.contains("failed"),
"unexpected failure: {e}"
);
}
MergeJobStatus::Running => panic!("should not still be running"),
}
}
// ── quality gate ordering test ────────────────────────────────
/// Regression test for bug 142: quality gates must run BEFORE the fast-forward
/// to master so that broken code never lands on master.
#[cfg(unix)]
#[test]
fn quality_gates_run_before_fast_forward_to_master() {
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 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 bash\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 script/test"])
.current_dir(repo)
.output()
.unwrap();
// Create a feature branch with a commit.
Command::new("git")
.args(["checkout", "-b", "feature/story-142_test"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("change.txt"), "feature change").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "feature work"])
.current_dir(repo)
.output()
.unwrap();
// Switch back to master and record its HEAD.
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
let head_before = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
// Run the squash-merge. The failing script/test makes quality gates
// fail → fast-forward must NOT happen.
let result =
crate::agents::merge::run_squash_merge(repo, "feature/story-142_test", "142_test")
.unwrap();
let head_after = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
// Gates must have failed (script/test exits 1) so master should be untouched.
assert!(
!result.success,
"run_squash_merge must report failure when gates fail"
);
assert_eq!(
head_before, head_after,
"master HEAD must not advance when quality gates fail (bug 142)"
);
}
#[tokio::test]
async fn merge_agent_work_conflict_does_not_break_master() {
use std::fs;
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Create a file on master.
fs::write(
repo.join("code.rs"),
"fn main() {\n println!(\"hello\");\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial code"])
.current_dir(repo)
.output()
.unwrap();
// Feature branch: modify the same line differently.
Command::new("git")
.args(["checkout", "-b", "feature/story-42_story_foo"])
.current_dir(repo)
.output()
.unwrap();
fs::write(
repo.join("code.rs"),
"fn main() {\n println!(\"hello\");\n feature_fn();\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "feature: add fn call"])
.current_dir(repo)
.output()
.unwrap();
// Master: add different line at same location.
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
fs::write(
repo.join("code.rs"),
"fn main() {\n println!(\"hello\");\n master_fn();\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "master: add fn call"])
.current_dir(repo)
.output()
.unwrap();
// Create story file in 4_merge.
let merge_dir = repo.join(".huskies/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap();
fs::write(merge_dir.join("42_story_foo.md"), "---\nname: Test\n---\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add story"])
.current_dir(repo)
.output()
.unwrap();
let pool = Arc::new(AgentPool::new_test(3001));
let job = run_merge_to_completion(&pool, repo, "42_story_foo").await;
// Master should NEVER have conflict markers, regardless of merge outcome.
let master_code = fs::read_to_string(repo.join("code.rs")).unwrap();
assert!(
!master_code.contains("<<<<<<<"),
"master must never contain conflict markers:\n{master_code}"
);
assert!(
!master_code.contains(">>>>>>>"),
"master must never contain conflict markers:\n{master_code}"
);
// The report should accurately reflect what happened.
match &job.status {
MergeJobStatus::Completed(report) => {
assert!(report.had_conflicts, "should report conflicts");
}
MergeJobStatus::Failed(_) => {
// Acceptable — merge aborted due to conflicts
}
MergeJobStatus::Running => panic!("should not still be running"),
}
}
// ── bug 675: zero commits ahead must fail with "no commits to merge" ─────
/// Regression test for bug 675: when the feature branch has zero commits
/// ahead of master the pipeline must fail with a clear "no commits to merge"
/// error and the story must remain in `4_merge` (not advance to `5_done`).
#[tokio::test]
async fn merge_agent_work_zero_commits_ahead_stays_in_merge_stage() {
use std::fs;
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Feature branch is created at the same commit as master — zero commits ahead.
Command::new("git")
.args(["checkout", "-b", "feature/story-675_zero_commits"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
// Place the story file in 4_merge so we can verify it stays there.
let merge_dir = repo.join(".huskies/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap();
fs::write(
merge_dir.join("675_zero_commits.md"),
"---\nname: Zero commits 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();
let pool = Arc::new(AgentPool::new_test(3001));
let job = run_merge_to_completion(&pool, repo, "675_zero_commits").await;
// The job must have failed with a "no commits to merge" error.
match &job.status {
MergeJobStatus::Failed(e) => {
assert!(
e.contains("no commits to merge"),
"error must contain 'no commits to merge', got: {e}"
);
assert!(
e.contains("675_zero_commits"),
"error must name the story_id, got: {e}"
);
}
MergeJobStatus::Completed(report) => {
panic!(
"expected Failed status, got Completed with success={}: {}",
report.success, report.gate_output
);
}
MergeJobStatus::Running => panic!("should not still be running"),
}
// Story file must still be in 4_merge — NOT advanced to 5_done.
assert!(
merge_dir.join("675_zero_commits.md").exists(),
"story file must remain in 4_merge when merge fails"
);
assert!(
!repo
.join(".huskies/work/5_done/675_zero_commits.md")
.exists(),
"story must NOT advance to 5_done when merge fails with no commits"
);
}
// ── 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;
crate::crdt_state::init_for_test();
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;
crate::crdt_state::init_for_test();
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;
crate::crdt_state::init_for_test();
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]
async fn merge_agent_work_one_commit_ahead_merges_successfully() {
use std::fs;
use tempfile::tempdir;
crate::crdt_state::init_for_test();
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-675_one_commit"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("feature_675.txt"), "feature content\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add feature file"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
// Place the story file in 4_merge.
let merge_dir = repo.join(".huskies/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap();
fs::write(
merge_dir.join("675_one_commit.md"),
"---\nname: One commit 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();
let pool = Arc::new(AgentPool::new_test(3001));
let job = run_merge_to_completion(&pool, repo, "675_one_commit").await;
// The merge must not fail with "no commits to merge".
match &job.status {
MergeJobStatus::Failed(e) => {
assert!(
!e.contains("no commits to merge"),
"one-commit-ahead branch must NOT fail with 'no commits to merge': {e}"
);
// Gate failures (no script/test) are acceptable in test env.
}
MergeJobStatus::Completed(report) => {
// Success or gate failure — both acceptable; the key invariant is
// that we didn't fail with the zero-commits early-exit.
assert!(
report.success || !report.gates_passed,
"unexpected state: success={} gates_passed={}",
report.success,
report.gates_passed
);
}
MergeJobStatus::Running => panic!("should not still be running"),
}
}
}