//! Pre-lock validation helpers for `AgentPool::start_agent`. use std::path::Path; use crate::config::ProjectConfig; use crate::pipeline_state::Stage; use super::super::super::{PipelineStage, agent_config_stage, pipeline_stage}; use super::super::worktree::find_active_story_stage; /// Validate that an explicit `agent_name` is allowed to attach to `story_id`'s /// current pipeline stage. /// /// Prevents wrong-stage assignments like a mergemaster on a coding-stage story /// (bug 312). Returns `Ok(())` if the agent has no specific stage (e.g. /// supervisor) or the story is not in an active stage; `Err` with a descriptive /// message on a stage mismatch. pub(super) fn validate_agent_stage( config: &ProjectConfig, project_root: &Path, story_id: &str, agent_name: Option<&str>, ) -> Result<(), String> { let Some(name) = agent_name else { return Ok(()); }; let agent_stage = config .find_agent(name) .map(agent_config_stage) .unwrap_or_else(|| pipeline_stage(name)); if agent_stage == PipelineStage::Other { return Ok(()); } let Some(story_stage) = find_active_story_stage(project_root, story_id) else { return Ok(()); }; let expected_stage = match story_stage { Stage::Coding => PipelineStage::Coder, Stage::Qa => PipelineStage::Qa, Stage::Merge { .. } => PipelineStage::Mergemaster, _ => PipelineStage::Other, }; if expected_stage != PipelineStage::Other && expected_stage != agent_stage { return Err(format!( "Agent '{name}' (stage: {agent_stage:?}) cannot be assigned to \ story '{story_id}' in {}/ (requires stage: {expected_stage:?})", story_stage.dir_name() )); } Ok(()) } /// Read the preferred `agent:` field from the story's front matter. /// /// When `agent_name` is `None` (caller is auto-selecting), this lets /// `start_agent` honour an explicit `agent: coder-opus` written by the /// `assign` command (bug 379). Returns `None` when an explicit agent_name /// was already supplied or when the story has no front-matter preference. pub(super) fn read_front_matter_agent(story_id: &str, agent_name: Option<&str>) -> Option { if agent_name.is_some() { return None; } // After story 871 the pin lives in the CRDT typed register; fall back // to legacy YAML parsing for stories whose CRDT entry doesn't yet have // the field populated. if let Some(view) = crate::crdt_state::read_item(story_id) && let Some(agent) = view.agent.as_ref() && !agent.is_empty() { return Some(agent.clone()); } crate::db::read_content(story_id).and_then(|contents| { crate::io::story_metadata::parse_front_matter(&contents) .ok()? .agent }) }