Files
huskies/server/src/agents/pool/auto_assign/auto_assign.rs
T

812 lines
32 KiB
Rust
Raw Normal View History

//! Auto-assign: scan pipeline stages and dispatch free agents to unassigned stories.
2026-04-29 09:49:45 +00:00
use std::path::Path;
use crate::config::ProjectConfig;
use crate::slog_warn;
use super::super::AgentPool;
impl AgentPool {
2026-04-29 09:49:45 +00:00
/// Scan all active pipeline stages and start free agents for any unassigned work.
///
2026-04-29 09:49:45 +00:00
/// 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(project_root);
let config = match ProjectConfig::load(project_root) {
Ok(c) => c,
Err(e) => {
slog_warn!("[auto-assign] Failed to load project config: {e}");
return;
}
};
2026-04-29 09:49:45 +00:00
// Process the coder (2_current/) and QA (3_qa/) stages.
self.assign_pipeline_stages(project_root, &config).await;
2026-04-27 23:31:57 +00:00
2026-04-29 09:49:45 +00:00
// 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();
2026-04-30 22:23:21 +00:00
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",
2026-04-30 22:23:21 +00:00
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",
2026-04-30 22:23:21 +00:00
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",
2026-04-30 22:23:21 +00:00
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.
2026-04-30 22:23:21 +00:00
crate::db::write_item_with_content(
"999_story_dep",
"5_done",
"---\nname: Dep\n---\n",
crate::db::ItemMeta::named("Dep"),
2026-04-30 22:23:21 +00:00
);
// 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(&current).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("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("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("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(&current).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("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("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("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"
);
}
2026-04-28 12:57:28 +00:00
// ── 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();
2026-05-12 14:43:27 +00:00
crate::crdt_state::init_for_test();
2026-04-28 12:57:28 +00:00
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9860_story_conflict",
2026-05-12 14:43:27 +00:00
"4_merge_failure",
"CONFLICT (content): server/src/lib.rs",
crate::db::ItemMeta::named("Conflict"),
2026-04-28 12:57:28 +00:00
);
2026-05-13 09:30:44 +00:00
// 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(
"9860_story_conflict:gate_output",
"CONFLICT (content): server/src/lib.rs",
);
2026-04-28 12:57:28 +00:00
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();
2026-05-12 14:43:27 +00:00
crate::crdt_state::init_for_test();
2026-04-28 12:57:28 +00:00
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9861_story_nothing",
2026-05-12 14:43:27 +00:00
"4_merge_failure",
"nothing to commit, working tree clean",
crate::db::ItemMeta::named("Nothing"),
2026-04-28 12:57:28 +00:00
);
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"
);
}
2026-04-28 14:17:58 +00:00
/// 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();
2026-05-13 06:05:01 +00:00
// 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.
2026-04-28 14:17:58 +00:00
crate::db::write_item_with_content(
"9863_story_blocked_conflict",
2026-05-13 06:05:01 +00:00
"blocked",
"CONFLICT (content): foo.rs",
crate::db::ItemMeta {
name: Some("Blocked conflict".to_string()),
..Default::default()
},
2026-04-28 14:17:58 +00:00
);
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"
);
}
2026-04-28 12:57:28 +00:00
/// 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();
2026-05-13 06:05:01 +00:00
// Story 945: "mergemaster attempted" is now `Stage::MergeFailureFinal`.
2026-04-28 12:57:28 +00:00
crate::db::write_item_with_content(
"9862_story_attempted",
2026-05-13 06:05:01 +00:00
"merge_failure_final",
"CONFLICT (content): foo.rs",
crate::db::ItemMeta::named("Already tried"),
2026-04-28 12:57:28 +00:00
);
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"
);
}
2026-05-12 16:36:15 +00:00
// ── 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"),
2026-05-12 16:36:15 +00:00
);
2026-05-13 09:30:44 +00:00
// 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(
"920_story_transient:gate_output",
"CONFLICT (content): foo.rs",
);
2026-05-12 16:36:15 +00:00
// Simulate two previous transient exits (below cap of 3) recorded in DB.
crate::db::write_content("920_story_transient:mergemaster_spawn_count", "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();
2026-05-13 06:05:01 +00:00
// Story 945: the genuine give-up state is now `Stage::MergeFailureFinal`.
2026-05-12 16:36:15 +00:00
crate::db::write_item_with_content(
"920_story_genuine",
2026-05-13 06:05:01 +00:00
"merge_failure_final",
"CONFLICT (content): bar.rs",
crate::db::ItemMeta::named("Genuine"),
2026-05-12 16:36:15 +00:00
);
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"
);
}
}