//! 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(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; } }; // 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::(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::(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)" ); } }