story-kit: merge 94_bug_stale_agent_state_persists_after_server_restart

This commit is contained in:
Dave
2026-02-23 20:38:17 +00:00
parent 39dbace8bf
commit cd902ff219
2 changed files with 451 additions and 3 deletions

View File

@@ -1108,6 +1108,151 @@ impl AgentPool {
}
}
/// Reconcile stories whose agent work was committed while the server was offline.
///
/// On server startup the in-memory agent pool is empty, so any story that an agent
/// completed during a previous session is stuck: the worktree has committed work but
/// the pipeline never advanced. This method detects those stories, re-runs the
/// acceptance gates, and advances the pipeline stage so that `auto_assign_available_work`
/// (called immediately after) picks up the right next-stage agents.
///
/// Algorithm:
/// 1. List all worktree directories under `{project_root}/.story_kit/worktrees/`.
/// 2. For each worktree, check whether its feature branch has commits ahead of the
/// base branch (`master` / `main`).
/// 3. If committed work is found AND the story is in `2_current/` or `3_qa/`:
/// - Run acceptance gates (uncommitted-change check + clippy + tests).
/// - On pass + `2_current/`: move the story to `3_qa/`.
/// - On pass + `3_qa/`: run the coverage gate; if that also passes move to `4_merge/`.
/// - On failure: leave the story where it is so `auto_assign_available_work` can
/// start a fresh agent to retry.
/// 4. Stories in `4_merge/` are left for `auto_assign_available_work` to handle via a
/// fresh mergemaster (squash-merge must be re-executed by the mergemaster agent).
pub async fn reconcile_on_startup(&self, project_root: &Path) {
let worktrees = match worktree::list_worktrees(project_root) {
Ok(wt) => wt,
Err(e) => {
eprintln!("[startup:reconcile] Failed to list worktrees: {e}");
return;
}
};
for wt_entry in &worktrees {
let story_id = &wt_entry.story_id;
let wt_path = wt_entry.path.clone();
// Determine which active stage the story is in.
let stage_dir = match find_active_story_stage(project_root, story_id) {
Some(s) => s,
None => continue, // Not in any active stage (upcoming/archived or unknown).
};
// 4_merge/ is left for auto_assign to handle with a fresh mergemaster.
if stage_dir == "4_merge" {
continue;
}
// Check whether the worktree has commits ahead of the base branch.
let wt_path_for_check = wt_path.clone();
let has_work = tokio::task::spawn_blocking(move || {
worktree_has_committed_work(&wt_path_for_check)
})
.await
.unwrap_or(false);
if !has_work {
eprintln!(
"[startup:reconcile] No committed work for '{story_id}' in {stage_dir}/; skipping."
);
continue;
}
eprintln!(
"[startup:reconcile] Found committed work for '{story_id}' in {stage_dir}/. Running acceptance gates."
);
// Run acceptance gates on the worktree.
let wt_path_for_gates = wt_path.clone();
let gates_result = tokio::task::spawn_blocking(move || {
check_uncommitted_changes(&wt_path_for_gates)?;
run_acceptance_gates(&wt_path_for_gates)
})
.await;
let (gates_passed, gate_output) = match gates_result {
Ok(Ok(pair)) => pair,
Ok(Err(e)) => {
eprintln!("[startup:reconcile] Gate check error for '{story_id}': {e}");
continue;
}
Err(e) => {
eprintln!(
"[startup:reconcile] Gate check task panicked for '{story_id}': {e}"
);
continue;
}
};
if !gates_passed {
eprintln!(
"[startup:reconcile] Gates failed for '{story_id}': {gate_output}\n\
Leaving in {stage_dir}/ for auto-assign to restart the agent."
);
continue;
}
eprintln!(
"[startup:reconcile] Gates passed for '{story_id}' (stage: {stage_dir}/)."
);
if stage_dir == "2_current" {
// Coder stage → advance to QA.
if let Err(e) = move_story_to_qa(project_root, story_id) {
eprintln!("[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}");
} else {
eprintln!("[startup:reconcile] Moved '{story_id}' → 3_qa/.");
}
} else if stage_dir == "3_qa" {
// QA stage → run coverage gate before advancing to merge.
let wt_path_for_cov = wt_path.clone();
let coverage_result =
tokio::task::spawn_blocking(move || run_coverage_gate(&wt_path_for_cov))
.await;
let (coverage_passed, coverage_output) = match coverage_result {
Ok(Ok(pair)) => pair,
Ok(Err(e)) => {
eprintln!(
"[startup:reconcile] Coverage gate error for '{story_id}': {e}"
);
continue;
}
Err(e) => {
eprintln!(
"[startup:reconcile] Coverage gate panicked for '{story_id}': {e}"
);
continue;
}
};
if coverage_passed {
if let Err(e) = move_story_to_merge(project_root, story_id) {
eprintln!(
"[startup:reconcile] Failed to move '{story_id}' to 4_merge/: {e}"
);
} else {
eprintln!("[startup:reconcile] Moved '{story_id}' → 4_merge/.");
}
} else {
eprintln!(
"[startup:reconcile] Coverage gate failed for '{story_id}': {coverage_output}\n\
Leaving in 3_qa/ for auto-assign to restart the QA agent."
);
}
}
}
}
/// Test helper: inject an agent with a completion report and project_root
/// for testing pipeline advance logic without spawning real agents.
#[cfg(test)]
@@ -1140,6 +1285,23 @@ impl AgentPool {
}
}
/// Return the active pipeline stage directory name for `story_id`, or `None` if the
/// story is not in any active stage (`2_current/`, `3_qa/`, `4_merge/`).
fn find_active_story_stage(project_root: &Path, story_id: &str) -> Option<&'static str> {
const STAGES: [&str; 3] = ["2_current", "3_qa", "4_merge"];
for stage in &STAGES {
let path = project_root
.join(".story_kit")
.join("work")
.join(stage)
.join(format!("{story_id}.md"));
if path.exists() {
return Some(stage);
}
}
None
}
/// Scan a work pipeline stage directory and return story IDs, sorted alphabetically.
/// Returns an empty `Vec` if the directory does not exist.
fn scan_stage_items(project_root: &Path, stage_dir: &str) -> Vec<String> {
@@ -1565,6 +1727,43 @@ pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), Str
// ── Acceptance-gate helpers ───────────────────────────────────────────────────
/// Detect the base branch for a git worktree by checking common default branch names.
///
/// Tries `master` then `main`; falls back to `"master"` if neither is resolvable.
fn detect_worktree_base_branch(wt_path: &Path) -> String {
for branch in &["master", "main"] {
let ok = Command::new("git")
.args(["rev-parse", "--verify", branch])
.current_dir(wt_path)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ok {
return branch.to_string();
}
}
"master".to_string()
}
/// Return `true` if the git worktree at `wt_path` has commits on its current
/// branch that are not present on the base branch (`master` or `main`).
///
/// Used during server startup reconciliation to detect stories whose agent work
/// was committed while the server was offline.
fn worktree_has_committed_work(wt_path: &Path) -> bool {
let base_branch = detect_worktree_base_branch(wt_path);
let output = Command::new("git")
.args(["log", &format!("{base_branch}..HEAD"), "--oneline"])
.current_dir(wt_path)
.output();
match output {
Ok(out) if out.status.success() => {
!String::from_utf8_lossy(&out.stdout).trim().is_empty()
}
_ => false,
}
}
/// Check whether the given directory has any uncommitted git changes.
/// Returns `Err` with a descriptive message if there are any.
fn check_uncommitted_changes(path: &Path) -> Result<(), String> {
@@ -3074,4 +3273,245 @@ name = "qa"
let free_qa = find_free_agent_for_stage(&config, &agents, &PipelineStage::Qa);
assert_eq!(free_qa, Some("qa"));
}
// ── find_active_story_stage tests ─────────────────────────────────────────
#[test]
fn find_active_story_stage_detects_current() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(current.join("10_story_test.md"), "test").unwrap();
assert_eq!(
find_active_story_stage(root, "10_story_test"),
Some("2_current")
);
}
#[test]
fn find_active_story_stage_detects_qa() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let qa = root.join(".story_kit/work/3_qa");
fs::create_dir_all(&qa).unwrap();
fs::write(qa.join("11_story_test.md"), "test").unwrap();
assert_eq!(
find_active_story_stage(root, "11_story_test"),
Some("3_qa")
);
}
#[test]
fn find_active_story_stage_detects_merge() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let merge = root.join(".story_kit/work/4_merge");
fs::create_dir_all(&merge).unwrap();
fs::write(merge.join("12_story_test.md"), "test").unwrap();
assert_eq!(
find_active_story_stage(root, "12_story_test"),
Some("4_merge")
);
}
#[test]
fn find_active_story_stage_returns_none_for_unknown_story() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(find_active_story_stage(tmp.path(), "99_nonexistent"), None);
}
// ── worktree_has_committed_work tests ─────────────────────────────────────
#[test]
fn worktree_has_committed_work_false_on_fresh_repo() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
// init_git_repo creates the initial commit on the default branch.
// HEAD IS the base branch — no commits ahead.
init_git_repo(repo);
assert!(!worktree_has_committed_work(repo));
}
#[test]
fn worktree_has_committed_work_true_after_commit_on_feature_branch() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path().join("project");
fs::create_dir_all(&project_root).unwrap();
init_git_repo(&project_root);
// Create a git 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-99_test",
])
.current_dir(&project_root)
.output()
.unwrap();
// No commits on the feature branch yet — same as base branch.
assert!(!worktree_has_committed_work(&wt_path));
// Add a commit to the feature branch in the worktree.
fs::write(wt_path.join("work.txt"), "done").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&wt_path)
.output()
.unwrap();
Command::new("git")
.args([
"-c",
"user.email=test@test.com",
"-c",
"user.name=Test",
"commit",
"-m",
"coder: implement story",
])
.current_dir(&wt_path)
.output()
.unwrap();
// Now the feature branch is ahead of the base branch.
assert!(worktree_has_committed_work(&wt_path));
}
// ── reconcile_on_startup tests ────────────────────────────────────────────
#[tokio::test]
async fn reconcile_on_startup_noop_when_no_worktrees() {
let tmp = tempfile::tempdir().unwrap();
let pool = AgentPool::new(3001);
// Should not panic; no worktrees to reconcile.
pool.reconcile_on_startup(tmp.path()).await;
}
#[tokio::test]
async fn reconcile_on_startup_skips_story_without_committed_work() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Set up story in 2_current/.
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(current.join("60_story_test.md"), "test").unwrap();
// Create a worktree directory that is a fresh git repo with no commits
// ahead of its own base branch (simulates a worktree where no work was done).
let wt_dir = root.join(".story_kit/worktrees/60_story_test");
fs::create_dir_all(&wt_dir).unwrap();
init_git_repo(&wt_dir);
let pool = AgentPool::new(3001);
pool.reconcile_on_startup(root).await;
// Story should still be in 2_current/ — nothing was reconciled.
assert!(
current.join("60_story_test.md").exists(),
"story should stay in 2_current/ when worktree has no committed work"
);
}
#[tokio::test]
async fn reconcile_on_startup_runs_gates_on_worktree_with_committed_work() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Set up a git repo for the project root.
init_git_repo(root);
// Set up story in 2_current/ and commit it so the project root is clean.
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(current.join("61_story_test.md"), "test").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(root)
.output()
.unwrap();
Command::new("git")
.args([
"-c",
"user.email=test@test.com",
"-c",
"user.name=Test",
"commit",
"-m",
"add story",
])
.current_dir(root)
.output()
.unwrap();
// Create a real git worktree for the story.
let wt_dir = root.join(".story_kit/worktrees/61_story_test");
fs::create_dir_all(wt_dir.parent().unwrap()).unwrap();
Command::new("git")
.args([
"worktree",
"add",
&wt_dir.to_string_lossy(),
"-b",
"feature/story-61_story_test",
])
.current_dir(root)
.output()
.unwrap();
// Add a commit to the feature branch (simulates coder completing work).
fs::write(wt_dir.join("implementation.txt"), "done").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&wt_dir)
.output()
.unwrap();
Command::new("git")
.args([
"-c",
"user.email=test@test.com",
"-c",
"user.name=Test",
"commit",
"-m",
"implement story",
])
.current_dir(&wt_dir)
.output()
.unwrap();
assert!(
worktree_has_committed_work(&wt_dir),
"test setup: worktree should have committed work"
);
let pool = AgentPool::new(3001);
pool.reconcile_on_startup(root).await;
// In the test env, cargo clippy will fail (no Cargo.toml) so gates fail
// and the story stays in 2_current/. The important assertion is that
// reconcile ran without panicking and the story is in a consistent state.
let in_current = current.join("61_story_test.md").exists();
let in_qa = root
.join(".story_kit/work/3_qa/61_story_test.md")
.exists();
assert!(
in_current || in_qa,
"story should be in 2_current/ or 3_qa/ after reconciliation"
);
}
}