2026-04-29 09:25:05 +00:00
|
|
|
//! Tests for agent pipeline completion handling.
|
2026-04-28 15:16:05 +00:00
|
|
|
use super::super::super::AgentPool;
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::agents::{AgentEvent, AgentStatus, CompletionReport};
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::process::Command;
|
|
|
|
|
|
|
|
|
|
fn init_git_repo(repo: &std::path::Path) {
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["init"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["config", "user.email", "test@test.com"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["config", "user.name", "Test"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["commit", "--allow-empty", "-m", "init"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── report_completion tests ────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn report_completion_rejects_nonexistent_agent() {
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
let result = pool.report_completion("no_story", "no_bot", "done").await;
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
let msg = result.unwrap_err();
|
|
|
|
|
assert!(msg.contains("No agent"), "unexpected: {msg}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn report_completion_rejects_non_running_agent() {
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
pool.inject_test_agent("s6", "bot", AgentStatus::Completed);
|
|
|
|
|
|
|
|
|
|
let result = pool.report_completion("s6", "bot", "done").await;
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
let msg = result.unwrap_err();
|
|
|
|
|
assert!(
|
|
|
|
|
msg.contains("not running"),
|
|
|
|
|
"expected 'not running' in: {msg}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn report_completion_rejects_dirty_worktree() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
let tmp = tempdir().unwrap();
|
|
|
|
|
let repo = tmp.path();
|
|
|
|
|
|
|
|
|
|
// Init a real git repo and make an initial commit
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["init"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["commit", "--allow-empty", "-m", "init"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// Write an uncommitted file
|
|
|
|
|
fs::write(repo.join("dirty.txt"), "not committed").unwrap();
|
|
|
|
|
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
pool.inject_test_agent_with_path("s7", "bot", AgentStatus::Running, repo.to_path_buf());
|
|
|
|
|
|
|
|
|
|
let result = pool.report_completion("s7", "bot", "done").await;
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
let msg = result.unwrap_err();
|
|
|
|
|
assert!(
|
|
|
|
|
msg.contains("uncommitted"),
|
|
|
|
|
"expected 'uncommitted' in: {msg}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── server-owned completion tests ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn server_owned_completion_skips_when_already_completed() {
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
let report = CompletionReport {
|
|
|
|
|
summary: "Already done".to_string(),
|
|
|
|
|
gates_passed: true,
|
|
|
|
|
gate_output: String::new(),
|
|
|
|
|
};
|
|
|
|
|
pool.inject_test_agent_with_completion(
|
|
|
|
|
"s10",
|
|
|
|
|
"coder-1",
|
|
|
|
|
AgentStatus::Completed,
|
|
|
|
|
PathBuf::from("/tmp/nonexistent"),
|
|
|
|
|
report,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Subscribe before calling so we can check if Done event was emitted.
|
|
|
|
|
let mut rx = pool.subscribe("s10", "coder-1").unwrap();
|
|
|
|
|
|
|
|
|
|
run_server_owned_completion(
|
|
|
|
|
&pool.agents,
|
|
|
|
|
pool.port,
|
|
|
|
|
"s10",
|
|
|
|
|
"coder-1",
|
|
|
|
|
Some("sess-1".to_string()),
|
|
|
|
|
pool.watcher_tx.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
// Status should remain Completed (unchanged) — no gate re-run.
|
|
|
|
|
let agents = pool.agents.lock().unwrap();
|
|
|
|
|
let key = super::super::super::composite_key("s10", "coder-1");
|
|
|
|
|
let agent = agents.get(&key).unwrap();
|
|
|
|
|
assert_eq!(agent.status, AgentStatus::Completed);
|
|
|
|
|
// Summary should still be the original, not overwritten.
|
|
|
|
|
assert_eq!(agent.completion.as_ref().unwrap().summary, "Already done");
|
|
|
|
|
drop(agents);
|
|
|
|
|
|
|
|
|
|
// No Done event should have been emitted.
|
|
|
|
|
assert!(
|
|
|
|
|
rx.try_recv().is_err(),
|
|
|
|
|
"should not emit Done when completion already exists"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn server_owned_completion_runs_gates_on_clean_worktree() {
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
let tmp = tempdir().unwrap();
|
|
|
|
|
let repo = tmp.path();
|
|
|
|
|
init_git_repo(repo);
|
|
|
|
|
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
pool.inject_test_agent_with_path("s11", "coder-1", AgentStatus::Running, repo.to_path_buf());
|
|
|
|
|
|
|
|
|
|
let mut rx = pool.subscribe("s11", "coder-1").unwrap();
|
|
|
|
|
|
|
|
|
|
run_server_owned_completion(
|
|
|
|
|
&pool.agents,
|
|
|
|
|
pool.port,
|
|
|
|
|
"s11",
|
|
|
|
|
"coder-1",
|
|
|
|
|
Some("sess-2".to_string()),
|
|
|
|
|
pool.watcher_tx.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
// Agent entry should be removed from the map after completion.
|
|
|
|
|
let agents = pool.agents.lock().unwrap();
|
|
|
|
|
let key = super::super::super::composite_key("s11", "coder-1");
|
|
|
|
|
assert!(
|
|
|
|
|
agents.get(&key).is_none(),
|
|
|
|
|
"agent should be removed from map after completion"
|
|
|
|
|
);
|
|
|
|
|
drop(agents);
|
|
|
|
|
|
|
|
|
|
// A Done event should have been emitted with the session_id.
|
|
|
|
|
let event = rx.try_recv().expect("should emit Done event");
|
|
|
|
|
match &event {
|
|
|
|
|
AgentEvent::Done { session_id, .. } => {
|
|
|
|
|
assert_eq!(*session_id, Some("sess-2".to_string()));
|
|
|
|
|
}
|
|
|
|
|
other => panic!("expected Done event, got: {other:?}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn server_owned_completion_fails_on_dirty_worktree() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
let tmp = tempdir().unwrap();
|
|
|
|
|
let repo = tmp.path();
|
|
|
|
|
init_git_repo(repo);
|
|
|
|
|
// Create an uncommitted file.
|
|
|
|
|
fs::write(repo.join("dirty.txt"), "not committed").unwrap();
|
|
|
|
|
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
pool.inject_test_agent_with_path("s12", "coder-1", AgentStatus::Running, repo.to_path_buf());
|
|
|
|
|
|
|
|
|
|
let mut rx = pool.subscribe("s12", "coder-1").unwrap();
|
|
|
|
|
|
|
|
|
|
run_server_owned_completion(
|
|
|
|
|
&pool.agents,
|
|
|
|
|
pool.port,
|
|
|
|
|
"s12",
|
|
|
|
|
"coder-1",
|
|
|
|
|
None,
|
|
|
|
|
pool.watcher_tx.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
// Agent entry should be removed from the map after completion (even on failure).
|
|
|
|
|
let agents = pool.agents.lock().unwrap();
|
|
|
|
|
let key = super::super::super::composite_key("s12", "coder-1");
|
|
|
|
|
assert!(
|
|
|
|
|
agents.get(&key).is_none(),
|
|
|
|
|
"agent should be removed from map after failed completion"
|
|
|
|
|
);
|
|
|
|
|
drop(agents);
|
|
|
|
|
|
|
|
|
|
// A Done event should have been emitted.
|
|
|
|
|
let event = rx.try_recv().expect("should emit Done event");
|
|
|
|
|
assert!(
|
|
|
|
|
matches!(event, AgentEvent::Done { .. }),
|
|
|
|
|
"expected Done event, got: {event:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn server_owned_completion_nonexistent_agent_is_noop() {
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
// Should not panic or error — just silently return.
|
|
|
|
|
run_server_owned_completion(
|
|
|
|
|
&pool.agents,
|
|
|
|
|
pool.port,
|
|
|
|
|
"nonexistent",
|
|
|
|
|
"bot",
|
|
|
|
|
None,
|
|
|
|
|
pool.watcher_tx.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Regression test for bug 445: a rate-limited mergemaster exits before
|
|
|
|
|
/// calling start_merge_agent_work. run_server_owned_completion must be a
|
|
|
|
|
/// no-op for mergemaster agents — it must not run acceptance gates and must
|
|
|
|
|
/// not advance the story to 5_done/ even when a passing script/test exists.
|
|
|
|
|
///
|
|
|
|
|
/// Before the fix: run_server_owned_completion would call run_pipeline_advance
|
|
|
|
|
/// for the Mergemaster stage, which ran post-merge tests on master (they pass
|
|
|
|
|
/// because nothing changed), then called move_story_to_done — advancing the
|
|
|
|
|
/// story without any squash merge having occurred.
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn server_owned_completion_is_noop_for_mergemaster() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
let tmp = tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
|
|
|
|
init_git_repo(root);
|
|
|
|
|
|
|
|
|
|
// Create a passing script/test so post-merge tests would succeed if
|
|
|
|
|
// run_pipeline_advance were incorrectly called for this mergemaster.
|
|
|
|
|
let script_dir = root.join("script");
|
|
|
|
|
fs::create_dir_all(&script_dir).unwrap();
|
|
|
|
|
let script_test = script_dir.join("test");
|
|
|
|
|
fs::write(&script_test, "#!/usr/bin/env sh\nexit 0\n").unwrap();
|
|
|
|
|
let mut perms = fs::metadata(&script_test).unwrap().permissions();
|
|
|
|
|
perms.set_mode(0o755);
|
|
|
|
|
fs::set_permissions(&script_test, perms).unwrap();
|
|
|
|
|
|
|
|
|
|
// Story in 4_merge/ — must NOT be moved to 5_done/.
|
|
|
|
|
let merge_dir = root.join(".huskies/work/4_merge");
|
|
|
|
|
fs::create_dir_all(&merge_dir).unwrap();
|
|
|
|
|
let story_path = merge_dir.join("99_story_merge445.md");
|
|
|
|
|
fs::write(&story_path, "---\nname: Merge 445 Test\n---\n").unwrap();
|
|
|
|
|
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
pool.inject_test_agent_with_path(
|
|
|
|
|
"99_story_merge445",
|
|
|
|
|
"mergemaster",
|
|
|
|
|
AgentStatus::Running,
|
|
|
|
|
root.to_path_buf(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
run_server_owned_completion(
|
|
|
|
|
&pool.agents,
|
|
|
|
|
pool.port,
|
|
|
|
|
"99_story_merge445",
|
|
|
|
|
"mergemaster",
|
|
|
|
|
None,
|
|
|
|
|
pool.watcher_tx.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
// Wait briefly in case any background task fires.
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
|
|
|
|
|
|
|
|
|
// Story must remain in 4_merge/ — not moved to 5_done/.
|
|
|
|
|
let done_path = root.join(".huskies/work/5_done/99_story_merge445.md");
|
|
|
|
|
assert!(
|
|
|
|
|
!done_path.exists(),
|
|
|
|
|
"Story must NOT be moved to 5_done/ when run_server_owned_completion \
|
|
|
|
|
is (incorrectly) called for a mergemaster agent"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
story_path.exists(),
|
|
|
|
|
"Story must remain in 4_merge/ when mergemaster completion is a no-op"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// The agent entry should remain in the pool (lifecycle cleanup is the
|
|
|
|
|
// caller's responsibility, not run_server_owned_completion's).
|
|
|
|
|
let agents = pool.agents.lock().unwrap();
|
|
|
|
|
let key = super::super::super::composite_key("99_story_merge445", "mergemaster");
|
|
|
|
|
assert!(
|
|
|
|
|
agents.get(&key).is_some(),
|
|
|
|
|
"Agent must remain in pool — run_server_owned_completion is a no-op for mergemaster"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Bug 645 + 651: when an agent crashes leaving dirty files but committed
|
|
|
|
|
/// work, server-owned completion should stash the dirty files during gates
|
|
|
|
|
/// and restore them afterward. Uncommitted work is never junk.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn server_owned_completion_preserves_dirty_worktree_with_committed_work() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
let tmp = tempdir().unwrap();
|
|
|
|
|
let project_root = tmp.path().join("project");
|
|
|
|
|
fs::create_dir_all(&project_root).unwrap();
|
|
|
|
|
init_git_repo(&project_root);
|
|
|
|
|
|
|
|
|
|
// Create a worktree on a feature branch with committed code.
|
|
|
|
|
let wt_path = tmp.path().join("wt");
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args([
|
|
|
|
|
"worktree",
|
|
|
|
|
"add",
|
|
|
|
|
&wt_path.to_string_lossy(),
|
|
|
|
|
"-b",
|
|
|
|
|
"feature/story-645_test",
|
|
|
|
|
])
|
|
|
|
|
.current_dir(&project_root)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// Commit a valid file.
|
|
|
|
|
fs::write(wt_path.join("work.txt"), "done").unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["add", "."])
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["commit", "-m", "coder: add work"])
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// Now simulate crash leaving dirty files.
|
|
|
|
|
fs::write(wt_path.join("dirty.txt"), "crash residue").unwrap();
|
|
|
|
|
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
pool.inject_test_agent_with_path("645_test", "coder-1", AgentStatus::Running, wt_path.clone());
|
|
|
|
|
|
|
|
|
|
let mut rx = pool.subscribe("645_test", "coder-1").unwrap();
|
|
|
|
|
|
|
|
|
|
run_server_owned_completion(
|
|
|
|
|
&pool.agents,
|
|
|
|
|
pool.port,
|
|
|
|
|
"645_test",
|
|
|
|
|
"coder-1",
|
|
|
|
|
Some("sess-645".to_string()),
|
|
|
|
|
pool.watcher_tx.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
// Bug 651: The dirty file must be PRESERVED — uncommitted work is never junk.
|
|
|
|
|
assert!(
|
|
|
|
|
wt_path.join("dirty.txt").exists(),
|
|
|
|
|
"dirty file should be preserved after server-owned completion (bug 651)"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
fs::read_to_string(wt_path.join("dirty.txt")).unwrap(),
|
|
|
|
|
"crash residue",
|
|
|
|
|
"dirty file contents should be unchanged"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// A Done event should have been emitted (completion ran, didn't fail
|
|
|
|
|
// on dirty worktree).
|
|
|
|
|
let event = rx.try_recv().expect("should emit Done event");
|
|
|
|
|
assert!(
|
|
|
|
|
matches!(event, AgentEvent::Done { .. }),
|
|
|
|
|
"expected Done event, got: {event:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// AC3 (bug 651): simulate an agent killed by the watchdog with a
|
|
|
|
|
/// substantive uncommitted diff. After the orchestrator's full
|
|
|
|
|
/// post-termination handling (gates, completion, advance check),
|
|
|
|
|
/// `git status --short` must still show the same modified files.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn watchdog_kill_preserves_uncommitted_diff() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
let tmp = tempdir().unwrap();
|
|
|
|
|
let project_root = tmp.path().join("project");
|
|
|
|
|
fs::create_dir_all(&project_root).unwrap();
|
|
|
|
|
init_git_repo(&project_root);
|
|
|
|
|
|
|
|
|
|
// Create a worktree on a feature branch with committed code.
|
|
|
|
|
let wt_path = tmp.path().join("wt");
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args([
|
|
|
|
|
"worktree",
|
|
|
|
|
"add",
|
|
|
|
|
&wt_path.to_string_lossy(),
|
|
|
|
|
"-b",
|
|
|
|
|
"feature/story-651_watchdog",
|
|
|
|
|
])
|
|
|
|
|
.current_dir(&project_root)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// Commit some work.
|
|
|
|
|
fs::write(wt_path.join("committed.txt"), "committed work").unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["add", "."])
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["commit", "-m", "coder: add committed work"])
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// Simulate substantive uncommitted diff left by watchdog kill.
|
|
|
|
|
fs::write(wt_path.join("in_progress.rs"), "fn wip() {}").unwrap();
|
|
|
|
|
fs::write(wt_path.join("committed.txt"), "modified after commit").unwrap();
|
|
|
|
|
|
|
|
|
|
// Snapshot git status before completion.
|
|
|
|
|
let status_before = Command::new("git")
|
|
|
|
|
.args(["status", "--short"])
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
let status_before = String::from_utf8_lossy(&status_before.stdout)
|
|
|
|
|
.trim()
|
|
|
|
|
.to_string();
|
|
|
|
|
assert!(
|
|
|
|
|
!status_before.is_empty(),
|
|
|
|
|
"pre-condition: worktree should be dirty"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let pool = AgentPool::new_test(3001);
|
|
|
|
|
pool.inject_test_agent_with_path(
|
|
|
|
|
"651_watchdog",
|
|
|
|
|
"coder-1",
|
|
|
|
|
AgentStatus::Running,
|
|
|
|
|
wt_path.clone(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
run_server_owned_completion(
|
|
|
|
|
&pool.agents,
|
|
|
|
|
pool.port,
|
|
|
|
|
"651_watchdog",
|
|
|
|
|
"coder-1",
|
|
|
|
|
Some("sess-651".to_string()),
|
|
|
|
|
pool.watcher_tx.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
// After full post-termination handling, git status must be unchanged.
|
|
|
|
|
let status_after = Command::new("git")
|
|
|
|
|
.args(["status", "--short"])
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
let status_after = String::from_utf8_lossy(&status_after.stdout)
|
|
|
|
|
.trim()
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
status_before, status_after,
|
|
|
|
|
"Bug 651: uncommitted diff must survive post-termination handling.\n\
|
|
|
|
|
Before: {status_before}\nAfter: {status_after}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Verify file contents are intact.
|
|
|
|
|
assert_eq!(
|
|
|
|
|
fs::read_to_string(wt_path.join("in_progress.rs")).unwrap(),
|
|
|
|
|
"fn wip() {}",
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
fs::read_to_string(wt_path.join("committed.txt")).unwrap(),
|
|
|
|
|
"modified after commit",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 15:54:10 +00:00
|
|
|
/// 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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 15:16:05 +00:00
|
|
|
/// 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
|
|
|
|
|
/// uncommitted state is just preserved on disk for the next agent.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn committed_work_advances_despite_uncommitted_noise() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
let tmp = tempdir().unwrap();
|
|
|
|
|
let project_root = tmp.path().join("project");
|
|
|
|
|
fs::create_dir_all(&project_root).unwrap();
|
|
|
|
|
init_git_repo(&project_root);
|
|
|
|
|
|
|
|
|
|
// Create a minimal Cargo project so cargo check works.
|
|
|
|
|
fs::write(
|
|
|
|
|
project_root.join("Cargo.toml"),
|
|
|
|
|
"[package]\nname = \"test_proj\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
fs::create_dir_all(project_root.join("src")).unwrap();
|
|
|
|
|
fs::write(project_root.join("src/lib.rs"), "// empty\n").unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["add", "."])
|
|
|
|
|
.current_dir(&project_root)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["commit", "-m", "add cargo project"])
|
|
|
|
|
.current_dir(&project_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-651_regression",
|
|
|
|
|
])
|
|
|
|
|
.current_dir(&project_root)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// Commit valid code on the feature branch.
|
|
|
|
|
fs::write(wt_path.join("src/lib.rs"), "pub fn good() {}\n").unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["add", "."])
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["commit", "-m", "add good fn"])
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// Simulate crash leaving uncommitted noise (broken syntax in tracked file).
|
|
|
|
|
fs::write(wt_path.join("src/lib.rs"), "THIS IS BROKEN SYNTAX!!!\n").unwrap();
|
|
|
|
|
fs::write(wt_path.join("crash_junk.tmp"), "untracked noise").unwrap();
|
|
|
|
|
|
|
|
|
|
// The "work survived" check should detect committed work and pass cargo check
|
|
|
|
|
// despite the dirty worktree, WITHOUT destroying the dirty files.
|
|
|
|
|
assert!(
|
|
|
|
|
crate::agents::gates::worktree_has_committed_work(&wt_path),
|
|
|
|
|
"committed work should be detected"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
crate::agents::gates::cargo_check_in_worktree(&wt_path),
|
|
|
|
|
"cargo check should pass on committed code (stash/pop, not reset)"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Dirty files must still exist after the check.
|
|
|
|
|
assert_eq!(
|
|
|
|
|
fs::read_to_string(wt_path.join("src/lib.rs")).unwrap(),
|
|
|
|
|
"THIS IS BROKEN SYNTAX!!!\n",
|
|
|
|
|
"uncommitted noise in tracked file must be preserved"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
wt_path.join("crash_junk.tmp").exists(),
|
|
|
|
|
"untracked noise file must be preserved"
|
|
|
|
|
);
|
|
|
|
|
}
|