refactor: split agents/pool/start.rs into mod.rs + validation.rs + spawn.rs

The 1630-line start.rs is split into a sub-module directory:

- validation.rs: validate_agent_stage + read_front_matter_agent helpers (69 lines)
- spawn.rs: run_agent_spawn — the background async work that was inlined as
  a tokio::spawn closure body inside start_agent (359 lines)
- mod.rs: AgentPool::start_agent orchestrator + tests (1062 lines)

Stage validation and front-matter agent reading are pre-lock pure helpers that
naturally extract.  The spawn closure body becomes a free async fn that takes
the previously-cloned values as parameters; rebound to the original _clone /
_owned names at the top of the body so the actual work code is a verbatim copy.

No behaviour change. All 23 start tests pass; full suite green.
This commit is contained in:
dave
2026-04-26 22:12:04 +00:00
parent 40f1794d41
commit eca15b4ee7
3 changed files with 458 additions and 344 deletions
@@ -0,0 +1,69 @@
//! Pre-lock validation helpers for `AgentPool::start_agent`.
use std::path::Path;
use crate::config::ProjectConfig;
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_dir) = find_active_story_stage(project_root, story_id) else {
return Ok(());
};
let expected_stage = match story_stage_dir {
"2_current" => PipelineStage::Coder,
"3_qa" => PipelineStage::Qa,
"4_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 {story_stage_dir}/ (requires stage: {expected_stage:?})"
));
}
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<String> {
if agent_name.is_some() {
return None;
}
crate::db::read_content(story_id).and_then(|contents| {
crate::io::story_metadata::parse_front_matter(&contents)
.ok()?
.agent
})
}