diff --git a/server/src/agents/pool/auto_assign/auto_assign.rs b/server/src/agents/pool/auto_assign/auto_assign.rs index 8083dccd..872fda56 100644 --- a/server/src/agents/pool/auto_assign/auto_assign.rs +++ b/server/src/agents/pool/auto_assign/auto_assign.rs @@ -19,7 +19,47 @@ use super::story_checks::{ }; impl AgentPool { + /// Scan `1_backlog/` and promote any story whose `depends_on` are all met. + /// + /// A story is only promoted if it explicitly lists `depends_on` AND every + /// listed dependency has reached `5_done` or `6_archived`. Stories with no + /// `depends_on` are left in the backlog for human scheduling. + fn promote_ready_backlog_stories(&self, project_root: &Path) { + use crate::io::story_metadata::parse_front_matter; + + let items = scan_stage_items(project_root, "1_backlog"); + for story_id in &items { + // Only promote stories that explicitly declare dependencies. + let story_path = project_root + .join(".huskies/work/1_backlog") + .join(format!("{story_id}.md")); + let has_deps = std::fs::read_to_string(&story_path) + .ok() + .and_then(|c| parse_front_matter(&c).ok()) + .and_then(|m| m.depends_on) + .map(|d| !d.is_empty()) + .unwrap_or(false); + if !has_deps { + continue; + } + // Check whether any dependencies are still unmet. + if has_unmet_dependencies(project_root, "1_backlog", story_id) { + continue; + } + // All deps met — promote from backlog to current. + slog!("[auto-assign] Story '{story_id}' deps met; promoting from backlog to current."); + if let Err(e) = + crate::agents::lifecycle::move_story_to_current(project_root, story_id) + { + slog!("[auto-assign] Failed to promote '{story_id}' to current: {e}"); + } + } + } + pub async fn auto_assign_available_work(&self, project_root: &Path) { + // Promote any backlog stories whose dependencies are all done. + self.promote_ready_backlog_stories(project_root); + let config = match ProjectConfig::load(project_root) { Ok(c) => c, Err(e) => { @@ -497,6 +537,105 @@ mod tests { ); } + // ── Bug 497: backlog dependency promotion ─────────────────────────────── + + /// Stories in backlog with `depends_on` that are all in 5_done must be + /// promoted to 2_current when auto_assign_available_work runs. + #[tokio::test] + async fn auto_assign_promotes_backlog_story_when_all_deps_done() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let sk = root.join(".huskies"); + let backlog = sk.join("work/1_backlog"); + let current = sk.join("work/2_current"); + let done = sk.join("work/5_done"); + std::fs::create_dir_all(&backlog).unwrap(); + std::fs::create_dir_all(¤t).unwrap(); + std::fs::create_dir_all(&done).unwrap(); + std::fs::write( + sk.join("project.toml"), + "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n", + ) + .unwrap(); + // Dep 1 is done. + std::fs::write(done.join("1_story_dep.md"), "---\nname: Dep\n---\n").unwrap(); + // Story B depends on story 1. + std::fs::write( + backlog.join("2_story_b.md"), + "---\nname: B\ndepends_on: [1]\n---\n", + ) + .unwrap(); + + let pool = AgentPool::new_test(3001); + pool.auto_assign_available_work(root).await; + + assert!( + current.join("2_story_b.md").exists(), + "story B should be promoted to 2_current/ once dep 1 is done" + ); + assert!( + !backlog.join("2_story_b.md").exists(), + "story B must be removed from 1_backlog/ after promotion" + ); + } + + /// Stories in backlog with unmet dependencies must NOT be promoted. + #[tokio::test] + async fn auto_assign_does_not_promote_backlog_story_with_unmet_deps() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let sk = root.join(".huskies"); + let backlog = sk.join("work/1_backlog"); + std::fs::create_dir_all(&backlog).unwrap(); + std::fs::write( + sk.join("project.toml"), + "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n", + ) + .unwrap(); + // Dep 99 is NOT done. + std::fs::write( + backlog.join("5_story_c.md"), + "---\nname: C\ndepends_on: [99]\n---\n", + ) + .unwrap(); + + let pool = AgentPool::new_test(3001); + pool.auto_assign_available_work(root).await; + + assert!( + backlog.join("5_story_c.md").exists(), + "story C should stay in 1_backlog/ when dep 99 is not done" + ); + } + + /// Stories in backlog with NO depends_on must NOT be auto-promoted. + #[tokio::test] + async fn auto_assign_does_not_promote_backlog_story_without_deps() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let sk = root.join(".huskies"); + let backlog = sk.join("work/1_backlog"); + std::fs::create_dir_all(&backlog).unwrap(); + std::fs::write( + sk.join("project.toml"), + "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n", + ) + .unwrap(); + std::fs::write( + backlog.join("7_story_nodeps.md"), + "---\nname: No deps\n---\n", + ) + .unwrap(); + + let pool = AgentPool::new_test(3001); + pool.auto_assign_available_work(root).await; + + assert!( + backlog.join("7_story_nodeps.md").exists(), + "story with no depends_on should stay in 1_backlog/ — human schedules it" + ); + } + /// Two concurrent auto_assign_available_work calls must not assign the same /// agent to two stories simultaneously. After both complete, at most one /// Pending/Running entry must exist per agent name.