huskies: merge 827
This commit is contained in:
@@ -259,4 +259,4 @@ To fix:
|
||||
- NEVER manually move story files between pipeline stages
|
||||
- NEVER call accept_story — merge_agent_work handles that
|
||||
- ALWAYS call report_merge_failure if you can't fix the merge"""
|
||||
system_prompt = "You are the mergemaster agent. Call merge_agent_work to merge. If gates fail, fix the issues in the merge workspace, verify with run_lint and run_tests MCP tools, recommit, and retrigger. After 3 failed attempts, call report_merge_failure and exit. Never move story files or call accept_story."
|
||||
system_prompt = "You are the mergemaster agent. Call merge_agent_work to merge. If gates fail, fix the issues in the merge workspace, verify with run_lint and run_tests MCP tools, recommit, and retrigger. After 3 failed attempts, call report_merge_failure and exit. Never move story files or call accept_story. When resolving merge conflicts: before editing any conflicted file, use git blame and git log on the merge commit to identify the originating story IDs for each side of the conflict. Read those stories' spec files (.huskies/work/ or .huskies/specs/) to understand the intent of each change. Resolve conflicts in a way that satisfies both stories' intent, and explain the resolution in the merge commit message (cite the story IDs and why you chose the resolution you did)."
|
||||
|
||||
@@ -14,8 +14,9 @@ use super::scan::{
|
||||
is_story_assigned_for_stage, scan_stage_items,
|
||||
};
|
||||
use super::story_checks::{
|
||||
check_archived_dependencies, has_merge_failure, has_review_hold, has_unmet_dependencies,
|
||||
is_story_blocked, is_story_frozen, read_story_front_matter_agent,
|
||||
check_archived_dependencies, has_content_conflict_failure, has_merge_failure,
|
||||
has_mergemaster_attempted, has_review_hold, has_unmet_dependencies, is_story_blocked,
|
||||
is_story_frozen, read_story_front_matter_agent,
|
||||
};
|
||||
|
||||
impl AgentPool {
|
||||
@@ -246,9 +247,66 @@ impl AgentPool {
|
||||
// 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).
|
||||
// Stories with a recorded merge failure may be eligible for
|
||||
// automatic mergemaster dispatch when the failure is a content
|
||||
// conflict — otherwise they need human intervention.
|
||||
if has_merge_failure(project_root, "4_merge", story_id) {
|
||||
// Auto-spawn mergemaster for content conflicts, but only once.
|
||||
if has_content_conflict_failure(project_root, "4_merge", story_id)
|
||||
&& !has_mergemaster_attempted(project_root, "4_merge", story_id)
|
||||
&& !is_story_blocked(project_root, "4_merge", story_id)
|
||||
{
|
||||
// Find the mergemaster agent.
|
||||
let mergemaster_agent = {
|
||||
let agents = match self.agents.lock() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
slog_error!(
|
||||
"[auto-assign] Failed to lock agents for mergemaster check: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if is_story_assigned_for_stage(
|
||||
&config,
|
||||
&agents,
|
||||
story_id,
|
||||
&PipelineStage::Mergemaster,
|
||||
) {
|
||||
// Already running — don't spawn again.
|
||||
None
|
||||
} else {
|
||||
find_free_agent_for_stage(&config, &agents, &PipelineStage::Mergemaster)
|
||||
.map(str::to_string)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(agent_name) = mergemaster_agent {
|
||||
slog!(
|
||||
"[auto-assign] Content conflict on '{story_id}'; \
|
||||
auto-spawning mergemaster '{agent_name}'."
|
||||
);
|
||||
// Record mergemaster_attempted before spawning so a
|
||||
// crash/restart doesn't re-trigger an infinite loop.
|
||||
if let Some(contents) = crate::db::read_content(story_id) {
|
||||
let updated =
|
||||
crate::io::story_metadata::write_mergemaster_attempted_in_content(
|
||||
&contents,
|
||||
);
|
||||
crate::db::write_content(story_id, &updated);
|
||||
crate::db::write_item_with_content(story_id, "4_merge", &updated);
|
||||
}
|
||||
if let Err(e) = self
|
||||
.start_agent(project_root, story_id, Some(&agent_name), None, None)
|
||||
.await
|
||||
{
|
||||
slog!(
|
||||
"[auto-assign] Failed to start mergemaster '{agent_name}' \
|
||||
for '{story_id}': {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -782,6 +840,110 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Story 827: auto-spawn mergemaster on content conflict ─────────────────
|
||||
|
||||
/// A story in 4_merge with a content-conflict merge_failure and no
|
||||
/// mergemaster_attempted flag must trigger an auto-spawn of mergemaster.
|
||||
#[tokio::test]
|
||||
async fn auto_assign_spawns_mergemaster_for_content_conflict() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9860_story_conflict",
|
||||
"4_merge",
|
||||
"---\nname: Conflict\nmerge_failure: \"CONFLICT (content): server/src/lib.rs\"\n---\n",
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.auto_assign_available_work(tmp.path()).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let mergemaster_spawned = agents.iter().any(|(key, a)| {
|
||||
key.contains("9860_story_conflict")
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
});
|
||||
assert!(
|
||||
mergemaster_spawned,
|
||||
"mergemaster should be spawned for a content-conflict story"
|
||||
);
|
||||
}
|
||||
|
||||
/// A story with merge_failure containing only "nothing to commit" must NOT
|
||||
/// auto-spawn mergemaster.
|
||||
#[tokio::test]
|
||||
async fn auto_assign_does_not_spawn_mergemaster_for_non_conflict_failure() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9861_story_nothing",
|
||||
"4_merge",
|
||||
"---\nname: Nothing\nmerge_failure: \"nothing to commit, working tree clean\"\n---\n",
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.auto_assign_available_work(tmp.path()).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let mergemaster_spawned = agents.iter().any(|(key, a)| {
|
||||
key.contains("9861_story_nothing")
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
});
|
||||
assert!(
|
||||
!mergemaster_spawned,
|
||||
"mergemaster must not be spawned for non-conflict failures"
|
||||
);
|
||||
}
|
||||
|
||||
/// A story with mergemaster_attempted: true must NOT auto-spawn again, even
|
||||
/// if the merge_failure still contains a content conflict.
|
||||
#[tokio::test]
|
||||
async fn auto_assign_does_not_respawn_mergemaster_when_already_attempted() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9862_story_attempted",
|
||||
"4_merge",
|
||||
"---\nname: Already tried\nmerge_failure: \"CONFLICT (content): foo.rs\"\nmergemaster_attempted: true\n---\n",
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.auto_assign_available_work(tmp.path()).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let mergemaster_spawned = agents.iter().any(|(key, a)| {
|
||||
key.contains("9862_story_attempted")
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
});
|
||||
assert!(
|
||||
!mergemaster_spawned,
|
||||
"mergemaster must not re-spawn when mergemaster_attempted is true"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two concurrent auto_assign_available_work calls must not assign the same
|
||||
/// agent to two stories simultaneously. After both complete, at most one
|
||||
/// Pending/Running entry must exist per agent name.
|
||||
|
||||
@@ -119,6 +119,47 @@ pub(super) fn has_merge_failure(project_root: &Path, _stage_dir: &str, story_id:
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Return `true` if the story's `merge_failure` contains a git content-conflict
|
||||
/// marker (`"Merge conflict"` or `"CONFLICT (content):"`).
|
||||
///
|
||||
/// Used by the auto-assigner to decide whether to spawn mergemaster automatically.
|
||||
pub(super) fn has_content_conflict_failure(
|
||||
project_root: &Path,
|
||||
_stage_dir: &str,
|
||||
story_id: &str,
|
||||
) -> bool {
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
let contents = match read_story_contents(project_root, story_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.merge_failure)
|
||||
.map(|reason| reason.contains("Merge conflict") || reason.contains("CONFLICT (content):"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return `true` if the story has `mergemaster_attempted: true` in its front matter.
|
||||
///
|
||||
/// Used to prevent the auto-assigner from repeatedly spawning mergemaster for
|
||||
/// the same story after a failed mergemaster session.
|
||||
pub(super) fn has_mergemaster_attempted(
|
||||
project_root: &Path,
|
||||
_stage_dir: &str,
|
||||
story_id: &str,
|
||||
) -> bool {
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
let contents = match read_story_contents(project_root, story_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.mergemaster_attempted)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -69,6 +69,9 @@ pub struct StoryMetadata {
|
||||
/// Present on items created with numeric-only IDs (no slug suffix).
|
||||
/// Used by the pipeline to determine routing (e.g. spikes skip QA).
|
||||
pub item_type: Option<String>,
|
||||
/// Set to `true` when the auto-assigner has already spawned a mergemaster
|
||||
/// session for a content-conflict failure. Prevents repeated spawns.
|
||||
pub mergemaster_attempted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -110,6 +113,9 @@ struct FrontMatter {
|
||||
/// Item type: "story", "bug", "spike", or "refactor".
|
||||
#[serde(rename = "type")]
|
||||
item_type: Option<String>,
|
||||
/// Set to `true` when the auto-assigner has already spawned a mergemaster
|
||||
/// session for a content-conflict failure.
|
||||
mergemaster_attempted: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
|
||||
@@ -153,6 +159,7 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata {
|
||||
frozen: front.frozen,
|
||||
run_tests_passed: front.run_tests_passed,
|
||||
item_type: front.item_type,
|
||||
mergemaster_attempted: front.mergemaster_attempted,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,6 +504,14 @@ pub fn write_review_hold_in_content(contents: &str) -> String {
|
||||
set_front_matter_field(contents, "review_hold", "true")
|
||||
}
|
||||
|
||||
/// Write `mergemaster_attempted: true` to story content (pure function).
|
||||
///
|
||||
/// Used by the auto-assigner to record that a mergemaster session has been
|
||||
/// spawned for a content-conflict failure, preventing repeated auto-spawns.
|
||||
pub fn write_mergemaster_attempted_in_content(contents: &str) -> String {
|
||||
set_front_matter_field(contents, "mergemaster_attempted", "true")
|
||||
}
|
||||
|
||||
/// Write or update `depends_on` in story content (pure function).
|
||||
///
|
||||
/// Serialises `deps` as an inline YAML sequence, e.g. `[477, 478]`.
|
||||
|
||||
Reference in New Issue
Block a user