story-kit: merge 307_story_configurable_coder_pool_size_and_default_model_in_project_toml

This commit is contained in:
Dave
2026-03-19 15:58:32 +00:00
parent 101b365354
commit 429597cbce
4 changed files with 386 additions and 2 deletions

View File

@@ -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");
}
}