//! 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.try_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::(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.try_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.try_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.try_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.try_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.try_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.try_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" ); } /// 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.try_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" ); } // ── AC4: startup reconcile + pool reconstruction ────────────────── /// AC4: Simulates a server restart by seeding the CRDT with a story in /// Coding stage, then running `auto_assign_available_work` (startup no longer /// floods the broadcast channel via replay — it calls reconcile functions /// directly). Asserts the pool ends in the expected state: exactly one agent /// assigned to the story, and a second pass does not double-spawn. #[tokio::test] async fn startup_auto_assign_assigns_agent_once() { 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(); crate::db::ensure_content_store(); let story_id = "9903_restart_replay"; crate::db::write_item_with_content( story_id, "2_current", "---\nname: Restart Replay\n---\n", crate::db::ItemMeta::named("Restart Replay"), ); let pool = AgentPool::new_test(3001); // First auto-assign pass. pool.auto_assign_available_work(tmp.path()).await; let count_after_first = { let agents = pool.agents.try_lock().unwrap(); agents .iter() .filter(|(key, a)| { key.contains(story_id) && matches!(a.status, AgentStatus::Pending | AgentStatus::Running) }) .count() }; // Second pass (idempotency): must not double-spawn agents. pool.auto_assign_available_work(tmp.path()).await; let count_after_second = { let agents = pool.agents.try_lock().unwrap(); agents .iter() .filter(|(key, a)| { key.contains(story_id) && matches!(a.status, AgentStatus::Pending | AgentStatus::Running) }) .count() }; assert!( count_after_first <= 1, "after first auto-assign at most one agent must be assigned to {story_id}" ); assert_eq!( count_after_first, count_after_second, "second auto-assign must not spawn additional agents (idempotency)" ); } }