diff --git a/server/src/agents/pool/pipeline/completion/tests.rs b/server/src/agents/pool/pipeline/completion/tests.rs index 0d560737..8cad2265 100644 --- a/server/src/agents/pool/pipeline/completion/tests.rs +++ b/server/src/agents/pool/pipeline/completion/tests.rs @@ -496,6 +496,127 @@ async fn watchdog_kill_preserves_uncommitted_diff() { ); } +/// Story 910 regression: a coder that exits with zero commits on the feature +/// branch must NOT be promoted to Merge. The server-owned completion path +/// detects `git rev-list master..HEAD == 0`, records `gates_passed=false`, +/// and the pipeline advance retries (or blocks at the cap) instead of +/// advancing the story. +#[tokio::test] +async fn zero_commit_coder_exit_stays_in_coding_not_promoted_to_merge() { + use std::fs; + use std::process::Command; + + let tmp = tempfile::tempdir().unwrap(); + let project_root = tmp.path().join("project"); + fs::create_dir_all(&project_root).unwrap(); + + // Init a git repo with an initial master commit. + Command::new("git") + .args(["init"]) + .current_dir(&project_root) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(&project_root) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(&project_root) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(&project_root) + .output() + .unwrap(); + + // Create a feature-branch worktree with ZERO commits ahead of master. + let wt_path = tmp.path().join("wt"); + Command::new("git") + .args([ + "worktree", + "add", + &wt_path.to_string_lossy(), + "-b", + "feature/story-9910_zero_exit", + ]) + .current_dir(&project_root) + .output() + .unwrap(); + + // Set up the story with max_retries=1 so it blocks on the first failure. + crate::crdt_state::init_for_test(); + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "9910_zero_exit", + "2_current", + "---\nname: Zero Exit Test\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Zero Exit Test\n---\n"), + ); + + fs::create_dir_all(project_root.join(".huskies")).unwrap(); + fs::write( + project_root.join(".huskies/project.toml"), + "max_retries = 1\n\n[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n", + ) + .unwrap(); + + let pool = AgentPool::new_test(3001); + pool.inject_test_agent_with_root_and_path( + "9910_zero_exit", + "coder-1", + AgentStatus::Running, + project_root.clone(), + wt_path.clone(), + ); + + let mut rx = pool.watcher_tx.subscribe(); + + run_server_owned_completion( + &pool.agents, + pool.port, + "9910_zero_exit", + "coder-1", + None, + pool.watcher_tx.clone(), + ) + .await; + + // The pipeline advance spawns asynchronously — poll with a timeout. + let mut got_blocked = false; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + while tokio::time::Instant::now() < deadline { + while let Ok(evt) = rx.try_recv() { + if let crate::io::watcher::WatcherEvent::StoryBlocked { story_id, .. } = &evt + && story_id == "9910_zero_exit" + { + got_blocked = true; + } + } + if got_blocked { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + assert!( + got_blocked, + "Story 910 regression: a zero-commit coder exit must block/retry \ + the story rather than advancing it to Merge" + ); + + // The story must NOT be in 4_merge. + if let Ok(Some(item)) = crate::pipeline_state::read_typed("9910_zero_exit") { + assert_ne!( + item.stage.dir_name(), + "4_merge", + "Story must NOT be in Merge after a zero-commit coder exit" + ); + } +} + /// AC4 (bug 651 regression for 645): when an agent crashes with committed /// work AND uncommitted noise, the auto-advance still picks up the /// committed work. The committed-state check is authoritative; the diff --git a/server/src/agents/pool/test_helpers.rs b/server/src/agents/pool/test_helpers.rs index 414cf214..d322f7c4 100644 --- a/server/src/agents/pool/test_helpers.rs +++ b/server/src/agents/pool/test_helpers.rs @@ -199,6 +199,48 @@ impl AgentPool { .and_then(|a| a.status_buffer.as_ref().map(|b| b.drain())) } + /// Test helper: inject an agent with a project root AND a worktree path. + /// + /// Use this when the full server-owned completion path needs both a + /// `project_root` (so `run_pipeline_advance` can load config and advance + /// the story) and a `worktree_info` (so gate checks can inspect the branch). + pub fn inject_test_agent_with_root_and_path( + &self, + story_id: &str, + agent_name: &str, + status: AgentStatus, + project_root: PathBuf, + worktree_path: PathBuf, + ) -> broadcast::Sender { + let (tx, _) = broadcast::channel::(64); + let key = composite_key(story_id, agent_name); + let mut agents = self.agents.lock().unwrap(); + agents.insert( + key, + StoryAgent { + agent_name: agent_name.to_string(), + status, + worktree_info: Some(WorktreeInfo { + path: worktree_path, + branch: format!("feature/story-{story_id}"), + base_branch: "master".to_string(), + }), + session_id: None, + tx: tx.clone(), + task_handle: None, + event_log: Arc::new(Mutex::new(Vec::new())), + completion: None, + project_root: Some(project_root), + log_session_id: None, + merge_failure_reported: false, + throttled: false, + termination_reason: None, + status_buffer: None, + }, + ); + tx + } + /// Inject a Running agent with a pre-built (possibly finished) task handle. /// Used by watchdog tests to simulate an orphaned agent. pub fn inject_test_agent_with_handle(