huskies: merge 910
This commit is contained in:
@@ -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
|
/// AC4 (bug 651 regression for 645): when an agent crashes with committed
|
||||||
/// work AND uncommitted noise, the auto-advance still picks up the
|
/// work AND uncommitted noise, the auto-advance still picks up the
|
||||||
/// committed work. The committed-state check is authoritative; the
|
/// committed work. The committed-state check is authoritative; the
|
||||||
|
|||||||
@@ -199,6 +199,48 @@ impl AgentPool {
|
|||||||
.and_then(|a| a.status_buffer.as_ref().map(|b| b.drain()))
|
.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<AgentEvent> {
|
||||||
|
let (tx, _) = broadcast::channel::<AgentEvent>(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.
|
/// Inject a Running agent with a pre-built (possibly finished) task handle.
|
||||||
/// Used by watchdog tests to simulate an orphaned agent.
|
/// Used by watchdog tests to simulate an orphaned agent.
|
||||||
pub fn inject_test_agent_with_handle(
|
pub fn inject_test_agent_with_handle(
|
||||||
|
|||||||
Reference in New Issue
Block a user