2026-04-29 09:49:45 +00:00
|
|
|
//! 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;
|
2026-05-13 13:17:46 +00:00
|
|
|
use crate::pipeline_state::Stage;
|
2026-04-29 09:49:45 +00:00
|
|
|
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) {
|
2026-05-13 13:17:46 +00:00
|
|
|
let stages: [(Stage, PipelineStage); 2] = [
|
2026-05-14 08:07:43 +00:00
|
|
|
(
|
|
|
|
|
Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: Default::default(),
|
|
|
|
|
},
|
|
|
|
|
PipelineStage::Coder,
|
|
|
|
|
),
|
2026-05-13 13:17:46 +00:00
|
|
|
(Stage::Qa, PipelineStage::Qa),
|
2026-04-29 09:49:45 +00:00
|
|
|
];
|
|
|
|
|
|
2026-05-13 13:17:46 +00:00
|
|
|
for (pipeline_stage, stage) in &stages {
|
|
|
|
|
let stage_dir = pipeline_stage.dir_name();
|
|
|
|
|
let items = scan_stage_items(pipeline_stage);
|
2026-04-29 09:49:45 +00:00
|
|
|
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.
|
2026-05-13 13:17:46 +00:00
|
|
|
if has_review_hold(story_id) {
|
2026-04-29 09:49:45 +00:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip frozen stories — pipeline advancement is suspended.
|
2026-05-13 13:17:46 +00:00
|
|
|
if is_story_frozen(story_id) {
|
2026-04-29 09:49:45 +00:00
|
|
|
slog!("[auto-assign] Story '{story_id}' is frozen; skipping until unfrozen.");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip blocked stories (retry limit exceeded).
|
2026-05-13 13:17:46 +00:00
|
|
|
if is_story_blocked(story_id) {
|
2026-04-29 09:49:45 +00:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip stories whose dependencies haven't landed yet.
|
2026-05-13 13:17:46 +00:00
|
|
|
if has_unmet_dependencies(story_id) {
|
2026-04-29 09:49:45 +00:00
|
|
|
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.
|
2026-05-13 13:17:46 +00:00
|
|
|
let preferred_agent = read_story_front_matter_agent(story_id);
|
2026-04-29 09:49:45 +00:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|