//! Tests for single-instance concurrency, TOCTOU races, duplicate-stage //! prevention, and pipeline-stage mismatch guards in `AgentPool::start_agent`. use super::super::AgentPool; use crate::agents::{AgentEvent, AgentStatus}; // ── start_agent single-instance concurrency tests ───────────────────────── #[tokio::test] async fn start_agent_rejects_when_same_agent_already_running_on_another_story() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let sk_dir = root.join(".huskies"); fs::create_dir_all(&sk_dir).unwrap(); fs::write(sk_dir.join("project.toml"), "[[agent]]\nname = \"qa\"\n").unwrap(); let pool = AgentPool::new_test(3001); pool.inject_test_agent("story-a", "qa", AgentStatus::Running); let result = pool .start_agent(root, "story-b", Some("qa"), None, None) .await; assert!( result.is_err(), "start_agent should fail when qa is already running on another story" ); let err = result.unwrap_err(); assert!( err.contains("already running") || err.contains("becomes available"), "error message should explain why: got '{err}'" ); } #[tokio::test] async fn start_agent_allows_new_story_when_previous_run_is_completed() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let sk_dir = root.join(".huskies"); fs::create_dir_all(&sk_dir).unwrap(); fs::write(sk_dir.join("project.toml"), "[[agent]]\nname = \"qa\"\n").unwrap(); let pool = AgentPool::new_test(3001); pool.inject_test_agent("story-a", "qa", AgentStatus::Completed); let result = pool .start_agent(root, "story-b", Some("qa"), None, None) .await; if let Err(ref e) = result { assert!( !e.contains("already running") && !e.contains("becomes available"), "completed agent must not trigger the concurrency guard: got '{e}'" ); } } // ── bug 118: pending entry cleanup on start_agent failure ──────────────── #[tokio::test] async fn start_agent_cleans_up_pending_entry_on_failure() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let sk_dir = root.join(".huskies"); fs::create_dir_all(&sk_dir).unwrap(); fs::write( sk_dir.join("project.toml"), "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n", ) .unwrap(); let upcoming = root.join(".huskies/work/1_backlog"); fs::create_dir_all(&upcoming).unwrap(); fs::write(upcoming.join("50_story_test.md"), "---\nname: Test\n---\n").unwrap(); let pool = AgentPool::new_test(3099); let result = pool .start_agent(root, "50_story_test", Some("coder-1"), None, None) .await; assert!( result.is_ok(), "start_agent should return Ok(Pending) immediately: {:?}", result.err() ); assert_eq!( result.unwrap().status, AgentStatus::Pending, "initial status must be Pending" ); let final_info = pool .wait_for_agent("50_story_test", "coder-1", 5000) .await .expect("wait_for_agent should not time out"); assert_eq!( final_info.status, AgentStatus::Failed, "agent must transition to Failed after worktree creation error" ); let agents = pool.agents.lock().unwrap(); let failed_entry = agents .values() .find(|a| a.agent_name == "coder-1" && a.status == AgentStatus::Failed); assert!( failed_entry.is_some(), "agent pool must retain a Failed entry so the UI can show the error state" ); drop(agents); let events = pool .drain_events("50_story_test", "coder-1") .expect("drain_events should succeed"); let has_error_event = events.iter().any(|e| matches!(e, AgentEvent::Error { .. })); assert!( has_error_event, "event_log must contain AgentEvent::Error after worktree creation fails" ); } #[tokio::test] async fn start_agent_guard_does_not_remove_running_entry() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let sk_dir = root.join(".huskies"); fs::create_dir_all(&sk_dir).unwrap(); fs::write(sk_dir.join("project.toml"), "[[agent]]\nname = \"qa\"\n").unwrap(); let pool = AgentPool::new_test(3099); pool.inject_test_agent("story-x", "qa", AgentStatus::Running); let result = pool .start_agent(root, "story-y", Some("qa"), None, None) .await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( err.contains("already running") || err.contains("becomes available"), "running entry must survive: got '{err}'" ); } // ── TOCTOU race-condition regression tests (story 132) ─────────────────── #[tokio::test] async fn toctou_pending_entry_blocks_same_agent_on_different_story() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let sk_dir = root.join(".huskies"); fs::create_dir_all(&sk_dir).unwrap(); fs::write( sk_dir.join("project.toml"), "[[agent]]\nname = \"coder-1\"\n", ) .unwrap(); let pool = AgentPool::new_test(3099); pool.inject_test_agent("86_story_foo", "coder-1", AgentStatus::Pending); let result = pool .start_agent(root, "130_story_bar", Some("coder-1"), None, None) .await; assert!(result.is_err(), "second start_agent must be rejected"); let err = result.unwrap_err(); assert!( err.contains("already running") || err.contains("becomes available"), "expected concurrency-rejection message, got: '{err}'" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn toctou_concurrent_start_agent_same_agent_exactly_one_concurrency_rejection() { 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"); fs::create_dir_all(sk_dir.join("work/1_backlog")).unwrap(); fs::write( root.join(".huskies/project.toml"), "[[agent]]\nname = \"coder-1\"\n", ) .unwrap(); fs::write( root.join(".huskies/work/1_backlog/86_story_foo.md"), "---\nname: Foo\n---\n", ) .unwrap(); fs::write( root.join(".huskies/work/1_backlog/130_story_bar.md"), "---\nname: Bar\n---\n", ) .unwrap(); let pool = Arc::new(AgentPool::new_test(3099)); let pool1 = pool.clone(); let root1 = root.clone(); let t1 = tokio::spawn(async move { pool1 .start_agent(&root1, "86_story_foo", Some("coder-1"), None, None) .await }); let pool2 = pool.clone(); let root2 = root.clone(); let t2 = tokio::spawn(async move { pool2 .start_agent(&root2, "130_story_bar", Some("coder-1"), None, None) .await }); let (r1, r2) = tokio::join!(t1, t2); let r1 = r1.unwrap(); let r2 = r2.unwrap(); let concurrency_rejections = [&r1, &r2] .iter() .filter(|r| { r.as_ref() .is_err_and(|e| e.contains("already running") || e.contains("becomes available")) }) .count(); assert_eq!( concurrency_rejections, 1, "exactly one call must be rejected by the concurrency check; \ got r1={r1:?} r2={r2:?}" ); } // ── story-230: prevent duplicate stage agents on same story ─────────────── #[tokio::test] async fn start_agent_rejects_second_coder_stage_on_same_story() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let sk_dir = root.join(".huskies"); fs::create_dir_all(&sk_dir).unwrap(); fs::write( sk_dir.join("project.toml"), "[[agent]]\nname = \"coder-1\"\n\n[[agent]]\nname = \"coder-2\"\n", ) .unwrap(); let pool = AgentPool::new_test(3099); pool.inject_test_agent("42_story_foo", "coder-1", AgentStatus::Running); let result = pool .start_agent(root, "42_story_foo", Some("coder-2"), None, None) .await; assert!( result.is_err(), "second coder on same story must be rejected" ); let err = result.unwrap_err(); assert!( err.contains("same pipeline stage"), "error must mention same pipeline stage, got: '{err}'" ); assert!( err.contains("coder-1") && err.contains("coder-2"), "error must name both agents, got: '{err}'" ); } #[tokio::test] async fn start_agent_rejects_second_qa_stage_on_same_story() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let sk_dir = root.join(".huskies"); fs::create_dir_all(&sk_dir).unwrap(); fs::write( sk_dir.join("project.toml"), "[[agent]]\nname = \"qa-1\"\nstage = \"qa\"\n\n\ [[agent]]\nname = \"qa-2\"\nstage = \"qa\"\n", ) .unwrap(); let pool = AgentPool::new_test(3099); pool.inject_test_agent("55_story_bar", "qa-1", AgentStatus::Running); let result = pool .start_agent(root, "55_story_bar", Some("qa-2"), None, None) .await; assert!(result.is_err(), "second qa on same story must be rejected"); let err = result.unwrap_err(); assert!( err.contains("same pipeline stage"), "error must mention same pipeline stage, got: '{err}'" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn start_agent_concurrent_two_coders_same_story_exactly_one_stage_rejection() { 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"); fs::create_dir_all(sk_dir.join("work/2_current")).unwrap(); fs::write( root.join(".huskies/project.toml"), "[[agent]]\nname = \"coder-1\"\n\n[[agent]]\nname = \"coder-2\"\n", ) .unwrap(); fs::write( root.join(".huskies/work/2_current/42_story_foo.md"), "---\nname: Foo\n---\n", ) .unwrap(); let pool = Arc::new(AgentPool::new_test(3099)); let pool1 = pool.clone(); let root1 = root.clone(); let t1 = tokio::spawn(async move { pool1 .start_agent(&root1, "42_story_foo", Some("coder-1"), None, None) .await }); let pool2 = pool.clone(); let root2 = root.clone(); let t2 = tokio::spawn(async move { pool2 .start_agent(&root2, "42_story_foo", Some("coder-2"), None, None) .await }); let (r1, r2) = tokio::join!(t1, t2); let r1 = r1.unwrap(); let r2 = r2.unwrap(); let stage_rejections = [&r1, &r2] .iter() .filter(|r| r.as_ref().is_err_and(|e| e.contains("same pipeline stage"))) .count(); assert_eq!( stage_rejections, 1, "exactly one call must be rejected by the stage-conflict check; \ got r1={r1:?} r2={r2:?}" ); } #[tokio::test] async fn start_agent_two_coders_different_stories_not_blocked_by_stage_check() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let sk_dir = root.join(".huskies"); fs::create_dir_all(sk_dir.join("work/1_backlog")).unwrap(); fs::write( root.join(".huskies/project.toml"), "[[agent]]\nname = \"coder-1\"\n\n[[agent]]\nname = \"coder-2\"\n", ) .unwrap(); fs::write( root.join(".huskies/work/1_backlog/99_story_baz.md"), "---\nname: Baz\n---\n", ) .unwrap(); let pool = AgentPool::new_test(3099); pool.inject_test_agent("42_story_foo", "coder-1", AgentStatus::Running); let result = pool .start_agent(root, "99_story_baz", Some("coder-2"), None, None) .await; if let Err(ref e) = result { assert!( !e.contains("same pipeline stage"), "stage-conflict guard must not fire for agents on different stories; \ got: '{e}'" ); } } // ── bug 312: stage-pipeline mismatch guard in start_agent ────────────── #[tokio::test] async fn start_agent_rejects_mergemaster_on_coding_stage_story() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let sk_dir = root.join(".huskies"); fs::create_dir_all(sk_dir.join("work/2_current")).unwrap(); fs::write( sk_dir.join("project.toml"), "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\ [[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n", ) .unwrap(); crate::db::ensure_content_store(); crate::db::write_item_with_content( "310_story_foo", "2_current", "---\nname: Foo\n---\n", crate::db::ItemMeta::named("Foo"), ); let pool = AgentPool::new_test(3099); let result = pool .start_agent(root, "310_story_foo", Some("mergemaster"), None, None) .await; assert!( result.is_err(), "mergemaster must not be assigned to a story in 2_current/" ); let err = result.unwrap_err(); assert!( err.contains("stage") && err.contains("2_current"), "error must mention stage mismatch, got: '{err}'" ); } #[tokio::test] async fn start_agent_rejects_coder_on_qa_stage_story() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let sk_dir = root.join(".huskies"); fs::create_dir_all(&sk_dir).unwrap(); fs::write( sk_dir.join("project.toml"), "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\ [[agent]]\nname = \"qa\"\nstage = \"qa\"\n", ) .unwrap(); crate::db::ensure_content_store(); crate::db::write_item_with_content( "8842_story_qa_guard", "3_qa", "---\nname: QA Guard\n---\n", crate::db::ItemMeta::named("QA Guard"), ); let pool = AgentPool::new_test(3099); let result = pool .start_agent(root, "8842_story_qa_guard", Some("coder-1"), None, None) .await; assert!( result.is_err(), "coder must not be assigned to a story in 3_qa/" ); let err = result.unwrap_err(); assert!( err.contains("stage") && err.contains("3_qa"), "error must mention stage mismatch, got: '{err}'" ); } #[tokio::test] async fn start_agent_rejects_qa_on_merge_stage_story() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let sk_dir = root.join(".huskies"); fs::create_dir_all(&sk_dir).unwrap(); fs::write( sk_dir.join("project.toml"), "[[agent]]\nname = \"qa\"\nstage = \"qa\"\n\n\ [[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n", ) .unwrap(); crate::db::ensure_content_store(); crate::db::write_item_with_content( "55_story_baz", "4_merge", "---\nname: Baz\n---\n", crate::db::ItemMeta::named("Baz"), ); let pool = AgentPool::new_test(3099); let result = pool .start_agent(root, "55_story_baz", Some("qa"), None, None) .await; assert!( result.is_err(), "qa must not be assigned to a story in 4_merge/" ); let err = result.unwrap_err(); assert!( err.contains("stage") && err.contains("4_merge"), "error must mention stage mismatch, got: '{err}'" ); } #[tokio::test] async fn start_agent_allows_supervisor_on_any_stage() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let sk_dir = root.join(".huskies"); fs::create_dir_all(sk_dir.join("work/2_current")).unwrap(); fs::write( sk_dir.join("project.toml"), "[[agent]]\nname = \"supervisor\"\nstage = \"other\"\n", ) .unwrap(); fs::write( sk_dir.join("work/2_current/77_story_sup.md"), "---\nname: Sup\n---\n", ) .unwrap(); let pool = AgentPool::new_test(3099); let result = pool .start_agent(root, "77_story_sup", Some("supervisor"), None, None) .await; match result { Ok(_) => {} Err(e) => { assert!( !e.contains("stage:") || !e.contains("cannot be assigned"), "supervisor should not be rejected for stage mismatch, got: '{e}'" ); } } } #[tokio::test] async fn start_agent_allows_correct_stage_agent() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let sk_dir = root.join(".huskies"); fs::create_dir_all(sk_dir.join("work/4_merge")).unwrap(); fs::write( sk_dir.join("project.toml"), "[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n", ) .unwrap(); fs::write( sk_dir.join("work/4_merge/88_story_ok.md"), "---\nname: OK\n---\n", ) .unwrap(); let pool = AgentPool::new_test(3099); let result = pool .start_agent(root, "88_story_ok", Some("mergemaster"), None, None) .await; match result { Ok(_) => {} Err(e) => { assert!( !e.contains("cannot be assigned"), "mergemaster on 4_merge/ story should not fail stage check, got: '{e}'" ); } } } /// Bug 502: when start_agent is called for a non-Coder agent (mergemaster /// or qa) on a story that's in 4_merge/, the unconditional /// move_story_to_current at the top of start_agent must NOT fire — even /// when a stale split-brain shadow of the story exists in 1_backlog/. /// /// Pre-fix behaviour: move_story_to_current would find the 1_backlog /// shadow and move it to 2_current/. find_active_story_stage would then /// report 2_current/, the stage check would expect a Coder-stage agent, /// and mergemaster would be rejected — leaving the story in 2_current/ /// to be picked up by the next auto-assign tick as a coder. Infinite loop. /// Observed live on 2026-04-09 against story 478. #[tokio::test] async fn start_agent_does_not_demote_merge_stage_story_with_backlog_shadow() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let sk_dir = root.join(".huskies"); fs::create_dir_all(sk_dir.join("work/1_backlog")).unwrap(); fs::create_dir_all(sk_dir.join("work/4_merge")).unwrap(); fs::write( sk_dir.join("project.toml"), "[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n", ) .unwrap(); // Real copy in 4_merge/ (where the story actually is per the DB). fs::write( sk_dir.join("work/4_merge/502_story_split_brain.md"), "---\nname: Split Brain\n---\n", ) .unwrap(); // Stale split-brain shadow in 1_backlog/ (post-491/492 migration // artifact — the filesystem shadow that bit us in production). fs::write( sk_dir.join("work/1_backlog/502_story_split_brain.md"), "---\nname: Split Brain\n---\n", ) .unwrap(); let pool = AgentPool::new_test(3098); let result = pool .start_agent( root, "502_story_split_brain", Some("mergemaster"), None, None, ) .await; // Stage check must not reject mergemaster. if let Err(ref e) = result { assert!( !e.contains("cannot be assigned"), "mergemaster on 4_merge/ story must not fail stage check even \ when a 1_backlog shadow exists, got: '{e}'" ); } // Critical: the story must still be in 4_merge/ after the call. // Before the fix, line 53 of start.rs would have demoted it to // 2_current/ via move_story_to_current finding the 1_backlog shadow. assert!( sk_dir .join("work/4_merge/502_story_split_brain.md") .exists(), "story must still be in 4_merge/ after start_agent(mergemaster, ...)" ); assert!( !sk_dir .join("work/2_current/502_story_split_brain.md") .exists(), "story must NOT have been demoted to 2_current/ — that's bug 502" ); }