905 lines
36 KiB
Rust
905 lines
36 KiB
Rust
//! Auto-assign: scan pipeline stages and dispatch free agents to unassigned stories.
|
|
|
|
use std::path::Path;
|
|
|
|
use crate::config::ProjectConfig;
|
|
use crate::slog_warn;
|
|
|
|
use super::super::AgentPool;
|
|
|
|
impl AgentPool {
|
|
/// Scan all active pipeline stages and start free agents for any unassigned work.
|
|
///
|
|
/// Order of operations:
|
|
/// 1. Promote backlog stories whose `depends_on` are all satisfied.
|
|
/// 2. Assign coder and QA agents to stories in `2_current/` and `3_qa/`.
|
|
/// 3. Trigger server-side merges (or auto-spawn mergemaster) for `4_merge/`.
|
|
pub async fn auto_assign_available_work(&self, project_root: &Path) {
|
|
// Promote any backlog stories whose dependencies are all done.
|
|
self.promote_ready_backlog_stories();
|
|
|
|
let config = match ProjectConfig::load(project_root) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
slog_warn!("[auto-assign] Failed to load project config: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Process the coder (2_current/) and QA (3_qa/) stages.
|
|
self.assign_pipeline_stages(project_root, &config).await;
|
|
|
|
// Process the merge (4_merge/) stage.
|
|
self.assign_merge_stage(project_root, &config).await;
|
|
}
|
|
}
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::super::super::AgentPool;
|
|
use crate::agents::AgentStatus;
|
|
use crate::io::watcher::WatcherEvent;
|
|
use tokio::sync::broadcast;
|
|
|
|
/// Story 203: auto_assign_available_work must detect a story in 2_current/
|
|
/// with no active agent and start an agent for it.
|
|
#[tokio::test]
|
|
async fn auto_assign_picks_up_story_queued_in_current() {
|
|
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 = \"coder-1\"\nstage = \"coder\"\n",
|
|
)
|
|
.unwrap();
|
|
// Place the story in 2_current/ via CRDT (the only source of truth).
|
|
crate::db::ensure_content_store();
|
|
crate::db::write_item_with_content(
|
|
"story-3",
|
|
"2_current",
|
|
"---\nname: Story 3\n---\n",
|
|
crate::db::ItemMeta::named("Story 3"),
|
|
);
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
// No agents are running — coder-1 is free.
|
|
|
|
// auto_assign will try to call start_agent, which will attempt to create
|
|
// a worktree (will fail without a git repo) — that is fine. We only need
|
|
// to verify the agent is registered as Pending before the background
|
|
// task eventually fails.
|
|
pool.auto_assign_available_work(tmp.path()).await;
|
|
|
|
let agents = pool.agents.lock().unwrap();
|
|
let has_pending = agents.values().any(|a| {
|
|
a.agent_name == "coder-1"
|
|
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
|
});
|
|
assert!(
|
|
has_pending,
|
|
"auto_assign should have started coder-1 for story-3, but pool is empty"
|
|
);
|
|
}
|
|
|
|
/// Story 265: auto_assign_available_work must skip spikes in 3_qa/ that
|
|
/// have review_hold: true set in their front matter.
|
|
#[tokio::test]
|
|
async fn auto_assign_skips_spikes_with_review_hold() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
|
|
// Create project.toml with a QA agent.
|
|
let sk = root.join(".huskies");
|
|
std::fs::create_dir_all(&sk).unwrap();
|
|
std::fs::write(
|
|
sk.join("project.toml"),
|
|
"[[agents]]\nname = \"qa\"\nrole = \"qa\"\nmodel = \"test\"\nprompt = \"test\"\n",
|
|
)
|
|
.unwrap();
|
|
|
|
// Put a spike in 3_qa/ with review_hold: true.
|
|
let qa_dir = root.join(".huskies/work/3_qa");
|
|
std::fs::create_dir_all(&qa_dir).unwrap();
|
|
std::fs::write(
|
|
qa_dir.join("20_spike_test.md"),
|
|
"---\nname: Test Spike\nreview_hold: true\n---\n# Spike\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(4);
|
|
let pool = AgentPool::new(3001, watcher_tx);
|
|
|
|
pool.auto_assign_available_work(root).await;
|
|
|
|
// No agent should have been started for the spike.
|
|
let agents = pool.agents.lock().unwrap();
|
|
assert!(
|
|
agents.is_empty(),
|
|
"No agents should be assigned to a spike with review_hold"
|
|
);
|
|
}
|
|
|
|
// ── Story 279: auto-assign respects agent stage from front matter ──────────
|
|
|
|
/// When a story in 3_qa/ has `agent: coder-1` in its front matter but
|
|
/// coder-1 is a coder-stage agent, auto-assign must NOT assign coder-1.
|
|
/// Instead it should fall back to a free QA-stage agent.
|
|
#[tokio::test]
|
|
async fn auto_assign_ignores_coder_preference_when_story_is_in_qa_stage() {
|
|
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 = \"coder-1\"\nstage = \"coder\"\n\n\
|
|
[[agent]]\nname = \"qa-1\"\nstage = \"qa\"\n",
|
|
)
|
|
.unwrap();
|
|
// Story in 3_qa/ with a preferred coder-stage agent — write via CRDT.
|
|
crate::db::ensure_content_store();
|
|
crate::db::write_item_with_content(
|
|
"9930_story_qa1",
|
|
"3_qa",
|
|
"---\nname: QA Story\nagent: coder-1\n---\n",
|
|
crate::db::ItemMeta {
|
|
name: Some("QA Story".into()),
|
|
agent: Some("coder-1".into()),
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
pool.auto_assign_available_work(tmp.path()).await;
|
|
|
|
let agents = pool.agents.lock().unwrap();
|
|
// coder-1 must NOT have been assigned to the QA story (wrong stage).
|
|
let coder_assigned_to_qa = agents.iter().any(|(key, a)| {
|
|
key.contains("9930_story_qa1")
|
|
&& a.agent_name == "coder-1"
|
|
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
|
});
|
|
assert!(
|
|
!coder_assigned_to_qa,
|
|
"coder-1 should not be assigned to a QA-stage story"
|
|
);
|
|
// qa-1 should have been assigned instead.
|
|
let qa_assigned = agents.iter().any(|(key, a)| {
|
|
key.contains("9930_story_qa1")
|
|
&& a.agent_name == "qa-1"
|
|
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
|
});
|
|
assert!(
|
|
qa_assigned,
|
|
"qa-1 should be assigned as fallback for the QA-stage story"
|
|
);
|
|
}
|
|
|
|
/// When a story in 2_current/ has `agent: coder-1` in its front matter and
|
|
/// coder-1 is a coder-stage agent, auto-assign must respect the preference
|
|
/// and assign coder-1 (not fall back to some other coder).
|
|
#[tokio::test]
|
|
async fn auto_assign_respects_coder_preference_when_story_is_in_current_stage() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let sk = tmp.path().join(".huskies");
|
|
std::fs::create_dir_all(sk.join("work/2_current")).unwrap();
|
|
std::fs::write(
|
|
sk.join("project.toml"),
|
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
|
|
[[agent]]\nname = \"coder-2\"\nstage = \"coder\"\n",
|
|
)
|
|
.unwrap();
|
|
// Story in 2_current/ with a preferred coder-1 agent.
|
|
crate::db::ensure_content_store();
|
|
crate::db::write_item_with_content(
|
|
"story-pref",
|
|
"2_current",
|
|
"---\nname: Coder Story\nagent: coder-1\n---\n",
|
|
crate::db::ItemMeta {
|
|
name: Some("Coder Story".into()),
|
|
agent: Some("coder-1".into()),
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
pool.auto_assign_available_work(tmp.path()).await;
|
|
|
|
let agents = pool.agents.lock().unwrap();
|
|
// coder-1 should have been picked (it matches the stage and is preferred).
|
|
let coder1_assigned = agents.values().any(|a| {
|
|
a.agent_name == "coder-1"
|
|
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
|
});
|
|
assert!(
|
|
coder1_assigned,
|
|
"coder-1 should be assigned when it matches the stage and is preferred"
|
|
);
|
|
// coder-2 must NOT be assigned (not preferred).
|
|
let coder2_assigned = agents.values().any(|a| {
|
|
a.agent_name == "coder-2"
|
|
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
|
});
|
|
assert!(
|
|
!coder2_assigned,
|
|
"coder-2 should not be assigned when coder-1 is explicitly preferred"
|
|
);
|
|
}
|
|
|
|
/// When the preferred agent's stage mismatches and no other agent of the
|
|
/// correct stage is available, auto-assign must not start any agent for that
|
|
/// story (no panic, no error).
|
|
#[tokio::test]
|
|
async fn auto_assign_stage_mismatch_with_no_fallback_starts_no_agent() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let sk = tmp.path().join(".huskies");
|
|
std::fs::create_dir_all(&sk).unwrap();
|
|
// Only a coder agent is configured — no QA agent exists.
|
|
std::fs::write(
|
|
sk.join("project.toml"),
|
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
|
)
|
|
.unwrap();
|
|
// Story in 3_qa/ requests coder-1 (wrong stage) and no QA agent exists — write via CRDT.
|
|
crate::db::ensure_content_store();
|
|
crate::db::write_item_with_content(
|
|
"9931_story_noqa",
|
|
"3_qa",
|
|
"---\nname: QA Story No Agent\nagent: coder-1\n---\n",
|
|
crate::db::ItemMeta {
|
|
name: Some("QA Story No Agent".into()),
|
|
agent: Some("coder-1".into()),
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
// Must not panic.
|
|
pool.auto_assign_available_work(tmp.path()).await;
|
|
|
|
let agents = pool.agents.lock().unwrap();
|
|
// No agent should be assigned to the specific QA story (coder-1 may
|
|
// be assigned to leaked 2_current items from the global CRDT store).
|
|
let assigned_to_qa_story = agents.iter().any(|(key, a)| {
|
|
key.contains("9931_story_noqa")
|
|
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
|
});
|
|
assert!(
|
|
!assigned_to_qa_story,
|
|
"No agent should be started when no stage-appropriate agent is available"
|
|
);
|
|
}
|
|
|
|
/// Story 484: auto_assign must skip stories whose depends_on entries are not
|
|
/// yet in 5_done or 6_archived.
|
|
#[tokio::test]
|
|
async fn auto_assign_skips_stories_with_unmet_dependencies() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
let sk = root.join(".huskies");
|
|
std::fs::create_dir_all(&sk).unwrap();
|
|
std::fs::write(
|
|
sk.join("project.toml"),
|
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
|
)
|
|
.unwrap();
|
|
// Story 9932 depends on 9999 which is not done — write via CRDT.
|
|
crate::db::ensure_content_store();
|
|
crate::db::write_item_with_content(
|
|
"9932_story_waiting",
|
|
"2_current",
|
|
"# Waiting\n",
|
|
crate::db::ItemMeta::named("Waiting"),
|
|
);
|
|
crate::crdt_state::set_depends_on("9932_story_waiting", &[9999]);
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
pool.auto_assign_available_work(root).await;
|
|
|
|
let agents = pool.agents.lock().unwrap();
|
|
// Filter to only agents assigned to our specific story to avoid
|
|
// interference from other tests sharing the global CRDT store.
|
|
let assigned_to_our_story = agents.iter().any(|(key, a)| {
|
|
key.contains("9932_story_waiting")
|
|
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
|
});
|
|
assert!(
|
|
!assigned_to_our_story,
|
|
"story with unmet deps should not be auto-assigned"
|
|
);
|
|
}
|
|
|
|
/// Story 484: auto_assign must pick up a story once its dependency lands in 5_done.
|
|
#[tokio::test]
|
|
async fn auto_assign_picks_up_story_after_dep_completes() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
let sk = root.join(".huskies");
|
|
std::fs::create_dir_all(&sk).unwrap();
|
|
std::fs::write(
|
|
sk.join("project.toml"),
|
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
|
)
|
|
.unwrap();
|
|
// Seed stories via CRDT (the only source of truth).
|
|
crate::db::ensure_content_store();
|
|
// Dep 999 is now done.
|
|
crate::db::write_item_with_content(
|
|
"999_story_dep",
|
|
"5_done",
|
|
"---\nname: Dep\n---\n",
|
|
crate::db::ItemMeta::named("Dep"),
|
|
);
|
|
// Story 10 depends on 999 which is done.
|
|
crate::db::write_item_with_content(
|
|
"10_story_unblocked",
|
|
"2_current",
|
|
"# Unblocked\n",
|
|
crate::db::ItemMeta::named("Unblocked"),
|
|
);
|
|
crate::crdt_state::set_depends_on("10_story_unblocked", &[999]);
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
pool.auto_assign_available_work(root).await;
|
|
|
|
let agents = pool.agents.lock().unwrap();
|
|
let has_pending = agents.values().any(|a| {
|
|
matches!(
|
|
a.status,
|
|
crate::agents::AgentStatus::Pending | crate::agents::AgentStatus::Running
|
|
)
|
|
});
|
|
assert!(
|
|
has_pending,
|
|
"story with all deps done should be auto-assigned"
|
|
);
|
|
}
|
|
|
|
// ── Bug 497: backlog dependency promotion ───────────────────────────────
|
|
|
|
/// Stories in backlog with `depends_on` that are all in 5_done must be
|
|
/// promoted to 2_current when auto_assign_available_work runs.
|
|
#[tokio::test]
|
|
async fn auto_assign_promotes_backlog_story_when_all_deps_done() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
let sk = root.join(".huskies");
|
|
let backlog = sk.join("work/1_backlog");
|
|
let current = sk.join("work/2_current");
|
|
let done = sk.join("work/5_done");
|
|
std::fs::create_dir_all(&backlog).unwrap();
|
|
std::fs::create_dir_all(¤t).unwrap();
|
|
std::fs::create_dir_all(&done).unwrap();
|
|
std::fs::write(
|
|
sk.join("project.toml"),
|
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
|
)
|
|
.unwrap();
|
|
// Dep 1 is done.
|
|
crate::db::ensure_content_store();
|
|
let dep_content = "---\nname: Dep\n---\n";
|
|
std::fs::write(done.join("1_story_dep.md"), dep_content).unwrap();
|
|
crate::db::write_content(crate::db::ContentKey::Story("1_story_dep"), dep_content);
|
|
// Story B depends on story 1.
|
|
let story_b_content = "---\nname: B\ndepends_on: [1]\n---\n";
|
|
std::fs::write(backlog.join("2_story_b.md"), story_b_content).unwrap();
|
|
crate::db::write_content(crate::db::ContentKey::Story("2_story_b"), story_b_content);
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
pool.auto_assign_available_work(root).await;
|
|
|
|
// The lifecycle function updates the content store (not the filesystem),
|
|
// so verify the move via the DB.
|
|
let content = crate::db::read_content(crate::db::ContentKey::Story("2_story_b"))
|
|
.expect("story B should be in content store after promotion");
|
|
assert!(
|
|
content.contains("name: B"),
|
|
"story B content should be preserved after promotion"
|
|
);
|
|
}
|
|
|
|
/// Stories in backlog with unmet dependencies must NOT be promoted.
|
|
#[tokio::test]
|
|
async fn auto_assign_does_not_promote_backlog_story_with_unmet_deps() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
let sk = root.join(".huskies");
|
|
let backlog = sk.join("work/1_backlog");
|
|
std::fs::create_dir_all(&backlog).unwrap();
|
|
std::fs::write(
|
|
sk.join("project.toml"),
|
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
|
)
|
|
.unwrap();
|
|
// Dep 99 is NOT done.
|
|
std::fs::write(
|
|
backlog.join("5_story_c.md"),
|
|
"---\nname: C\ndepends_on: [99]\n---\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
pool.auto_assign_available_work(root).await;
|
|
|
|
assert!(
|
|
backlog.join("5_story_c.md").exists(),
|
|
"story C should stay in 1_backlog/ when dep 99 is not done"
|
|
);
|
|
}
|
|
|
|
// ── Bug 503: archived-dep promotion visibility ─────────────────────────────
|
|
|
|
/// A backlog story whose dep is in 6_archived must still be promoted
|
|
/// (archived = satisfied), but the promotion must not silently skip the warning
|
|
/// path. This test verifies the promotion itself fires; the warning is a
|
|
/// slog_warn! side-effect that we can't easily assert on in unit tests.
|
|
#[tokio::test]
|
|
async fn auto_assign_promotes_backlog_story_when_dep_is_archived() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
let sk = root.join(".huskies");
|
|
let backlog = sk.join("work/1_backlog");
|
|
let current = sk.join("work/2_current");
|
|
let archived = sk.join("work/6_archived");
|
|
std::fs::create_dir_all(&backlog).unwrap();
|
|
std::fs::create_dir_all(¤t).unwrap();
|
|
std::fs::create_dir_all(&archived).unwrap();
|
|
std::fs::write(
|
|
sk.join("project.toml"),
|
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
|
)
|
|
.unwrap();
|
|
// Dep 490 is in 6_archived (e.g. a CRDT spike that was archived/superseded).
|
|
crate::db::ensure_content_store();
|
|
let dep_content = "---\nname: CRDT Spike\n---\n";
|
|
std::fs::write(archived.join("490_spike_crdt.md"), dep_content).unwrap();
|
|
crate::db::write_content(crate::db::ContentKey::Story("490_spike_crdt"), dep_content);
|
|
// Story 478 depends on 490 (the archived spike).
|
|
let story_content = "---\nname: Dependent\ndepends_on: [490]\n---\n";
|
|
std::fs::write(backlog.join("478_story_dependent.md"), story_content).unwrap();
|
|
crate::db::write_content(
|
|
crate::db::ContentKey::Story("478_story_dependent"),
|
|
story_content,
|
|
);
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
pool.auto_assign_available_work(root).await;
|
|
|
|
// Story 478 must be promoted even though dep 490 is only in 6_archived
|
|
// (not in 5_done), because archived = satisfied. The lifecycle function
|
|
// updates the content store, so verify via the DB.
|
|
let content = crate::db::read_content(crate::db::ContentKey::Story("478_story_dependent"))
|
|
.expect("story 478 should be in content store after promotion");
|
|
assert!(
|
|
content.contains("name: Dependent"),
|
|
"story 478 content should be preserved after promotion"
|
|
);
|
|
}
|
|
|
|
/// Stories in backlog with NO depends_on must NOT be auto-promoted.
|
|
#[tokio::test]
|
|
async fn auto_assign_does_not_promote_backlog_story_without_deps() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
let sk = root.join(".huskies");
|
|
let backlog = sk.join("work/1_backlog");
|
|
std::fs::create_dir_all(&backlog).unwrap();
|
|
std::fs::write(
|
|
sk.join("project.toml"),
|
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
backlog.join("7_story_nodeps.md"),
|
|
"---\nname: No deps\n---\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
pool.auto_assign_available_work(root).await;
|
|
|
|
assert!(
|
|
backlog.join("7_story_nodeps.md").exists(),
|
|
"story with no depends_on should stay in 1_backlog/ — human schedules it"
|
|
);
|
|
}
|
|
|
|
// ── 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.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn toctou_concurrent_auto_assign_no_duplicate_agent_assignments() {
|
|
use std::fs;
|
|
use std::sync::Arc;
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().to_path_buf();
|
|
|
|
let sk_dir = root.join(".huskies");
|
|
// Two stories waiting in 2_current, one coder agent.
|
|
fs::create_dir_all(sk_dir.join("work/2_current")).unwrap();
|
|
fs::write(
|
|
sk_dir.join("project.toml"),
|
|
"[[agent]]\nname = \"coder-1\"\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
sk_dir.join("work/2_current/86_story_foo.md"),
|
|
"---\nname: Foo\n---\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
sk_dir.join("work/2_current/130_story_bar.md"),
|
|
"---\nname: Bar\n---\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let pool = Arc::new(AgentPool::new_test(3099));
|
|
|
|
// Run two concurrent auto_assign calls.
|
|
let pool1 = pool.clone();
|
|
let root1 = root.clone();
|
|
let t1 = tokio::spawn(async move { pool1.auto_assign_available_work(&root1).await });
|
|
|
|
let pool2 = pool.clone();
|
|
let root2 = root.clone();
|
|
let t2 = tokio::spawn(async move { pool2.auto_assign_available_work(&root2).await });
|
|
|
|
let _ = tokio::join!(t1, t2);
|
|
|
|
// At most one Pending/Running entry should exist for coder-1.
|
|
let agents = pool.agents.lock().unwrap();
|
|
let active_coder_count = agents
|
|
.values()
|
|
.filter(|a| {
|
|
a.agent_name == "coder-1"
|
|
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
|
})
|
|
.count();
|
|
|
|
assert!(
|
|
active_coder_count <= 1,
|
|
"coder-1 must not be assigned to more than one story simultaneously; \
|
|
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)"
|
|
);
|
|
}
|
|
}
|