From ed8646f0d9661a4353e92ddac2c49cd6720ba7d4 Mon Sep 17 00:00:00 2001 From: dave Date: Mon, 27 Apr 2026 17:29:11 +0000 Subject: [PATCH] huskies: merge 681_refactor_decompose_server_src_agents_pool_pipeline_advance_mod_rs_1509_lines --- .../src/agents/pool/pipeline/advance/mod.rs | 1014 +---------------- .../src/agents/pool/pipeline/advance/tests.rs | 237 ++++ .../pool/pipeline/advance/tests_regression.rs | 775 +++++++++++++ 3 files changed, 1016 insertions(+), 1010 deletions(-) create mode 100644 server/src/agents/pool/pipeline/advance/tests.rs create mode 100644 server/src/agents/pool/pipeline/advance/tests_regression.rs diff --git a/server/src/agents/pool/pipeline/advance/mod.rs b/server/src/agents/pool/pipeline/advance/mod.rs index b4c3d187..90e1be55 100644 --- a/server/src/agents/pool/pipeline/advance/mod.rs +++ b/server/src/agents/pool/pipeline/advance/mod.rs @@ -501,1013 +501,7 @@ mod helpers; use helpers::{resolve_qa_mode_from_store, write_review_hold_to_store}; pub(crate) use helpers::{should_block_story, spawn_pipeline_advance}; -mod tests { - use super::super::super::AgentPool; - use super::super::super::composite_key; - use crate::agents::{AgentStatus, CompletionReport}; - use crate::io::watcher::WatcherEvent; - - // ── pipeline advance tests ──────────────────────────────────────────────── - - #[tokio::test] - async fn pipeline_advance_coder_gates_pass_server_qa_moves_to_merge() { - use std::fs; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - // Set up story in 2_current/ (no qa frontmatter → uses project default "server"). - // Use a unique high-numbered ID to avoid collision with the agent_qa test. - let current = root.join(".huskies/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - fs::write(current.join("9908_story_server_qa.md"), "test").unwrap(); - crate::db::ensure_content_store(); - crate::db::write_content("9908_story_server_qa", "test"); - - let pool = AgentPool::new_test(3001); - pool.run_pipeline_advance( - "9908_story_server_qa", - "coder-1", - CompletionReport { - summary: "done".to_string(), - gates_passed: true, - gate_output: String::new(), - }, - Some(root.to_path_buf()), - None, - false, - None, - ) - .await; - - // With default qa: server, story skips QA and goes straight to 4_merge/ - // Lifecycle moves now update the content store, not the filesystem. - assert!( - crate::db::read_content("9908_story_server_qa").is_some(), - "story should still exist in content store after move to merge" - ); - } - - #[tokio::test] - async fn pipeline_advance_coder_gates_pass_agent_qa_moves_to_qa() { - use std::fs; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - // Set up story in 2_current/ with qa: agent frontmatter. - // Use a unique high-numbered ID to avoid collision with the server_qa test. - let current = root.join(".huskies/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - fs::write( - current.join("9909_story_agent_qa.md"), - "---\nname: Test\nqa: agent\n---\ntest", - ) - .unwrap(); - crate::db::ensure_content_store(); - crate::db::write_content( - "9909_story_agent_qa", - "---\nname: Test\nqa: agent\n---\ntest", - ); - - let pool = AgentPool::new_test(3001); - pool.run_pipeline_advance( - "9909_story_agent_qa", - "coder-1", - CompletionReport { - summary: "done".to_string(), - gates_passed: true, - gate_output: String::new(), - }, - Some(root.to_path_buf()), - None, - false, - None, - ) - .await; - - // With qa: agent, story should move to 3_qa/ - // Lifecycle moves now update the content store, not the filesystem. - assert!( - crate::db::read_content("9909_story_agent_qa").is_some(), - "story should still exist in content store after move to qa" - ); - } - - #[tokio::test] - async fn pipeline_advance_qa_gates_pass_moves_story_to_merge() { - use std::fs; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - // Set up story in 3_qa/ - let qa_dir = root.join(".huskies/work/3_qa"); - fs::create_dir_all(&qa_dir).unwrap(); - // qa: server so the story skips human review and goes straight to merge. - fs::write( - qa_dir.join("51_story_test.md"), - "---\nname: Test\nqa: server\n---\ntest", - ) - .unwrap(); - crate::db::ensure_content_store(); - crate::db::write_content("51_story_test", "---\nname: Test\nqa: server\n---\ntest"); - - let pool = AgentPool::new_test(3001); - pool.run_pipeline_advance( - "51_story_test", - "qa", - CompletionReport { - summary: "QA done".to_string(), - gates_passed: true, - gate_output: String::new(), - }, - Some(root.to_path_buf()), - None, - false, - None, - ) - .await; - - // Story should have moved to 4_merge/ - // Lifecycle moves now update the content store, not the filesystem. - assert!( - crate::db::read_content("51_story_test").is_some(), - "story should still exist in content store after move to merge" - ); - } - - #[tokio::test] - async fn pipeline_advance_supervisor_does_not_advance() { - use std::fs; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - let current = root.join(".huskies/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - fs::write(current.join("52_story_test.md"), "test").unwrap(); - - let pool = AgentPool::new_test(3001); - pool.run_pipeline_advance( - "52_story_test", - "supervisor", - CompletionReport { - summary: "supervised".to_string(), - gates_passed: true, - gate_output: String::new(), - }, - Some(root.to_path_buf()), - None, - false, - None, - ) - .await; - - // Story should NOT have moved (supervisors don't advance pipeline) - assert!( - current.join("52_story_test.md").exists(), - "story should still be in 2_current/ for supervisor" - ); - } - - #[tokio::test] - async fn pipeline_advance_sends_agent_state_changed_to_watcher_tx() { - use std::fs; - - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - // Seed story via CRDT (the only source of truth). - crate::db::ensure_content_store(); - crate::db::write_item_with_content("173_story_test", "2_current", "---\nname: test\n---\n"); - - // Write a project.toml with a qa agent so start_agent can resolve it. - fs::create_dir_all(root.join(".huskies")).unwrap(); - fs::write( - root.join(".huskies/project.toml"), - r#" -default_qa = "agent" - -[[agent]] -name = "coder-1" -role = "Coder" -command = "echo" -args = ["noop"] -prompt = "test" -stage = "coder" - -[[agent]] -name = "qa" -role = "QA" -command = "echo" -args = ["noop"] -prompt = "test" -stage = "qa" -"#, - ) - .unwrap(); - - let pool = AgentPool::new_test(3001); - // Subscribe to the watcher channel BEFORE the pipeline advance. - let mut rx = pool.watcher_tx.subscribe(); - - pool.run_pipeline_advance( - "173_story_test", - "coder-1", - CompletionReport { - summary: "done".to_string(), - gates_passed: true, - gate_output: String::new(), - }, - Some(root.to_path_buf()), - None, - false, - None, - ) - .await; - - // The pipeline advance should have sent AgentStateChanged events via - // the pool's watcher_tx (not a dummy channel). Collect all events. - let mut got_agent_state_changed = false; - while let Ok(evt) = rx.try_recv() { - if matches!(evt, WatcherEvent::AgentStateChanged) { - got_agent_state_changed = true; - break; - } - } - - assert!( - got_agent_state_changed, - "pipeline advance should send AgentStateChanged through the real watcher_tx \ - (bug 173: lozenges must update when agents are assigned during pipeline advance)" - ); - } - - // ── story 519: mergemaster pre-flight blocks when no commits ahead ── - - /// Regression test for story 519: when the feature branch has zero commits - /// ahead of master, mergemaster must not spawn a Claude session. A no-op - /// session spent $0.82 in the 2026-04-09 incident because the worktree was - /// reset to master before mergemaster ran. - #[tokio::test] - async fn mergemaster_blocks_and_sends_story_blocked_when_no_commits_ahead() { - use std::fs; - use std::process::Command; - - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - // Init a bare git repo on master with one empty commit. - Command::new("git") - .args(["init"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["config", "user.email", "test@test.com"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["config", "user.name", "Test"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["commit", "--allow-empty", "-m", "init"]) - .current_dir(root) - .output() - .unwrap(); - - // Create a feature branch that points at master HEAD (zero commits ahead). - // This replicates the incident where the worktree was reset to master. - Command::new("git") - .args(["branch", "feature/story-9919_story_no_commits"]) - .current_dir(root) - .output() - .unwrap(); - - // Set up pipeline dirs and story file. - let current = root.join(".huskies/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - fs::create_dir_all(root.join(".huskies/work/4_merge")).unwrap(); - fs::write( - current.join("9919_story_no_commits.md"), - "---\nname: Test\n---\n", - ) - .unwrap(); - crate::db::ensure_content_store(); - crate::db::write_content("9919_story_no_commits", "---\nname: Test\n---\n"); - - let pool = AgentPool::new_test(3001); - let mut rx = pool.watcher_tx.subscribe(); - - // Simulate coder completing with gates passed (qa: server → goes to merge). - pool.run_pipeline_advance( - "9919_story_no_commits", - "coder-1", - CompletionReport { - summary: "done".to_string(), - gates_passed: true, - gate_output: String::new(), - }, - Some(root.to_path_buf()), - None, - false, - None, - ) - .await; - - // Story should still exist in the content store after moving to merge. - assert!( - crate::db::read_content("9919_story_no_commits").is_some(), - "story should remain in content store — not removed" - ); - - // A StoryBlocked event must have been emitted (triggers chat failure notice, - // not the success 🎉 emoji). - let mut got_blocked = false; - while let Ok(evt) = rx.try_recv() { - if let WatcherEvent::StoryBlocked { story_id, .. } = &evt - && story_id == "9919_story_no_commits" - { - got_blocked = true; - break; - } - } - assert!( - got_blocked, - "StoryBlocked event must be sent when feature branch has no commits ahead of master" - ); - - // No mergemaster agent should have been started. - let agents = pool.agents.lock().unwrap(); - let mergemaster_started = agents - .values() - .any(|a| a.agent_name.contains("mergemaster")); - assert!( - !mergemaster_started, - "mergemaster agent must NOT be started when no commits ahead of master" - ); - } - - // ── bug 295: pipeline advance picks up waiting QA stories ────────── - - #[tokio::test] - async fn pipeline_advance_picks_up_waiting_qa_stories_after_completion() { - use super::super::super::auto_assign::is_agent_free; - use std::fs; - - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - let sk = root.join(".huskies"); - fs::create_dir_all(&sk).unwrap(); - - // Configure a single QA agent. - fs::write( - sk.join("project.toml"), - r#" -[[agent]] -name = "qa" -stage = "qa" -"#, - ) - .unwrap(); - - // Seed stories via CRDT (the only source of truth). - crate::db::ensure_content_store(); - // Story 292 is in QA with QA agent running (will "complete" via - // run_pipeline_advance below). Story 293 is in QA with NO agent — - // simulating the "stuck" state from bug 295. - crate::db::write_item_with_content( - "292_story_first", - "3_qa", - "---\nname: First\nqa: human\n---\n", - ); - crate::db::write_item_with_content( - "293_story_second", - "3_qa", - "---\nname: Second\nqa: human\n---\n", - ); - - let pool = AgentPool::new_test(3001); - // QA is currently running on story 292. - pool.inject_test_agent("292_story_first", "qa", AgentStatus::Running); - - // Verify that 293 cannot get a QA agent right now (QA is busy). - { - let agents = pool.agents.lock().unwrap(); - assert!( - !is_agent_free(&agents, "qa"), - "qa should be busy on story 292" - ); - } - - // Simulate QA completing on story 292: remove the agent from the pool - // (as run_server_owned_completion does) then run pipeline advance. - { - let mut agents = pool.agents.lock().unwrap(); - agents.remove(&composite_key("292_story_first", "qa")); - } - - pool.run_pipeline_advance( - "292_story_first", - "qa", - CompletionReport { - summary: "QA done".to_string(), - gates_passed: true, - gate_output: String::new(), - }, - Some(root.to_path_buf()), - None, - false, - None, - ) - .await; - - // After pipeline advance, auto_assign should have started QA on story 293. - let agents = pool.agents.lock().unwrap(); - let qa_on_293 = agents.values().any(|a| { - a.agent_name == "qa" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running) - }); - assert!( - qa_on_293, - "auto_assign should have started qa for story 293 after 292's QA completed, \ - but no qa agent is pending/running. Pool: {:?}", - agents - .iter() - .map(|(k, a)| format!("{k}: {} ({})", a.agent_name, a.status)) - .collect::>() - ); - } - - // ── bug 529: stale mergemaster advance for a done story is a no-op ── - - /// Regression test for bug 529: when a stale mergemaster advance fires - /// after the story has already reached 5_done, the advance must be a - /// no-op — no post-merge tests, no notifications, no agent restarts. - #[tokio::test] - async fn stale_mergemaster_advance_for_done_story_is_noop() { - use std::fs; - use std::process::Command; - - // Initialise CRDT so read_typed works. - crate::crdt_state::init_for_test(); - crate::db::ensure_content_store(); - - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - // Init a git repo so post-merge tests would pass if they ran. - Command::new("git") - .args(["init"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["config", "user.email", "test@test.com"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["config", "user.name", "Test"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["commit", "--allow-empty", "-m", "init"]) - .current_dir(root) - .output() - .unwrap(); - - // Set up pipeline dirs. - fs::create_dir_all(root.join(".huskies/work/5_done")).unwrap(); - - // Seed the story in 5_done via the DB, which also writes to the CRDT. - let story_id = "9929_story_zombie_merge"; - let content = "---\nname: Zombie Merge Test\n---\n"; - crate::db::write_content(story_id, content); - crate::db::write_item_with_content(story_id, "5_done", content); - - let pool = AgentPool::new_test(3001); - let mut rx = pool.watcher_tx.subscribe(); - - // Simulate a stale mergemaster advance firing for the already-done story. - pool.run_pipeline_advance( - story_id, - "mergemaster", - CompletionReport { - summary: "stale advance".to_string(), - gates_passed: true, - gate_output: String::new(), - }, - Some(root.to_path_buf()), - None, - false, - None, - ) - .await; - - // No agents should have been started. - let agents = pool.agents.lock().unwrap(); - assert!( - agents.is_empty(), - "No agents should be started for a stale advance on a done story. \ - Pool: {:?}", - agents.keys().collect::>() - ); - drop(agents); - - // No StoryBlocked or other events should have been emitted. - let mut got_event = false; - while let Ok(evt) = rx.try_recv() { - // AgentStateChanged from auto_assign is acceptable only if the - // advance didn't short-circuit. Since we return early, no events. - if matches!(evt, WatcherEvent::StoryBlocked { .. }) { - got_event = true; - } - } - assert!( - !got_event, - "No StoryBlocked event should be emitted for a stale advance" - ); - - // The story should still be in 5_done (not moved elsewhere). - if let Ok(Some(item)) = crate::pipeline_state::read_typed(story_id) { - assert_eq!( - item.stage.dir_name(), - "5_done", - "Story should remain in 5_done after stale mergemaster advance" - ); - } - } - - // ── bug 645: work-survived check advances to QA instead of blocking ── - - /// Integration test: when a coder agent fails gates but committed work - /// survives and compiles, the story advances to QA (not retry/block). - /// Simulates an agent that commits work and then dies mid-output. - #[tokio::test] - async fn work_survived_advances_to_qa_instead_of_blocking() { - use std::fs; - use std::process::Command; - - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - // Init a git repo with a minimal Cargo project. - Command::new("git") - .args(["init"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["config", "user.email", "test@test.com"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["config", "user.name", "Test"]) - .current_dir(root) - .output() - .unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"test_proj\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", - ) - .unwrap(); - fs::create_dir_all(root.join("src")).unwrap(); - fs::write(root.join("src/lib.rs"), "// empty\n").unwrap(); - Command::new("git") - .args(["add", "."]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["commit", "-m", "init"]) - .current_dir(root) - .output() - .unwrap(); - - // Create a worktree on a feature branch. - let wt_path = tmp.path().join("wt"); - Command::new("git") - .args([ - "worktree", - "add", - &wt_path.to_string_lossy(), - "-b", - "feature/story-9945_story_survived", - ]) - .current_dir(root) - .output() - .unwrap(); - - // Commit valid code on the feature branch. - fs::write(wt_path.join("src/lib.rs"), "pub fn survived() {}\n").unwrap(); - Command::new("git") - .args(["add", "."]) - .current_dir(&wt_path) - .output() - .unwrap(); - Command::new("git") - .args(["commit", "-m", "add survived fn"]) - .current_dir(&wt_path) - .output() - .unwrap(); - - // Set up the story in the content store. - crate::db::ensure_content_store(); - crate::db::write_content("9945_story_survived", "---\nname: Survived Test\n---\n"); - crate::db::write_item_with_content( - "9945_story_survived", - "2_current", - "---\nname: Survived Test\n---\n", - ); - - // Simulate a passing run_tests call during the agent's session (bug 668): - // the agent ran script/test, it passed, and the server captured the evidence. - crate::db::write_content("9945_story_survived:run_tests_ok", "1"); - - let pool = AgentPool::new_test(3001); - - // Simulate coder failing gates (e.g. agent crashed, dirty worktree). - pool.run_pipeline_advance( - "9945_story_survived", - "coder-1", - CompletionReport { - summary: "Agent crashed".to_string(), - gates_passed: false, - gate_output: "Worktree has uncommitted changes".to_string(), - }, - Some(root.to_path_buf()), - Some(wt_path), - false, - None, - ) - .await; - - // Story should have advanced — content store should reflect the move. - // The work-survived check should have moved it to QA (or merge for - // server qa mode), NOT incremented retry_count. - let content = crate::db::read_content("9945_story_survived") - .expect("story should exist in content store"); - assert!( - !content.contains("blocked"), - "story should NOT be blocked when committed work survives: {content}" - ); - assert!( - !content.contains("retry_count"), - "story should NOT have retry_count when work survived: {content}" - ); - } - - /// Backwards-compat: agents that die WITHOUT committed work still get - /// the existing retry/block treatment. - #[tokio::test] - async fn no_committed_work_still_retries_and_blocks() { - use std::fs; - use std::process::Command; - - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - // Init a git repo (no Cargo project needed — cargo check will fail). - Command::new("git") - .args(["init"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["config", "user.email", "test@test.com"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["config", "user.name", "Test"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["commit", "--allow-empty", "-m", "init"]) - .current_dir(root) - .output() - .unwrap(); - - // Create a worktree with NO commits on the feature branch. - let wt_path = tmp.path().join("wt"); - Command::new("git") - .args([ - "worktree", - "add", - &wt_path.to_string_lossy(), - "-b", - "feature/story-9946_story_nowork", - ]) - .current_dir(root) - .output() - .unwrap(); - - // Set up the story with max_retries=1 so it blocks immediately. - crate::db::ensure_content_store(); - crate::db::write_content("9946_story_nowork", "---\nname: No Work Test\n---\n"); - crate::db::write_item_with_content( - "9946_story_nowork", - "2_current", - "---\nname: No Work Test\n---\n", - ); - - // Write a project.toml with max_retries = 1. - fs::create_dir_all(root.join(".huskies")).unwrap(); - fs::write( - root.join(".huskies/project.toml"), - "max_retries = 1\n\n[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n", - ) - .unwrap(); - - let pool = AgentPool::new_test(3001); - let mut rx = pool.watcher_tx.subscribe(); - - // Simulate coder failing gates with NO committed work on the worktree. - pool.run_pipeline_advance( - "9946_story_nowork", - "coder-1", - CompletionReport { - summary: "Agent crashed".to_string(), - gates_passed: false, - gate_output: "Tests failed".to_string(), - }, - Some(root.to_path_buf()), - Some(wt_path), - false, - None, - ) - .await; - - // With no committed work and max_retries=1, the story should be blocked. - let mut got_blocked = false; - while let Ok(evt) = rx.try_recv() { - if let WatcherEvent::StoryBlocked { story_id, .. } = &evt - && story_id == "9946_story_nowork" - { - got_blocked = true; - break; - } - } - assert!( - got_blocked, - "Story with no committed work should be blocked after exceeding retry limit" - ); - } - - // ── bug 668: pipeline must NOT advance when gates_passed=false and no test evidence ── - - /// Path (a): gates_passed=false with committed work but NO captured run_tests - /// evidence → story stays in coding (retries), does NOT advance to QA/merge. - #[tokio::test] - async fn gates_failed_no_test_evidence_does_not_advance() { - use std::fs; - use std::process::Command; - - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - // Init a git repo with committed work on a feature branch. - Command::new("git") - .args(["init"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["config", "user.email", "test@test.com"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["config", "user.name", "Test"]) - .current_dir(root) - .output() - .unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname=\"t\"\nversion=\"0.1.0\"\nedition=\"2021\"\n", - ) - .unwrap(); - fs::create_dir_all(root.join("src")).unwrap(); - fs::write(root.join("src/lib.rs"), "// empty\n").unwrap(); - Command::new("git") - .args(["add", "."]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["commit", "-m", "init"]) - .current_dir(root) - .output() - .unwrap(); - - // Create a worktree with committed work on feature branch. - let wt_path = tmp.path().join("wt"); - Command::new("git") - .args([ - "worktree", - "add", - &wt_path.to_string_lossy(), - "-b", - "feature/story-9947_story_no_evidence", - ]) - .current_dir(root) - .output() - .unwrap(); - - fs::write(wt_path.join("src/lib.rs"), "pub fn added() {}\n").unwrap(); - Command::new("git") - .args(["add", "."]) - .current_dir(&wt_path) - .output() - .unwrap(); - Command::new("git") - .args(["commit", "-m", "add fn"]) - .current_dir(&wt_path) - .output() - .unwrap(); - - // Set up the story with max_retries=1 so we can observe the retry/block. - crate::db::ensure_content_store(); - crate::db::write_content( - "9947_story_no_evidence", - "---\nname: No Evidence Test\n---\n", - ); - crate::db::write_item_with_content( - "9947_story_no_evidence", - "2_current", - "---\nname: No Evidence Test\n---\n", - ); - - // Explicitly ensure no test evidence exists for this story. - crate::db::delete_content("9947_story_no_evidence:run_tests_ok"); - - fs::create_dir_all(root.join(".huskies")).unwrap(); - fs::write( - root.join(".huskies/project.toml"), - "max_retries = 1\n\n[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n", - ) - .unwrap(); - - let pool = AgentPool::new_test(3001); - let mut rx = pool.watcher_tx.subscribe(); - - // gates_passed=false, no run_tests evidence, but committed work exists. - pool.run_pipeline_advance( - "9947_story_no_evidence", - "coder-1", - CompletionReport { - summary: "Gates failed".to_string(), - gates_passed: false, - gate_output: "Tests failed".to_string(), - }, - Some(root.to_path_buf()), - Some(wt_path), - false, - None, - ) - .await; - - // Story must NOT advance — it should be blocked (max_retries=1 means - // first failure triggers block) rather than moving to QA/merge. - let mut got_blocked = false; - while let Ok(evt) = rx.try_recv() { - if let WatcherEvent::StoryBlocked { story_id, .. } = &evt - && story_id == "9947_story_no_evidence" - { - got_blocked = true; - break; - } - } - assert!( - got_blocked, - "gates_passed=false without run_tests evidence must NOT advance to QA/merge — \ - story should stay in coding (bug 668)" - ); - } - - /// Path (b): gates_passed=false WITH captured run_tests evidence AND committed - /// work → advances to QA/merge (the legitimate bug-645 salvage case). - /// This is the case where the agent ran passing tests then crashed before server - /// gates could confirm results. - #[tokio::test] - async fn gates_failed_with_test_evidence_and_committed_work_advances() { - use std::fs; - use std::process::Command; - - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - // Init a git repo with committed work. - Command::new("git") - .args(["init"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["config", "user.email", "test@test.com"]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["config", "user.name", "Test"]) - .current_dir(root) - .output() - .unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname=\"t\"\nversion=\"0.1.0\"\nedition=\"2021\"\n", - ) - .unwrap(); - fs::create_dir_all(root.join("src")).unwrap(); - fs::write(root.join("src/lib.rs"), "// empty\n").unwrap(); - Command::new("git") - .args(["add", "."]) - .current_dir(root) - .output() - .unwrap(); - Command::new("git") - .args(["commit", "-m", "init"]) - .current_dir(root) - .output() - .unwrap(); - - let wt_path = tmp.path().join("wt"); - Command::new("git") - .args([ - "worktree", - "add", - &wt_path.to_string_lossy(), - "-b", - "feature/story-9948_story_with_evidence", - ]) - .current_dir(root) - .output() - .unwrap(); - - fs::write(wt_path.join("src/lib.rs"), "pub fn salvaged() {}\n").unwrap(); - Command::new("git") - .args(["add", "."]) - .current_dir(&wt_path) - .output() - .unwrap(); - Command::new("git") - .args(["commit", "-m", "add salvaged fn"]) - .current_dir(&wt_path) - .output() - .unwrap(); - - crate::db::ensure_content_store(); - crate::db::write_content( - "9948_story_with_evidence", - "---\nname: With Evidence Test\n---\n", - ); - crate::db::write_item_with_content( - "9948_story_with_evidence", - "2_current", - "---\nname: With Evidence Test\n---\n", - ); - - // Write the run_tests evidence — simulates the agent having called run_tests - // MCP and getting a passing result before it crashed. - crate::db::write_content("9948_story_with_evidence:run_tests_ok", "1"); - - let pool = AgentPool::new_test(3001); - - // gates_passed=false (agent crashed), but test evidence exists. - pool.run_pipeline_advance( - "9948_story_with_evidence", - "coder-1", - CompletionReport { - summary: "Agent crashed".to_string(), - gates_passed: false, - gate_output: "PTY write assertion failed".to_string(), - }, - Some(root.to_path_buf()), - Some(wt_path), - false, - None, - ) - .await; - - // Story should advance (not blocked, no retry_count). - let content = crate::db::read_content("9948_story_with_evidence") - .expect("story must exist in content store"); - assert!( - !content.contains("blocked"), - "story must NOT be blocked when test evidence exists and work committed: {content}" - ); - assert!( - !content.contains("retry_count"), - "story must NOT have retry_count when salvaged via test evidence: {content}" - ); - // Evidence must be consumed (cleared) after use. - assert!( - crate::db::read_content("9948_story_with_evidence:run_tests_ok").is_none(), - "run_tests evidence must be cleared after pipeline advance consumes it" - ); - } -} +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_regression; diff --git a/server/src/agents/pool/pipeline/advance/tests.rs b/server/src/agents/pool/pipeline/advance/tests.rs new file mode 100644 index 00000000..b202522d --- /dev/null +++ b/server/src/agents/pool/pipeline/advance/tests.rs @@ -0,0 +1,237 @@ +//! Basic pipeline advance tests. +use super::super::super::AgentPool; +use crate::agents::CompletionReport; +use crate::io::watcher::WatcherEvent; + +// ── pipeline advance tests ──────────────────────────────────────────────── + +#[tokio::test] +async fn pipeline_advance_coder_gates_pass_server_qa_moves_to_merge() { + use std::fs; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Set up story in 2_current/ (no qa frontmatter → uses project default "server"). + // Use a unique high-numbered ID to avoid collision with the agent_qa test. + let current = root.join(".huskies/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + fs::write(current.join("9908_story_server_qa.md"), "test").unwrap(); + crate::db::ensure_content_store(); + crate::db::write_content("9908_story_server_qa", "test"); + + let pool = AgentPool::new_test(3001); + pool.run_pipeline_advance( + "9908_story_server_qa", + "coder-1", + CompletionReport { + summary: "done".to_string(), + gates_passed: true, + gate_output: String::new(), + }, + Some(root.to_path_buf()), + None, + false, + None, + ) + .await; + + // With default qa: server, story skips QA and goes straight to 4_merge/ + // Lifecycle moves now update the content store, not the filesystem. + assert!( + crate::db::read_content("9908_story_server_qa").is_some(), + "story should still exist in content store after move to merge" + ); +} + +#[tokio::test] +async fn pipeline_advance_coder_gates_pass_agent_qa_moves_to_qa() { + use std::fs; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Set up story in 2_current/ with qa: agent frontmatter. + // Use a unique high-numbered ID to avoid collision with the server_qa test. + let current = root.join(".huskies/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + fs::write( + current.join("9909_story_agent_qa.md"), + "---\nname: Test\nqa: agent\n---\ntest", + ) + .unwrap(); + crate::db::ensure_content_store(); + crate::db::write_content( + "9909_story_agent_qa", + "---\nname: Test\nqa: agent\n---\ntest", + ); + + let pool = AgentPool::new_test(3001); + pool.run_pipeline_advance( + "9909_story_agent_qa", + "coder-1", + CompletionReport { + summary: "done".to_string(), + gates_passed: true, + gate_output: String::new(), + }, + Some(root.to_path_buf()), + None, + false, + None, + ) + .await; + + // With qa: agent, story should move to 3_qa/ + // Lifecycle moves now update the content store, not the filesystem. + assert!( + crate::db::read_content("9909_story_agent_qa").is_some(), + "story should still exist in content store after move to qa" + ); +} + +#[tokio::test] +async fn pipeline_advance_qa_gates_pass_moves_story_to_merge() { + use std::fs; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Set up story in 3_qa/ + let qa_dir = root.join(".huskies/work/3_qa"); + fs::create_dir_all(&qa_dir).unwrap(); + // qa: server so the story skips human review and goes straight to merge. + fs::write( + qa_dir.join("51_story_test.md"), + "---\nname: Test\nqa: server\n---\ntest", + ) + .unwrap(); + crate::db::ensure_content_store(); + crate::db::write_content("51_story_test", "---\nname: Test\nqa: server\n---\ntest"); + + let pool = AgentPool::new_test(3001); + pool.run_pipeline_advance( + "51_story_test", + "qa", + CompletionReport { + summary: "QA done".to_string(), + gates_passed: true, + gate_output: String::new(), + }, + Some(root.to_path_buf()), + None, + false, + None, + ) + .await; + + // Story should have moved to 4_merge/ + // Lifecycle moves now update the content store, not the filesystem. + assert!( + crate::db::read_content("51_story_test").is_some(), + "story should still exist in content store after move to merge" + ); +} + +#[tokio::test] +async fn pipeline_advance_supervisor_does_not_advance() { + use std::fs; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + let current = root.join(".huskies/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + fs::write(current.join("52_story_test.md"), "test").unwrap(); + + let pool = AgentPool::new_test(3001); + pool.run_pipeline_advance( + "52_story_test", + "supervisor", + CompletionReport { + summary: "supervised".to_string(), + gates_passed: true, + gate_output: String::new(), + }, + Some(root.to_path_buf()), + None, + false, + None, + ) + .await; + + // Story should NOT have moved (supervisors don't advance pipeline) + assert!( + current.join("52_story_test.md").exists(), + "story should still be in 2_current/ for supervisor" + ); +} + +#[tokio::test] +async fn pipeline_advance_sends_agent_state_changed_to_watcher_tx() { + use std::fs; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Seed story via CRDT (the only source of truth). + crate::db::ensure_content_store(); + crate::db::write_item_with_content("173_story_test", "2_current", "---\nname: test\n---\n"); + + // Write a project.toml with a qa agent so start_agent can resolve it. + fs::create_dir_all(root.join(".huskies")).unwrap(); + fs::write( + root.join(".huskies/project.toml"), + r#" +default_qa = "agent" + +[[agent]] +name = "coder-1" +role = "Coder" +command = "echo" +args = ["noop"] +prompt = "test" +stage = "coder" + +[[agent]] +name = "qa" +role = "QA" +command = "echo" +args = ["noop"] +prompt = "test" +stage = "qa" +"#, + ) + .unwrap(); + + let pool = AgentPool::new_test(3001); + // Subscribe to the watcher channel BEFORE the pipeline advance. + let mut rx = pool.watcher_tx.subscribe(); + + pool.run_pipeline_advance( + "173_story_test", + "coder-1", + CompletionReport { + summary: "done".to_string(), + gates_passed: true, + gate_output: String::new(), + }, + Some(root.to_path_buf()), + None, + false, + None, + ) + .await; + + // The pipeline advance should have sent AgentStateChanged events via + // the pool's watcher_tx (not a dummy channel). Collect all events. + let mut got_agent_state_changed = false; + while let Ok(evt) = rx.try_recv() { + if matches!(evt, WatcherEvent::AgentStateChanged) { + got_agent_state_changed = true; + break; + } + } + + assert!( + got_agent_state_changed, + "pipeline advance should send AgentStateChanged through the real watcher_tx \ + (bug 173: lozenges must update when agents are assigned during pipeline advance)" + ); +} diff --git a/server/src/agents/pool/pipeline/advance/tests_regression.rs b/server/src/agents/pool/pipeline/advance/tests_regression.rs new file mode 100644 index 00000000..c8114513 --- /dev/null +++ b/server/src/agents/pool/pipeline/advance/tests_regression.rs @@ -0,0 +1,775 @@ +//! Regression tests for pipeline advance (bugs 295, 519, 529, 645, 668). +use super::super::super::{AgentPool, composite_key}; +use crate::agents::{AgentStatus, CompletionReport}; +use crate::io::watcher::WatcherEvent; + +// ── story 519: mergemaster pre-flight blocks when no commits ahead ── + +/// Regression test for story 519: when the feature branch has zero commits +/// ahead of master, mergemaster must not spawn a Claude session. A no-op +/// session spent $0.82 in the 2026-04-09 incident because the worktree was +/// reset to master before mergemaster ran. +#[tokio::test] +async fn mergemaster_blocks_and_sends_story_blocked_when_no_commits_ahead() { + use std::fs; + use std::process::Command; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Init a bare git repo on master with one empty commit. + Command::new("git") + .args(["init"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(root) + .output() + .unwrap(); + + // Create a feature branch that points at master HEAD (zero commits ahead). + // This replicates the incident where the worktree was reset to master. + Command::new("git") + .args(["branch", "feature/story-9919_story_no_commits"]) + .current_dir(root) + .output() + .unwrap(); + + // Set up pipeline dirs and story file. + let current = root.join(".huskies/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + fs::create_dir_all(root.join(".huskies/work/4_merge")).unwrap(); + fs::write( + current.join("9919_story_no_commits.md"), + "---\nname: Test\n---\n", + ) + .unwrap(); + crate::db::ensure_content_store(); + crate::db::write_content("9919_story_no_commits", "---\nname: Test\n---\n"); + + let pool = AgentPool::new_test(3001); + let mut rx = pool.watcher_tx.subscribe(); + + // Simulate coder completing with gates passed (qa: server → goes to merge). + pool.run_pipeline_advance( + "9919_story_no_commits", + "coder-1", + CompletionReport { + summary: "done".to_string(), + gates_passed: true, + gate_output: String::new(), + }, + Some(root.to_path_buf()), + None, + false, + None, + ) + .await; + + // Story should still exist in the content store after moving to merge. + assert!( + crate::db::read_content("9919_story_no_commits").is_some(), + "story should remain in content store — not removed" + ); + + // A StoryBlocked event must have been emitted (triggers chat failure notice, + // not the success 🎉 emoji). + let mut got_blocked = false; + while let Ok(evt) = rx.try_recv() { + if let WatcherEvent::StoryBlocked { story_id, .. } = &evt + && story_id == "9919_story_no_commits" + { + got_blocked = true; + break; + } + } + assert!( + got_blocked, + "StoryBlocked event must be sent when feature branch has no commits ahead of master" + ); + + // No mergemaster agent should have been started. + let agents = pool.agents.lock().unwrap(); + let mergemaster_started = agents + .values() + .any(|a| a.agent_name.contains("mergemaster")); + assert!( + !mergemaster_started, + "mergemaster agent must NOT be started when no commits ahead of master" + ); +} + +// ── bug 295: pipeline advance picks up waiting QA stories ────────── + +#[tokio::test] +async fn pipeline_advance_picks_up_waiting_qa_stories_after_completion() { + use super::super::super::auto_assign::is_agent_free; + use std::fs; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + let sk = root.join(".huskies"); + fs::create_dir_all(&sk).unwrap(); + + // Configure a single QA agent. + fs::write( + sk.join("project.toml"), + r#" +[[agent]] +name = "qa" +stage = "qa" +"#, + ) + .unwrap(); + + // Seed stories via CRDT (the only source of truth). + crate::db::ensure_content_store(); + // Story 292 is in QA with QA agent running (will "complete" via + // run_pipeline_advance below). Story 293 is in QA with NO agent — + // simulating the "stuck" state from bug 295. + crate::db::write_item_with_content( + "292_story_first", + "3_qa", + "---\nname: First\nqa: human\n---\n", + ); + crate::db::write_item_with_content( + "293_story_second", + "3_qa", + "---\nname: Second\nqa: human\n---\n", + ); + + let pool = AgentPool::new_test(3001); + // QA is currently running on story 292. + pool.inject_test_agent("292_story_first", "qa", AgentStatus::Running); + + // Verify that 293 cannot get a QA agent right now (QA is busy). + { + let agents = pool.agents.lock().unwrap(); + assert!( + !is_agent_free(&agents, "qa"), + "qa should be busy on story 292" + ); + } + + // Simulate QA completing on story 292: remove the agent from the pool + // (as run_server_owned_completion does) then run pipeline advance. + { + let mut agents = pool.agents.lock().unwrap(); + agents.remove(&composite_key("292_story_first", "qa")); + } + + pool.run_pipeline_advance( + "292_story_first", + "qa", + CompletionReport { + summary: "QA done".to_string(), + gates_passed: true, + gate_output: String::new(), + }, + Some(root.to_path_buf()), + None, + false, + None, + ) + .await; + + // After pipeline advance, auto_assign should have started QA on story 293. + let agents = pool.agents.lock().unwrap(); + let qa_on_293 = agents.values().any(|a| { + a.agent_name == "qa" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running) + }); + assert!( + qa_on_293, + "auto_assign should have started qa for story 293 after 292's QA completed, \ + but no qa agent is pending/running. Pool: {:?}", + agents + .iter() + .map(|(k, a)| format!("{k}: {} ({})", a.agent_name, a.status)) + .collect::>() + ); +} + +// ── bug 529: stale mergemaster advance for a done story is a no-op ── + +/// Regression test for bug 529: when a stale mergemaster advance fires +/// after the story has already reached 5_done, the advance must be a +/// no-op — no post-merge tests, no notifications, no agent restarts. +#[tokio::test] +async fn stale_mergemaster_advance_for_done_story_is_noop() { + use std::fs; + use std::process::Command; + + // Initialise CRDT so read_typed works. + crate::crdt_state::init_for_test(); + crate::db::ensure_content_store(); + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Init a git repo so post-merge tests would pass if they ran. + Command::new("git") + .args(["init"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(root) + .output() + .unwrap(); + + // Set up pipeline dirs. + fs::create_dir_all(root.join(".huskies/work/5_done")).unwrap(); + + // Seed the story in 5_done via the DB, which also writes to the CRDT. + let story_id = "9929_story_zombie_merge"; + let content = "---\nname: Zombie Merge Test\n---\n"; + crate::db::write_content(story_id, content); + crate::db::write_item_with_content(story_id, "5_done", content); + + let pool = AgentPool::new_test(3001); + let mut rx = pool.watcher_tx.subscribe(); + + // Simulate a stale mergemaster advance firing for the already-done story. + pool.run_pipeline_advance( + story_id, + "mergemaster", + CompletionReport { + summary: "stale advance".to_string(), + gates_passed: true, + gate_output: String::new(), + }, + Some(root.to_path_buf()), + None, + false, + None, + ) + .await; + + // No agents should have been started. + let agents = pool.agents.lock().unwrap(); + assert!( + agents.is_empty(), + "No agents should be started for a stale advance on a done story. \ + Pool: {:?}", + agents.keys().collect::>() + ); + drop(agents); + + // No StoryBlocked or other events should have been emitted. + let mut got_event = false; + while let Ok(evt) = rx.try_recv() { + // AgentStateChanged from auto_assign is acceptable only if the + // advance didn't short-circuit. Since we return early, no events. + if matches!(evt, WatcherEvent::StoryBlocked { .. }) { + got_event = true; + } + } + assert!( + !got_event, + "No StoryBlocked event should be emitted for a stale advance" + ); + + // The story should still be in 5_done (not moved elsewhere). + if let Ok(Some(item)) = crate::pipeline_state::read_typed(story_id) { + assert_eq!( + item.stage.dir_name(), + "5_done", + "Story should remain in 5_done after stale mergemaster advance" + ); + } +} + +// ── bug 645: work-survived check advances to QA instead of blocking ── + +/// Integration test: when a coder agent fails gates but committed work +/// survives and compiles, the story advances to QA (not retry/block). +/// Simulates an agent that commits work and then dies mid-output. +#[tokio::test] +async fn work_survived_advances_to_qa_instead_of_blocking() { + use std::fs; + use std::process::Command; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Init a git repo with a minimal Cargo project. + Command::new("git") + .args(["init"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(root) + .output() + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"test_proj\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/lib.rs"), "// empty\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(root) + .output() + .unwrap(); + + // Create a worktree on a feature branch. + let wt_path = tmp.path().join("wt"); + Command::new("git") + .args([ + "worktree", + "add", + &wt_path.to_string_lossy(), + "-b", + "feature/story-9945_story_survived", + ]) + .current_dir(root) + .output() + .unwrap(); + + // Commit valid code on the feature branch. + fs::write(wt_path.join("src/lib.rs"), "pub fn survived() {}\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(&wt_path) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add survived fn"]) + .current_dir(&wt_path) + .output() + .unwrap(); + + // Set up the story in the content store. + crate::db::ensure_content_store(); + crate::db::write_content("9945_story_survived", "---\nname: Survived Test\n---\n"); + crate::db::write_item_with_content( + "9945_story_survived", + "2_current", + "---\nname: Survived Test\n---\n", + ); + + // Simulate a passing run_tests call during the agent's session (bug 668): + // the agent ran script/test, it passed, and the server captured the evidence. + crate::db::write_content("9945_story_survived:run_tests_ok", "1"); + + let pool = AgentPool::new_test(3001); + + // Simulate coder failing gates (e.g. agent crashed, dirty worktree). + pool.run_pipeline_advance( + "9945_story_survived", + "coder-1", + CompletionReport { + summary: "Agent crashed".to_string(), + gates_passed: false, + gate_output: "Worktree has uncommitted changes".to_string(), + }, + Some(root.to_path_buf()), + Some(wt_path), + false, + None, + ) + .await; + + // Story should have advanced — content store should reflect the move. + // The work-survived check should have moved it to QA (or merge for + // server qa mode), NOT incremented retry_count. + let content = crate::db::read_content("9945_story_survived") + .expect("story should exist in content store"); + assert!( + !content.contains("blocked"), + "story should NOT be blocked when committed work survives: {content}" + ); + assert!( + !content.contains("retry_count"), + "story should NOT have retry_count when work survived: {content}" + ); +} + +/// Backwards-compat: agents that die WITHOUT committed work still get +/// the existing retry/block treatment. +#[tokio::test] +async fn no_committed_work_still_retries_and_blocks() { + use std::fs; + use std::process::Command; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Init a git repo (no Cargo project needed — cargo check will fail). + Command::new("git") + .args(["init"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(root) + .output() + .unwrap(); + + // Create a worktree with NO commits on the feature branch. + let wt_path = tmp.path().join("wt"); + Command::new("git") + .args([ + "worktree", + "add", + &wt_path.to_string_lossy(), + "-b", + "feature/story-9946_story_nowork", + ]) + .current_dir(root) + .output() + .unwrap(); + + // Set up the story with max_retries=1 so it blocks immediately. + crate::db::ensure_content_store(); + crate::db::write_content("9946_story_nowork", "---\nname: No Work Test\n---\n"); + crate::db::write_item_with_content( + "9946_story_nowork", + "2_current", + "---\nname: No Work Test\n---\n", + ); + + // Write a project.toml with max_retries = 1. + fs::create_dir_all(root.join(".huskies")).unwrap(); + fs::write( + root.join(".huskies/project.toml"), + "max_retries = 1\n\n[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n", + ) + .unwrap(); + + let pool = AgentPool::new_test(3001); + let mut rx = pool.watcher_tx.subscribe(); + + // Simulate coder failing gates with NO committed work on the worktree. + pool.run_pipeline_advance( + "9946_story_nowork", + "coder-1", + CompletionReport { + summary: "Agent crashed".to_string(), + gates_passed: false, + gate_output: "Tests failed".to_string(), + }, + Some(root.to_path_buf()), + Some(wt_path), + false, + None, + ) + .await; + + // With no committed work and max_retries=1, the story should be blocked. + let mut got_blocked = false; + while let Ok(evt) = rx.try_recv() { + if let WatcherEvent::StoryBlocked { story_id, .. } = &evt + && story_id == "9946_story_nowork" + { + got_blocked = true; + break; + } + } + assert!( + got_blocked, + "Story with no committed work should be blocked after exceeding retry limit" + ); +} + +// ── bug 668: pipeline must NOT advance when gates_passed=false and no test evidence ── + +/// Path (a): gates_passed=false with committed work but NO captured run_tests +/// evidence → story stays in coding (retries), does NOT advance to QA/merge. +#[tokio::test] +async fn gates_failed_no_test_evidence_does_not_advance() { + use std::fs; + use std::process::Command; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Init a git repo with committed work on a feature branch. + Command::new("git") + .args(["init"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(root) + .output() + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname=\"t\"\nversion=\"0.1.0\"\nedition=\"2021\"\n", + ) + .unwrap(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/lib.rs"), "// empty\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(root) + .output() + .unwrap(); + + // Create a worktree with committed work on feature branch. + let wt_path = tmp.path().join("wt"); + Command::new("git") + .args([ + "worktree", + "add", + &wt_path.to_string_lossy(), + "-b", + "feature/story-9947_story_no_evidence", + ]) + .current_dir(root) + .output() + .unwrap(); + + fs::write(wt_path.join("src/lib.rs"), "pub fn added() {}\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(&wt_path) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add fn"]) + .current_dir(&wt_path) + .output() + .unwrap(); + + // Set up the story with max_retries=1 so we can observe the retry/block. + crate::db::ensure_content_store(); + crate::db::write_content( + "9947_story_no_evidence", + "---\nname: No Evidence Test\n---\n", + ); + crate::db::write_item_with_content( + "9947_story_no_evidence", + "2_current", + "---\nname: No Evidence Test\n---\n", + ); + + // Explicitly ensure no test evidence exists for this story. + crate::db::delete_content("9947_story_no_evidence:run_tests_ok"); + + fs::create_dir_all(root.join(".huskies")).unwrap(); + fs::write( + root.join(".huskies/project.toml"), + "max_retries = 1\n\n[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n", + ) + .unwrap(); + + let pool = AgentPool::new_test(3001); + let mut rx = pool.watcher_tx.subscribe(); + + // gates_passed=false, no run_tests evidence, but committed work exists. + pool.run_pipeline_advance( + "9947_story_no_evidence", + "coder-1", + CompletionReport { + summary: "Gates failed".to_string(), + gates_passed: false, + gate_output: "Tests failed".to_string(), + }, + Some(root.to_path_buf()), + Some(wt_path), + false, + None, + ) + .await; + + // Story must NOT advance — it should be blocked (max_retries=1 means + // first failure triggers block) rather than moving to QA/merge. + let mut got_blocked = false; + while let Ok(evt) = rx.try_recv() { + if let WatcherEvent::StoryBlocked { story_id, .. } = &evt + && story_id == "9947_story_no_evidence" + { + got_blocked = true; + break; + } + } + assert!( + got_blocked, + "gates_passed=false without run_tests evidence must NOT advance to QA/merge — \ + story should stay in coding (bug 668)" + ); +} + +/// Path (b): gates_passed=false WITH captured run_tests evidence AND committed +/// work → advances to QA/merge (the legitimate bug-645 salvage case). +/// This is the case where the agent ran passing tests then crashed before server +/// gates could confirm results. +#[tokio::test] +async fn gates_failed_with_test_evidence_and_committed_work_advances() { + use std::fs; + use std::process::Command; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Init a git repo with committed work. + Command::new("git") + .args(["init"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(root) + .output() + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname=\"t\"\nversion=\"0.1.0\"\nedition=\"2021\"\n", + ) + .unwrap(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/lib.rs"), "// empty\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(root) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(root) + .output() + .unwrap(); + + let wt_path = tmp.path().join("wt"); + Command::new("git") + .args([ + "worktree", + "add", + &wt_path.to_string_lossy(), + "-b", + "feature/story-9948_story_with_evidence", + ]) + .current_dir(root) + .output() + .unwrap(); + + fs::write(wt_path.join("src/lib.rs"), "pub fn salvaged() {}\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(&wt_path) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add salvaged fn"]) + .current_dir(&wt_path) + .output() + .unwrap(); + + crate::db::ensure_content_store(); + crate::db::write_content( + "9948_story_with_evidence", + "---\nname: With Evidence Test\n---\n", + ); + crate::db::write_item_with_content( + "9948_story_with_evidence", + "2_current", + "---\nname: With Evidence Test\n---\n", + ); + + // Write the run_tests evidence — simulates the agent having called run_tests + // MCP and getting a passing result before it crashed. + crate::db::write_content("9948_story_with_evidence:run_tests_ok", "1"); + + let pool = AgentPool::new_test(3001); + + // gates_passed=false (agent crashed), but test evidence exists. + pool.run_pipeline_advance( + "9948_story_with_evidence", + "coder-1", + CompletionReport { + summary: "Agent crashed".to_string(), + gates_passed: false, + gate_output: "PTY write assertion failed".to_string(), + }, + Some(root.to_path_buf()), + Some(wt_path), + false, + None, + ) + .await; + + // Story should advance (not blocked, no retry_count). + let content = crate::db::read_content("9948_story_with_evidence") + .expect("story must exist in content store"); + assert!( + !content.contains("blocked"), + "story must NOT be blocked when test evidence exists and work committed: {content}" + ); + assert!( + !content.contains("retry_count"), + "story must NOT have retry_count when salvaged via test evidence: {content}" + ); + // Evidence must be consumed (cleared) after use. + assert!( + crate::db::read_content("9948_story_with_evidence:run_tests_ok").is_none(), + "run_tests evidence must be cleared after pipeline advance consumes it" + ); +}