diff --git a/server/src/agents.rs b/server/src/agents.rs index fe0580f..ebe0f2a 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -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 { @@ -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(¤t).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(¤t).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(¤t).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" + ); + } } diff --git a/server/src/main.rs b/server/src/main.rs index 54bf849..ce46c22 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -78,11 +78,19 @@ async fn main() -> Result<(), std::io::Error> { let app = build_routes(ctx); - // On startup, auto-assign free agents to any work already queued in the - // active pipeline stages (2_current/, 3_qa/, 4_merge/). + // On startup: + // 1. Reconcile any stories whose agent work was committed while the server was + // offline (worktree has commits ahead of master but pipeline didn't advance). + // 2. Auto-assign free agents to remaining unassigned work in the pipeline. if let Some(root) = startup_root { tokio::spawn(async move { - eprintln!("[auto-assign] Server startup: scanning pipeline stages for unassigned work."); + eprintln!( + "[startup] Reconciling completed worktrees from previous session." + ); + startup_agents.reconcile_on_startup(&root).await; + eprintln!( + "[auto-assign] Scanning pipeline stages for unassigned work." + ); startup_agents.auto_assign_available_work(&root).await; }); }