huskies: merge 827
This commit is contained in:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user