huskies: merge 757
This commit is contained in:
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
mod conflicts;
|
|
||||||
mod squash;
|
mod squash;
|
||||||
|
|
||||||
pub(crate) use squash::run_squash_merge;
|
pub(crate) use squash::run_squash_merge;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use std::process::Command;
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use super::super::gates::run_project_tests;
|
use super::super::gates::run_project_tests;
|
||||||
use super::conflicts::try_resolve_conflicts;
|
|
||||||
use super::{MergeReport, SquashMergeResult};
|
use super::{MergeReport, SquashMergeResult};
|
||||||
use crate::config::ProjectConfig;
|
use crate::config::ProjectConfig;
|
||||||
|
|
||||||
@@ -107,55 +106,28 @@ pub(crate) fn run_squash_merge(
|
|||||||
all_output.push_str(&merge_stderr);
|
all_output.push_str(&merge_stderr);
|
||||||
all_output.push('\n');
|
all_output.push('\n');
|
||||||
|
|
||||||
let mut had_conflicts = false;
|
let conflicts_resolved = false;
|
||||||
let mut conflicts_resolved = false;
|
|
||||||
let mut conflict_details: Option<String> = None;
|
let mut conflict_details: Option<String> = None;
|
||||||
|
|
||||||
if !merge.status.success() {
|
if !merge.status.success() {
|
||||||
had_conflicts = true;
|
all_output.push_str(
|
||||||
all_output.push_str("=== Conflicts detected, attempting auto-resolution ===\n");
|
"=== Conflicts detected — aborting merge. Use `start_agent mergemaster` \
|
||||||
|
to invoke LLM-driven conflict resolution. ===\n",
|
||||||
// Try to automatically resolve simple conflicts.
|
);
|
||||||
match try_resolve_conflicts(&merge_wt_path) {
|
let details =
|
||||||
Ok((resolved, resolution_log)) => {
|
format!("Merge conflicts in branch '{branch}':\n{merge_stdout}{merge_stderr}");
|
||||||
all_output.push_str(&resolution_log);
|
conflict_details = Some(details);
|
||||||
if resolved {
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
conflicts_resolved = true;
|
return Ok(SquashMergeResult {
|
||||||
all_output.push_str("=== All conflicts resolved automatically ===\n");
|
success: false,
|
||||||
} else {
|
had_conflicts: true,
|
||||||
// Could not resolve — abort, clean up, and report.
|
conflicts_resolved,
|
||||||
let details = format!(
|
conflict_details,
|
||||||
"Merge conflicts in branch '{branch}':\n{merge_stdout}{merge_stderr}\n{resolution_log}"
|
output: all_output,
|
||||||
);
|
gates_passed: false,
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let had_conflicts = false;
|
||||||
|
|
||||||
// ── Commit in the temporary worktree ──────────────────────────
|
// ── Commit in the temporary worktree ──────────────────────────
|
||||||
all_output.push_str("=== git commit ===\n");
|
all_output.push_str("=== git commit ===\n");
|
||||||
|
|||||||
@@ -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.
|
// 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();
|
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.had_conflicts, "additive conflict should be detected");
|
||||||
assert!(
|
assert!(
|
||||||
result.conflicts_resolved,
|
!result.conflicts_resolved,
|
||||||
"additive conflict must be auto-resolved; output:\n{}",
|
"deterministic merge must NOT auto-resolve conflicts"
|
||||||
result.output
|
|
||||||
);
|
);
|
||||||
|
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();
|
let content = fs::read_to_string(repo.join("module.rs")).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
!content.contains("<<<<<<<"),
|
!content.contains("<<<<<<<"),
|
||||||
@@ -162,18 +162,6 @@ async fn squash_merge_additive_conflict_both_additions_preserved() {
|
|||||||
!content.contains(">>>>>>>"),
|
!content.contains(">>>>>>>"),
|
||||||
"master must not contain conflict markers"
|
"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.
|
// Cleanup: no leftover merge-queue branch or workspace.
|
||||||
let branches = Command::new("git")
|
let branches = Command::new("git")
|
||||||
@@ -262,25 +250,22 @@ async fn squash_merge_conflict_resolved_but_gates_fail_reported_as_failure() {
|
|||||||
.output()
|
.output()
|
||||||
.unwrap();
|
.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();
|
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.had_conflicts, "conflict must be detected");
|
||||||
assert!(
|
assert!(
|
||||||
result.conflicts_resolved,
|
!result.conflicts_resolved,
|
||||||
"additive conflict must be auto-resolved"
|
"deterministic merge must NOT auto-resolve conflicts"
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!result.gates_passed,
|
|
||||||
"quality gates must fail (script/test exits 1)"
|
|
||||||
);
|
);
|
||||||
|
// Merge is aborted at conflict detection; gates are never reached.
|
||||||
assert!(
|
assert!(
|
||||||
!result.success,
|
!result.success,
|
||||||
"merge must be reported as failed when gates fail"
|
"conflicting merge must be reported as failed"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!result.output.is_empty(),
|
!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).
|
// Master must NOT have been updated (cherry-pick was blocked by gate failure).
|
||||||
|
|||||||
@@ -83,10 +83,9 @@ impl AgentPool {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Process each active pipeline stage in order.
|
// Process each active pipeline stage in order.
|
||||||
let stages: [(&str, PipelineStage); 3] = [
|
let stages: [(&str, PipelineStage); 2] = [
|
||||||
("2_current", PipelineStage::Coder),
|
("2_current", PipelineStage::Coder),
|
||||||
("3_qa", PipelineStage::Qa),
|
("3_qa", PipelineStage::Qa),
|
||||||
("4_merge", PipelineStage::Mergemaster),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (stage_dir, stage) in &stages {
|
for (stage_dir, stage) in &stages {
|
||||||
@@ -121,58 +120,6 @@ impl AgentPool {
|
|||||||
continue;
|
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
|
// Re-acquire the lock on each iteration to see state changes
|
||||||
// from previous start_agent calls in the same pass.
|
// from previous start_agent calls in the same pass.
|
||||||
let preferred_agent =
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,8 +77,7 @@ impl AgentPool {
|
|||||||
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
|
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
self.start_mergemaster_or_block(&project_root, story_id)
|
self.trigger_server_side_merge(&project_root, story_id);
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crate::io::story_metadata::QaMode::Agent => {
|
crate::io::story_metadata::QaMode::Agent => {
|
||||||
@@ -151,8 +150,7 @@ impl AgentPool {
|
|||||||
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
|
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
self.start_mergemaster_or_block(&project_root, story_id)
|
self.trigger_server_side_merge(&project_root, story_id);
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crate::io::story_metadata::QaMode::Agent => {
|
crate::io::story_metadata::QaMode::Agent => {
|
||||||
@@ -272,8 +270,7 @@ impl AgentPool {
|
|||||||
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
|
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
self.start_mergemaster_or_block(&project_root, story_id)
|
self.trigger_server_side_merge(&project_root, story_id);
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let Some(reason) =
|
} else if let Some(reason) =
|
||||||
@@ -440,41 +437,6 @@ impl AgentPool {
|
|||||||
// become available (bug 295).
|
// become available (bug 295).
|
||||||
self.auto_assign_available_work(&project_root).await;
|
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.
|
/// Spawn pipeline advancement as a background task.
|
||||||
|
|||||||
@@ -88,16 +88,23 @@ async fn mergemaster_blocks_and_sends_story_blocked_when_no_commits_ahead() {
|
|||||||
"story should remain in content store — not removed"
|
"story should remain in content store — not removed"
|
||||||
);
|
);
|
||||||
|
|
||||||
// A StoryBlocked event must have been emitted (triggers chat failure notice,
|
// A StoryBlocked event must be emitted by the background merge task.
|
||||||
// not the success 🎉 emoji).
|
// The deterministic merge pipeline runs asynchronously, so poll with a
|
||||||
|
// timeout instead of a non-blocking try_recv().
|
||||||
let mut got_blocked = false;
|
let mut got_blocked = false;
|
||||||
while let Ok(evt) = rx.try_recv() {
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
if let WatcherEvent::StoryBlocked { story_id, .. } = &evt
|
while tokio::time::Instant::now() < deadline {
|
||||||
&& story_id == "9919_story_no_commits"
|
while let Ok(evt) = rx.try_recv() {
|
||||||
{
|
if let WatcherEvent::StoryBlocked { story_id, .. } = &evt
|
||||||
got_blocked = true;
|
&& story_id == "9919_story_no_commits"
|
||||||
|
{
|
||||||
|
got_blocked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got_blocked {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
got_blocked,
|
got_blocked,
|
||||||
|
|||||||
@@ -105,7 +105,55 @@ impl AgentPool {
|
|||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let report = pool.run_merge_pipeline(&root, &sid).await;
|
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 {
|
let status = match report {
|
||||||
Ok(r) => crate::agents::merge::MergeJobStatus::Completed(r),
|
Ok(r) => crate::agents::merge::MergeJobStatus::Completed(r),
|
||||||
Err(e) => crate::agents::merge::MergeJobStatus::Failed(e),
|
Err(e) => crate::agents::merge::MergeJobStatus::Failed(e),
|
||||||
@@ -115,7 +163,7 @@ impl AgentPool {
|
|||||||
{
|
{
|
||||||
job.status = status;
|
job.status = status;
|
||||||
}
|
}
|
||||||
if failed {
|
if !success {
|
||||||
pool.auto_assign_available_work(&root).await;
|
pool.auto_assign_available_work(&root).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -192,6 +240,27 @@ impl AgentPool {
|
|||||||
.and_then(|jobs| jobs.get(story_id).cloned())
|
.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<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(),
|
||||||
|
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
|
/// Record that the mergemaster agent for `story_id` explicitly reported a
|
||||||
/// merge failure via the `report_merge_failure` MCP tool.
|
/// 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
|
/// Non-regression test for bug 675: a feature branch with exactly one commit
|
||||||
/// ahead of master must continue to merge successfully (happy path).
|
/// ahead of master must continue to merge successfully (happy path).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
Reference in New Issue
Block a user