story-kit: merge 307_story_configurable_coder_pool_size_and_default_model_in_project_toml
This commit is contained in:
@@ -1568,6 +1568,27 @@ impl AgentPool {
|
||||
let preferred_agent =
|
||||
read_story_front_matter_agent(project_root, stage_dir, 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.
|
||||
@@ -2225,18 +2246,60 @@ fn is_story_assigned_for_stage(
|
||||
})
|
||||
}
|
||||
|
||||
/// Count active (pending/running) agents for a given pipeline stage.
|
||||
fn count_active_agents_for_stage(
|
||||
config: &ProjectConfig,
|
||||
agents: &HashMap<String, StoryAgent>,
|
||||
stage: &PipelineStage,
|
||||
) -> usize {
|
||||
agents
|
||||
.values()
|
||||
.filter(|a| {
|
||||
matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
|
||||
&& config
|
||||
.find_agent(&a.agent_name)
|
||||
.map(|cfg| agent_config_stage(cfg) == *stage)
|
||||
.unwrap_or_else(|| pipeline_stage(&a.agent_name) == *stage)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Find the first configured agent for `stage` that has no active (pending/running) assignment.
|
||||
/// Returns `None` if all agents for that stage are busy or none are configured.
|
||||
/// Uses the agent's explicit `stage` config field (preferred) or falls back to name-based detection.
|
||||
/// Returns `None` if all agents for that stage are busy, none are configured,
|
||||
/// or the `max_coders` limit has been reached (for the Coder stage).
|
||||
///
|
||||
/// For the Coder stage, when `default_coder_model` is set, only considers agents whose
|
||||
/// model matches the default. This ensures opus-class agents are reserved for explicit
|
||||
/// front-matter requests.
|
||||
fn find_free_agent_for_stage<'a>(
|
||||
config: &'a ProjectConfig,
|
||||
agents: &HashMap<String, StoryAgent>,
|
||||
stage: &PipelineStage,
|
||||
) -> Option<&'a str> {
|
||||
// Enforce max_coders limit for the Coder stage.
|
||||
if *stage == PipelineStage::Coder
|
||||
&& let Some(max) = config.max_coders
|
||||
{
|
||||
let active = count_active_agents_for_stage(config, agents, stage);
|
||||
if active >= max {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
for agent_config in &config.agent {
|
||||
if agent_config_stage(agent_config) != *stage {
|
||||
continue;
|
||||
}
|
||||
// When default_coder_model is set, only auto-assign coder agents whose
|
||||
// model matches. This keeps opus agents reserved for explicit requests.
|
||||
if *stage == PipelineStage::Coder
|
||||
&& let Some(ref default_model) = config.default_coder_model
|
||||
{
|
||||
let agent_model = agent_config.model.as_deref().unwrap_or("");
|
||||
if agent_model != default_model {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let is_busy = agents.values().any(|a| {
|
||||
a.agent_name == agent_config.name
|
||||
&& matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
|
||||
@@ -5219,4 +5282,197 @@ stage = "qa"
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helper to construct a test StoryAgent ──────────────────────────
|
||||
|
||||
fn make_test_story_agent(agent_name: &str, status: AgentStatus) -> StoryAgent {
|
||||
StoryAgent {
|
||||
agent_name: agent_name.to_string(),
|
||||
status,
|
||||
worktree_info: None,
|
||||
session_id: None,
|
||||
tx: broadcast::channel(1).0,
|
||||
task_handle: None,
|
||||
event_log: Arc::new(Mutex::new(Vec::new())),
|
||||
completion: None,
|
||||
project_root: None,
|
||||
log_session_id: None,
|
||||
merge_failure_reported: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── find_free_agent_for_stage: default_coder_model filtering ─────────
|
||||
|
||||
#[test]
|
||||
fn find_free_agent_skips_opus_when_default_coder_model_set() {
|
||||
let config = make_config(
|
||||
r#"
|
||||
default_coder_model = "sonnet"
|
||||
|
||||
[[agent]]
|
||||
name = "coder-1"
|
||||
stage = "coder"
|
||||
model = "sonnet"
|
||||
|
||||
[[agent]]
|
||||
name = "coder-opus"
|
||||
stage = "coder"
|
||||
model = "opus"
|
||||
"#,
|
||||
);
|
||||
|
||||
let agents = HashMap::new();
|
||||
let free = find_free_agent_for_stage(&config, &agents, &PipelineStage::Coder);
|
||||
assert_eq!(free, Some("coder-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_free_agent_returns_opus_when_no_default_coder_model() {
|
||||
let config = make_config(
|
||||
r#"
|
||||
[[agent]]
|
||||
name = "coder-opus"
|
||||
stage = "coder"
|
||||
model = "opus"
|
||||
"#,
|
||||
);
|
||||
|
||||
let agents = HashMap::new();
|
||||
let free = find_free_agent_for_stage(&config, &agents, &PipelineStage::Coder);
|
||||
assert_eq!(free, Some("coder-opus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_free_agent_returns_none_when_all_sonnet_coders_busy() {
|
||||
let config = make_config(
|
||||
r#"
|
||||
default_coder_model = "sonnet"
|
||||
|
||||
[[agent]]
|
||||
name = "coder-1"
|
||||
stage = "coder"
|
||||
model = "sonnet"
|
||||
|
||||
[[agent]]
|
||||
name = "coder-opus"
|
||||
stage = "coder"
|
||||
model = "opus"
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut agents = HashMap::new();
|
||||
agents.insert(
|
||||
"story1:coder-1".to_string(),
|
||||
make_test_story_agent("coder-1", AgentStatus::Running),
|
||||
);
|
||||
|
||||
let free = find_free_agent_for_stage(&config, &agents, &PipelineStage::Coder);
|
||||
assert_eq!(free, None, "opus agent should not be auto-assigned");
|
||||
}
|
||||
|
||||
// ── find_free_agent_for_stage: max_coders limit ─────────────────────
|
||||
|
||||
#[test]
|
||||
fn find_free_agent_respects_max_coders() {
|
||||
let config = make_config(
|
||||
r#"
|
||||
max_coders = 1
|
||||
|
||||
[[agent]]
|
||||
name = "coder-1"
|
||||
stage = "coder"
|
||||
model = "sonnet"
|
||||
|
||||
[[agent]]
|
||||
name = "coder-2"
|
||||
stage = "coder"
|
||||
model = "sonnet"
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut agents = HashMap::new();
|
||||
agents.insert(
|
||||
"story1:coder-1".to_string(),
|
||||
make_test_story_agent("coder-1", AgentStatus::Running),
|
||||
);
|
||||
|
||||
let free = find_free_agent_for_stage(&config, &agents, &PipelineStage::Coder);
|
||||
assert_eq!(free, None, "max_coders=1 should block second coder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_free_agent_allows_within_max_coders() {
|
||||
let config = make_config(
|
||||
r#"
|
||||
max_coders = 2
|
||||
|
||||
[[agent]]
|
||||
name = "coder-1"
|
||||
stage = "coder"
|
||||
model = "sonnet"
|
||||
|
||||
[[agent]]
|
||||
name = "coder-2"
|
||||
stage = "coder"
|
||||
model = "sonnet"
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut agents = HashMap::new();
|
||||
agents.insert(
|
||||
"story1:coder-1".to_string(),
|
||||
make_test_story_agent("coder-1", AgentStatus::Running),
|
||||
);
|
||||
|
||||
let free = find_free_agent_for_stage(&config, &agents, &PipelineStage::Coder);
|
||||
assert_eq!(free, Some("coder-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_coders_does_not_affect_qa_stage() {
|
||||
let config = make_config(
|
||||
r#"
|
||||
max_coders = 1
|
||||
|
||||
[[agent]]
|
||||
name = "qa"
|
||||
stage = "qa"
|
||||
model = "sonnet"
|
||||
"#,
|
||||
);
|
||||
|
||||
let agents = HashMap::new();
|
||||
let free = find_free_agent_for_stage(&config, &agents, &PipelineStage::Qa);
|
||||
assert_eq!(free, Some("qa"));
|
||||
}
|
||||
|
||||
// ── count_active_agents_for_stage ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn count_active_agents_counts_running_and_pending() {
|
||||
let config = make_config(
|
||||
r#"
|
||||
[[agent]]
|
||||
name = "coder-1"
|
||||
stage = "coder"
|
||||
|
||||
[[agent]]
|
||||
name = "coder-2"
|
||||
stage = "coder"
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut agents = HashMap::new();
|
||||
agents.insert(
|
||||
"s1:coder-1".to_string(),
|
||||
make_test_story_agent("coder-1", AgentStatus::Running),
|
||||
);
|
||||
agents.insert(
|
||||
"s2:coder-2".to_string(),
|
||||
make_test_story_agent("coder-2", AgentStatus::Completed),
|
||||
);
|
||||
|
||||
let count = count_active_agents_for_stage(&config, &agents, &PipelineStage::Coder);
|
||||
assert_eq!(count, 1, "Only Running coder should be counted, not Completed");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user