//! Pipeline stage dispatch: assign free coder and QA agents to stories in `2_current/` and `3_qa/`. use std::path::Path; use crate::config::ProjectConfig; use crate::pipeline_state::Stage; use crate::slog; use crate::slog_error; use super::super::super::PipelineStage; use super::super::AgentPool; use super::scan::{ count_active_agents_for_stage, find_free_agent_for_stage, is_agent_free, is_story_assigned_for_stage, scan_stage_items, }; use super::story_checks::{ has_review_hold, has_unmet_dependencies, is_story_blocked, is_story_frozen, read_story_front_matter_agent, }; impl AgentPool { /// Assign free agents to stories in the coder (`2_current/`) and QA (`3_qa/`) stages. /// /// For each stage, iterates over pending stories and starts a free agent if one is /// available. Respects `max_coders`, `review_hold`, frozen, blocked, and unmet-dep /// guards. Agent front-matter preferences and stage-mismatch fallback are handled /// here as well. pub(super) async fn assign_pipeline_stages(&self, project_root: &Path, config: &ProjectConfig) { let stages: [(Stage, PipelineStage); 2] = [ (Stage::Coding { claim: None }, PipelineStage::Coder), (Stage::Qa, PipelineStage::Qa), ]; for (pipeline_stage, stage) in &stages { let stage_dir = pipeline_stage.dir_name(); let items = scan_stage_items(pipeline_stage); if items.is_empty() { continue; } for story_id in &items { // Items marked with review_hold (e.g. spikes after QA passes) stay // in their current stage for human review — don't auto-assign agents. if has_review_hold(story_id) { continue; } // Skip frozen stories — pipeline advancement is suspended. if is_story_frozen(story_id) { slog!("[auto-assign] Story '{story_id}' is frozen; skipping until unfrozen."); continue; } // Skip blocked stories (retry limit exceeded). if is_story_blocked(story_id) { continue; } // Skip stories whose dependencies haven't landed yet. if has_unmet_dependencies(story_id) { slog!( "[auto-assign] Story '{story_id}' has unmet dependencies; skipping until deps are done." ); continue; } // Re-acquire the lock on each iteration to see state changes // from previous start_agent calls in the same pass. let preferred_agent = read_story_front_matter_agent(story_id); // Check max_coders limit for the Coder stage before agent selection. // If the pool is full, all remaining items in this stage wait. if *stage == PipelineStage::Coder && let Some(max) = config.max_coders { let agents_lock = match self.agents.lock() { Ok(a) => a, Err(e) => { slog_error!("[auto-assign] Failed to lock agents: {e}"); break; } }; let active = count_active_agents_for_stage(config, &agents_lock, stage); if active >= max { slog!( "[auto-assign] Coder pool full ({active}/{max}); remaining items in {stage_dir}/ will wait." ); break; } } // Outcome: (already_assigned, chosen_agent, preferred_busy, stage_mismatch) // preferred_busy=true means the story has a specific agent requested but it is // currently occupied — the story should wait rather than fall back. // stage_mismatch=true means the preferred agent's stage doesn't match the // pipeline stage, so we fell back to a generic stage agent. let (already_assigned, free_agent, preferred_busy, stage_mismatch) = { let agents = match self.agents.lock() { Ok(a) => a, Err(e) => { slog_error!("[auto-assign] Failed to lock agents: {e}"); break; } }; let assigned = is_story_assigned_for_stage(config, &agents, story_id, stage); if assigned { (true, None, false, false) } else if let Some(ref pref) = preferred_agent { // Story has a front-matter agent preference. // Verify the preferred agent's stage matches the current // pipeline stage — a coder shouldn't be assigned to QA. let pref_stage_matches = config .find_agent(pref) .map(|cfg| super::super::super::agent_config_stage(cfg) == *stage) .unwrap_or(false); if !pref_stage_matches { // Stage mismatch — fall back to any free agent for this stage. let free = find_free_agent_for_stage(config, &agents, stage) .map(|s| s.to_string()); (false, free, false, true) } else if is_agent_free(&agents, pref) { (false, Some(pref.clone()), false, false) } else { (false, None, true, false) } } else { let free = find_free_agent_for_stage(config, &agents, stage) .map(|s| s.to_string()); (false, free, false, false) } }; if already_assigned { // Story already has an active agent — skip silently. continue; } if preferred_busy { // The story requests a specific agent that is currently busy. // Do not fall back to a different agent; let this story wait. slog!( "[auto-assign] Preferred agent '{}' busy for '{story_id}'; story will wait.", preferred_agent.as_deref().unwrap_or("?") ); continue; } if stage_mismatch { slog!( "[auto-assign] Preferred agent '{}' stage mismatch for '{story_id}' in {stage_dir}/; falling back to stage-appropriate agent.", preferred_agent.as_deref().unwrap_or("?") ); } match free_agent { Some(agent_name) => { slog!( "[auto-assign] Assigning '{agent_name}' to '{story_id}' in {stage_dir}/" ); if let Err(e) = self .start_agent(project_root, story_id, Some(&agent_name), None, None) .await { slog!( "[auto-assign] Failed to start '{agent_name}' for '{story_id}': {e}" ); } } None => { // No free agents of this type — stop scanning this stage. slog!( "[auto-assign] All {:?} agents busy; remaining items in {stage_dir}/ will wait.", stage ); break; } } } } } }