huskies: merge 998

This commit is contained in:
dave
2026-05-13 19:33:33 +00:00
parent 75dc1fc15a
commit bbdee1239b
9 changed files with 427 additions and 586 deletions
@@ -509,252 +509,6 @@ 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::crdt_state::init_for_test();
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9860_story_conflict",
"4_merge_failure",
"CONFLICT (content): server/src/lib.rs",
crate::db::ItemMeta::named("Conflict"),
);
// After master c228ae16, has_content_conflict_failure reads from
// {story_id}:gate_output (not the story description), so seed it there.
crate::db::write_content(
crate::db::ContentKey::GateOutput("9860_story_conflict"),
"CONFLICT (content): server/src/lib.rs",
);
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::crdt_state::init_for_test();
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9861_story_nothing",
"4_merge_failure",
"nothing to commit, working tree clean",
crate::db::ItemMeta::named("Nothing"),
);
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 in 4_merge with blocked: true must NOT auto-spawn mergemaster
/// even when it has an unresolved content-conflict merge_failure and
/// mergemaster_attempted is still false.
#[tokio::test]
async fn auto_assign_does_not_spawn_mergemaster_for_blocked_story() {
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();
// Story 945: "blocked AND in 4_merge" is no longer representable as
// separate states. A blocked story lives in `Stage::Blocked` (which
// maps to wire-form "blocked"), so auto-assign won't see it in 4_merge.
crate::db::write_item_with_content(
"9863_story_blocked_conflict",
"blocked",
"CONFLICT (content): foo.rs",
crate::db::ItemMeta {
name: Some("Blocked conflict".to_string()),
..Default::default()
},
);
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("9863_story_blocked_conflict")
&& a.agent_name == "mergemaster"
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
});
assert!(
!mergemaster_spawned,
"mergemaster must not be spawned for a blocked story"
);
}
/// 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();
// Story 945: "mergemaster attempted" is now `Stage::MergeFailureFinal`.
crate::db::write_item_with_content(
"9862_story_attempted",
"merge_failure_final",
"CONFLICT (content): foo.rs",
crate::db::ItemMeta::named("Already tried"),
);
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"
);
}
// ── Story 920: transient vs genuine mergemaster termination ──────────────
/// AC4 (transient): a mergemaster that was killed transiently (no
/// report_merge_failure, spawn count below cap) must be re-spawned by the
/// next auto-assign pass — `mergemaster_attempted` stays false.
#[tokio::test]
async fn transient_mergemaster_exit_allows_respawn() {
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::crdt_state::init_for_test();
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"920_story_transient",
"4_merge_failure",
"CONFLICT (content): foo.rs",
crate::db::ItemMeta::named("Transient"),
);
// After master c228ae16, has_content_conflict_failure reads from
// {story_id}:gate_output (not the story description), so seed it there.
crate::db::write_content(
crate::db::ContentKey::GateOutput("920_story_transient"),
"CONFLICT (content): foo.rs",
);
// Simulate two previous transient exits (below cap of 3) recorded in DB.
crate::db::write_content(
crate::db::ContentKey::MergeMasterSpawnCount("920_story_transient"),
"2",
);
// mergemaster_attempted must still be false (transient exits don't set it).
let pool = AgentPool::new_test(3001);
pool.auto_assign_available_work(tmp.path()).await;
let agents = pool.agents.lock().unwrap();
let respawned = agents.iter().any(|(key, a)| {
key.contains("920_story_transient")
&& a.agent_name == "mergemaster"
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
});
assert!(
respawned,
"mergemaster must re-spawn after transient terminations while below cap"
);
}
/// AC4 (genuine): after report_merge_failure, mergemaster_attempted is set
/// to true and auto-assign must not trigger another re-spawn.
#[tokio::test]
async fn genuine_mergemaster_exit_no_respawn() {
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::crdt_state::init_for_test();
crate::db::ensure_content_store();
// Story 945: the genuine give-up state is now `Stage::MergeFailureFinal`.
crate::db::write_item_with_content(
"920_story_genuine",
"merge_failure_final",
"CONFLICT (content): bar.rs",
crate::db::ItemMeta::named("Genuine"),
);
let pool = AgentPool::new_test(3001);
pool.auto_assign_available_work(tmp.path()).await;
let agents = pool.agents.lock().unwrap();
let spawned = agents.iter().any(|(key, a)| {
key.contains("920_story_genuine")
&& a.agent_name == "mergemaster"
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
});
assert!(
!spawned,
"mergemaster must not re-spawn after genuine give-up (mergemaster_attempted=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.
@@ -814,159 +568,4 @@ mod tests {
found {active_coder_count} active entries"
);
}
// ── Story 958: MergeFailure transition fires auto-assign via watcher bridge ─
/// Regression: before story 958, the auto-assign subscriber filtered events
/// with `is_active()`, which returned false for `MergeFailure`. This meant
/// a CRDT `MergeFailure` transition never triggered auto-assign, and
/// mergemaster was never auto-spawned on content conflicts.
///
/// After story 958, the subscriber fires on EVERY WorkItem event. This
/// test verifies the end-to-end path: a WorkItem event with stage
/// `merge_failure` arriving on the watcher channel causes
/// `auto_assign_available_work` to run, which then auto-spawns mergemaster.
#[tokio::test]
async fn merge_failure_watcher_event_triggers_mergemaster_spawn() {
use std::sync::Arc;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let sk = root.join(".huskies");
std::fs::create_dir_all(&sk).unwrap();
std::fs::write(
sk.join("project.toml"),
"[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
)
.unwrap();
// The spawn path calls `git worktree add` — the tempdir must be a real
// git repo with at least one commit or it fails with "not a git repo".
for args in [
&["init"][..],
&["config", "user.email", "test@test.com"],
&["config", "user.name", "Test"],
&["commit", "--allow-empty", "-m", "init"],
] {
std::process::Command::new("git")
.args(args)
.current_dir(&root)
.output()
.unwrap();
}
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"958_regression_conflict",
"4_merge_failure",
"CONFLICT (content): server/src/lib.rs",
crate::db::ItemMeta::named("Regression"),
);
crate::db::write_content(
crate::db::ContentKey::GateOutput("958_regression_conflict"),
"CONFLICT (content): server/src/lib.rs",
);
let (watcher_tx, _) = broadcast::channel::<crate::io::watcher::WatcherEvent>(16);
let pool = Arc::new(AgentPool::new(3102, watcher_tx.clone()));
crate::startup::tick_loop::spawn_event_bridges(
watcher_tx.clone(),
Some(root.clone()),
Arc::clone(&pool),
);
// Simulate the CRDT bridge forwarding a merge_failure stage transition.
let _ = watcher_tx.send(crate::io::watcher::WatcherEvent::WorkItem {
stage: "merge_failure".to_string(),
item_id: "958_regression_conflict".to_string(),
action: "update".to_string(),
commit_msg: "huskies: update 958_regression_conflict".to_string(),
from_stage: Some("merge".to_string()),
});
// Allow the subscriber task to run auto_assign_available_work.
tokio::task::yield_now().await;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let agents = pool.agents.lock().unwrap();
let mergemaster_spawned = agents.iter().any(|(key, a)| {
key.contains("958_regression_conflict")
&& a.agent_name == "mergemaster"
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
});
assert!(
mergemaster_spawned,
"mergemaster must be auto-spawned when a merge_failure event fires \
through the watcher bridge (story 958 regression)"
);
}
/// AC5 (story 982): a merge failure with content conflicts — seeded via the
/// typed `transition_to_merge_failure(ConflictDetected)` path without any
/// direct content-store or MergeJob writes in the test — produces
/// `Stage::MergeFailure { kind: ConflictDetected(_), .. }` and
/// auto-spawn-mergemaster fires within one `auto_assign_available_work` call.
#[tokio::test]
async fn auto_spawn_mergemaster_for_conflict_detected_kind_without_content_store_writes() {
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::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "982_ac5_conflict_auto_spawn";
// Seed at Merge stage so the transition is valid.
crate::db::write_item_with_content(
story_id,
"4_merge",
"---\nname: AC5 auto-spawn test\n---\n",
crate::db::ItemMeta::named("AC5 auto-spawn test"),
);
// Transition to MergeFailure(ConflictDetected) via lifecycle — no direct
// content-store writes in this test body.
crate::agents::lifecycle::transition_to_merge_failure(
story_id,
crate::pipeline_state::MergeFailureKind::ConflictDetected(Some(
"CONFLICT (content): server/src/lib.rs".to_string(),
)),
)
.expect("transition to MergeFailure(ConflictDetected) should succeed");
// Verify the stage kind before triggering auto-assign.
let item = crate::pipeline_state::read_typed(story_id)
.unwrap()
.unwrap();
assert!(
matches!(
item.stage,
crate::pipeline_state::Stage::MergeFailure {
kind: crate::pipeline_state::MergeFailureKind::ConflictDetected(_),
..
}
),
"stage must be MergeFailure(ConflictDetected) before auto-assign: {:?}",
item.stage
);
// One auto-assign cycle should spawn mergemaster.
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(story_id)
&& a.agent_name == "mergemaster"
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
});
assert!(
mergemaster_spawned,
"mergemaster must be auto-spawned for ConflictDetected kind in one auto-assign cycle"
);
}
}