2026-04-27 18:20:34 +00:00
|
|
|
//! Tests for basic coder selection and front-matter agent preference in
|
|
|
|
|
//! `AgentPool::start_agent`.
|
|
|
|
|
|
|
|
|
|
use super::super::AgentPool;
|
|
|
|
|
use crate::agents::{AgentEvent, AgentStatus};
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn start_agent_auto_selects_second_coder_when_first_busy() {
|
|
|
|
|
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"),
|
|
|
|
|
r#"
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "supervisor"
|
|
|
|
|
stage = "other"
|
|
|
|
|
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder-1"
|
|
|
|
|
stage = "coder"
|
|
|
|
|
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder-2"
|
|
|
|
|
stage = "coder"
|
|
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
pool.inject_test_agent("other-story", "coder-1", AgentStatus::Running);
|
|
|
|
|
|
|
|
|
|
let result = pool
|
|
|
|
|
.start_agent(tmp.path(), "42_my_story", None, None, None)
|
|
|
|
|
.await;
|
|
|
|
|
match result {
|
|
|
|
|
Ok(info) => {
|
|
|
|
|
assert_eq!(info.agent_name, "coder-2");
|
|
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
assert!(
|
|
|
|
|
!err.contains("All coder agents are busy"),
|
|
|
|
|
"should have selected coder-2 but got: {err}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!err.contains("No coder agent configured"),
|
|
|
|
|
"should not fail on agent selection, got: {err}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn start_agent_returns_busy_when_all_coders_occupied() {
|
|
|
|
|
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"),
|
|
|
|
|
r#"
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder-1"
|
|
|
|
|
stage = "coder"
|
|
|
|
|
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder-2"
|
|
|
|
|
stage = "coder"
|
|
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
pool.inject_test_agent("story-1", "coder-1", AgentStatus::Running);
|
|
|
|
|
pool.inject_test_agent("story-2", "coder-2", AgentStatus::Pending);
|
|
|
|
|
|
|
|
|
|
let result = pool
|
|
|
|
|
.start_agent(tmp.path(), "story-3", None, None, None)
|
|
|
|
|
.await;
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
let err = result.unwrap_err();
|
|
|
|
|
assert!(
|
|
|
|
|
err.contains("All coder agents are busy"),
|
|
|
|
|
"expected busy error, got: {err}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn start_agent_moves_story_to_current_when_coders_busy() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let sk = tmp.path().join(".huskies");
|
|
|
|
|
let backlog = sk.join("work/1_backlog");
|
|
|
|
|
std::fs::create_dir_all(&backlog).unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
sk.join("project.toml"),
|
|
|
|
|
r#"
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder-1"
|
|
|
|
|
stage = "coder"
|
|
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let story_content = "---\nname: Story 3\n---\n";
|
|
|
|
|
std::fs::write(backlog.join("story-3.md"), story_content).unwrap();
|
|
|
|
|
crate::db::ensure_content_store();
|
2026-05-13 11:22:57 +00:00
|
|
|
crate::db::write_content(crate::db::ContentKey::Story("story-3"), story_content);
|
2026-04-27 18:20:34 +00:00
|
|
|
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
pool.inject_test_agent("story-1", "coder-1", AgentStatus::Running);
|
|
|
|
|
|
|
|
|
|
let result = pool
|
|
|
|
|
.start_agent(tmp.path(), "story-3", None, None, None)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
let err = result.unwrap_err();
|
|
|
|
|
assert!(
|
|
|
|
|
err.contains("All coder agents are busy"),
|
|
|
|
|
"expected busy error, got: {err}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
err.contains("queued in work/2_current/"),
|
|
|
|
|
"expected story-to-current message, got: {err}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// The lifecycle function updates the content store (not the filesystem),
|
|
|
|
|
// so verify the move via the DB.
|
2026-05-13 11:22:57 +00:00
|
|
|
let content = crate::db::read_content(crate::db::ContentKey::Story("story-3"))
|
2026-04-27 18:20:34 +00:00
|
|
|
.expect("story-3 should be in content store after move to current");
|
|
|
|
|
assert!(
|
|
|
|
|
content.contains("name: Story 3"),
|
|
|
|
|
"story-3 content should be preserved after move"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn start_agent_story_already_in_current_is_noop() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let sk = tmp.path().join(".huskies");
|
|
|
|
|
let current = sk.join("work/2_current");
|
|
|
|
|
std::fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
sk.join("project.toml"),
|
|
|
|
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
std::fs::write(current.join("story-5.md"), "---\nname: Story 5\n---\n").unwrap();
|
|
|
|
|
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
|
|
|
|
|
let result = pool
|
|
|
|
|
.start_agent(tmp.path(), "story-5", None, None, None)
|
|
|
|
|
.await;
|
|
|
|
|
match result {
|
|
|
|
|
Ok(_) => {}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
assert!(
|
|
|
|
|
!e.contains("Failed to move"),
|
|
|
|
|
"should not fail on idempotent move, got: {e}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn start_agent_explicit_name_unchanged_when_busy() {
|
|
|
|
|
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"),
|
|
|
|
|
r#"
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder-1"
|
|
|
|
|
stage = "coder"
|
|
|
|
|
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder-2"
|
|
|
|
|
stage = "coder"
|
|
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
pool.inject_test_agent("story-1", "coder-1", AgentStatus::Running);
|
|
|
|
|
|
|
|
|
|
let result = pool
|
|
|
|
|
.start_agent(tmp.path(), "story-2", Some("coder-1"), None, None)
|
|
|
|
|
.await;
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
let err = result.unwrap_err();
|
|
|
|
|
assert!(
|
|
|
|
|
err.contains("coder-1") && err.contains("already running"),
|
|
|
|
|
"expected explicit busy error, got: {err}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── front matter agent preference (bug 379) ──────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn start_agent_honours_front_matter_agent_when_idle() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let sk = tmp.path().join(".huskies");
|
|
|
|
|
let backlog = sk.join("work/1_backlog");
|
|
|
|
|
std::fs::create_dir_all(&backlog).unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
sk.join("project.toml"),
|
|
|
|
|
r#"
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder-sonnet"
|
|
|
|
|
stage = "coder"
|
|
|
|
|
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder-opus"
|
|
|
|
|
stage = "coder"
|
|
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
// Story file with agent preference in front matter.
|
|
|
|
|
std::fs::write(
|
|
|
|
|
backlog.join("368_story_test.md"),
|
|
|
|
|
"---\nname: Test Story\nagent: coder-opus\n---\n# Story 368\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let pool = AgentPool::new_test(3010);
|
|
|
|
|
// coder-sonnet is busy so without front matter the auto-selection
|
|
|
|
|
// would skip coder-opus and try something else.
|
|
|
|
|
pool.inject_test_agent("other-story", "coder-sonnet", AgentStatus::Running);
|
|
|
|
|
|
|
|
|
|
let result = pool
|
|
|
|
|
.start_agent(tmp.path(), "368_story_test", None, None, None)
|
|
|
|
|
.await;
|
|
|
|
|
match result {
|
|
|
|
|
Ok(info) => {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
info.agent_name, "coder-opus",
|
|
|
|
|
"should pick the front-matter preferred agent"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
// Allowed to fail for infrastructure reasons (no git repo),
|
|
|
|
|
// but NOT due to agent selection ignoring the preference.
|
|
|
|
|
assert!(
|
|
|
|
|
!err.contains("All coder agents are busy"),
|
|
|
|
|
"should not report busy when coder-opus is idle: {err}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!err.contains("coder-sonnet"),
|
|
|
|
|
"should not have picked coder-sonnet: {err}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn start_agent_returns_error_when_front_matter_agent_busy() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let sk = tmp.path().join(".huskies");
|
|
|
|
|
let backlog = sk.join("work/1_backlog");
|
|
|
|
|
let current = sk.join("work/2_current");
|
|
|
|
|
std::fs::create_dir_all(&backlog).unwrap();
|
|
|
|
|
std::fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
sk.join("project.toml"),
|
|
|
|
|
r#"
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder-sonnet"
|
|
|
|
|
stage = "coder"
|
|
|
|
|
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder-opus"
|
|
|
|
|
stage = "coder"
|
|
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let story_content = "---\nname: Test Story\nagent: coder-opus\n---\n# Story 368\n";
|
|
|
|
|
std::fs::write(backlog.join("368_story_test.md"), story_content).unwrap();
|
|
|
|
|
// Also write to the filesystem current dir and content store so that
|
|
|
|
|
// start_agent reads the correct front matter even when another test has
|
|
|
|
|
// left a stale entry for "368_story_test" in the global CRDT.
|
|
|
|
|
std::fs::write(current.join("368_story_test.md"), story_content).unwrap();
|
|
|
|
|
crate::db::ensure_content_store();
|
2026-05-13 11:22:57 +00:00
|
|
|
crate::db::write_content(
|
|
|
|
|
crate::db::ContentKey::Story("368_story_test"),
|
|
|
|
|
story_content,
|
|
|
|
|
);
|
2026-05-12 19:03:51 +01:00
|
|
|
// Story 929: agent pin comes from the CRDT register, not YAML. Seed it.
|
|
|
|
|
crate::crdt_state::init_for_test();
|
2026-05-12 22:31:59 +01:00
|
|
|
crate::crdt_state::write_item_str(
|
2026-05-12 19:03:51 +01:00
|
|
|
"368_story_test",
|
|
|
|
|
"2_current",
|
|
|
|
|
Some("Test Story"),
|
|
|
|
|
Some("coder-opus"),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
);
|
2026-04-27 18:20:34 +00:00
|
|
|
|
|
|
|
|
let pool = AgentPool::new_test(3011);
|
|
|
|
|
// Preferred agent is busy — should NOT fall back to coder-sonnet.
|
|
|
|
|
pool.inject_test_agent("other-story", "coder-opus", AgentStatus::Running);
|
|
|
|
|
|
|
|
|
|
let result = pool
|
|
|
|
|
.start_agent(tmp.path(), "368_story_test", None, None, None)
|
|
|
|
|
.await;
|
|
|
|
|
assert!(
|
|
|
|
|
result.is_err(),
|
|
|
|
|
"expected error when preferred agent is busy"
|
|
|
|
|
);
|
|
|
|
|
let err = result.unwrap_err();
|
|
|
|
|
assert!(
|
|
|
|
|
err.contains("coder-opus"),
|
|
|
|
|
"error should mention the preferred agent: {err}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
err.contains("busy") || err.contains("queued"),
|
|
|
|
|
"error should say agent is busy or story is queued: {err}"
|
|
|
|
|
);
|
|
|
|
|
}
|