story-kit: merge 153_bug_auto_assign_broken_after_stage_field_was_added_to_agent_config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-24 15:50:34 +00:00
parent a68ce38950
commit 774548c04c
2 changed files with 119 additions and 5 deletions

View File

@@ -2,7 +2,7 @@ use crate::agent_log::AgentLogWriter;
use crate::slog; use crate::slog;
use crate::slog_error; use crate::slog_error;
use crate::slog_warn; use crate::slog_warn;
use crate::config::ProjectConfig; use crate::config::{AgentConfig, ProjectConfig};
use crate::worktree::{self, WorktreeInfo}; use crate::worktree::{self, WorktreeInfo};
use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use serde::Serialize; use serde::Serialize;
@@ -160,6 +160,21 @@ pub fn pipeline_stage(agent_name: &str) -> PipelineStage {
} }
} }
/// Determine the pipeline stage for a configured agent.
///
/// Prefers the explicit `stage` config field (added in Bug 150) over the
/// legacy name-based heuristic so that agents with non-standard names
/// (e.g. `qa-2`, `coder-opus`) are assigned to the correct stage.
fn agent_config_stage(cfg: &AgentConfig) -> PipelineStage {
match cfg.stage.as_deref() {
Some("coder") => PipelineStage::Coder,
Some("qa") => PipelineStage::Qa,
Some("mergemaster") => PipelineStage::Mergemaster,
Some(_) => PipelineStage::Other,
None => pipeline_stage(&cfg.name),
}
}
/// Completion report produced when acceptance gates are run. /// Completion report produced when acceptance gates are run.
/// ///
/// Created automatically by the server when an agent process exits normally, /// Created automatically by the server when an agent process exits normally,
@@ -721,7 +736,11 @@ impl AgentPool {
} }
}; };
let stage = pipeline_stage(agent_name); let config = ProjectConfig::load(&project_root).unwrap_or_default();
let stage = config
.find_agent(agent_name)
.map(agent_config_stage)
.unwrap_or_else(|| pipeline_stage(agent_name));
match stage { match stage {
PipelineStage::Other => { PipelineStage::Other => {
@@ -1222,7 +1241,7 @@ impl AgentPool {
break; break;
} }
}; };
let assigned = is_story_assigned_for_stage(&agents, story_id, stage); let assigned = is_story_assigned_for_stage(&config, &agents, story_id, stage);
let free = if assigned { let free = if assigned {
None None
} else { } else {
@@ -1689,7 +1708,11 @@ fn scan_stage_items(project_root: &Path, stage_dir: &str) -> Vec<String> {
} }
/// Return `true` if `story_id` has any active (pending/running) agent matching `stage`. /// Return `true` if `story_id` has any active (pending/running) agent matching `stage`.
///
/// Uses the explicit `stage` config field when the agent is found in `config`;
/// falls back to the legacy name-based heuristic for unlisted agents.
fn is_story_assigned_for_stage( fn is_story_assigned_for_stage(
config: &ProjectConfig,
agents: &HashMap<String, StoryAgent>, agents: &HashMap<String, StoryAgent>,
story_id: &str, story_id: &str,
stage: &PipelineStage, stage: &PipelineStage,
@@ -1697,21 +1720,26 @@ fn is_story_assigned_for_stage(
agents.iter().any(|(key, agent)| { agents.iter().any(|(key, agent)| {
// Composite key format: "{story_id}:{agent_name}" // Composite key format: "{story_id}:{agent_name}"
let key_story_id = key.rsplit_once(':').map(|(sid, _)| sid).unwrap_or(key); let key_story_id = key.rsplit_once(':').map(|(sid, _)| sid).unwrap_or(key);
let agent_stage = config
.find_agent(&agent.agent_name)
.map(agent_config_stage)
.unwrap_or_else(|| pipeline_stage(&agent.agent_name));
key_story_id == story_id key_story_id == story_id
&& pipeline_stage(&agent.agent_name) == *stage && agent_stage == *stage
&& matches!(agent.status, AgentStatus::Running | AgentStatus::Pending) && matches!(agent.status, AgentStatus::Running | AgentStatus::Pending)
}) })
} }
/// Find the first configured agent for `stage` that has no active (pending/running) assignment. /// 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. /// 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.
fn find_free_agent_for_stage<'a>( fn find_free_agent_for_stage<'a>(
config: &'a ProjectConfig, config: &'a ProjectConfig,
agents: &HashMap<String, StoryAgent>, agents: &HashMap<String, StoryAgent>,
stage: &PipelineStage, stage: &PipelineStage,
) -> Option<&'a str> { ) -> Option<&'a str> {
for agent_config in &config.agent { for agent_config in &config.agent {
if pipeline_stage(&agent_config.name) != *stage { if agent_config_stage(agent_config) != *stage {
continue; continue;
} }
let is_busy = agents.values().any(|a| { let is_busy = agents.values().any(|a| {
@@ -4117,23 +4145,28 @@ mod tests {
#[test] #[test]
fn is_story_assigned_returns_true_for_running_coder() { fn is_story_assigned_returns_true_for_running_coder() {
use crate::config::ProjectConfig;
let config = ProjectConfig::default();
let pool = AgentPool::new(3001); let pool = AgentPool::new(3001);
pool.inject_test_agent("42_story_foo", "coder-1", AgentStatus::Running); pool.inject_test_agent("42_story_foo", "coder-1", AgentStatus::Running);
let agents = pool.agents.lock().unwrap(); let agents = pool.agents.lock().unwrap();
assert!(is_story_assigned_for_stage( assert!(is_story_assigned_for_stage(
&config,
&agents, &agents,
"42_story_foo", "42_story_foo",
&PipelineStage::Coder &PipelineStage::Coder
)); ));
// Same story but wrong stage — should be false // Same story but wrong stage — should be false
assert!(!is_story_assigned_for_stage( assert!(!is_story_assigned_for_stage(
&config,
&agents, &agents,
"42_story_foo", "42_story_foo",
&PipelineStage::Qa &PipelineStage::Qa
)); ));
// Different story — should be false // Different story — should be false
assert!(!is_story_assigned_for_stage( assert!(!is_story_assigned_for_stage(
&config,
&agents, &agents,
"99_story_other", "99_story_other",
&PipelineStage::Coder &PipelineStage::Coder
@@ -4142,12 +4175,15 @@ mod tests {
#[test] #[test]
fn is_story_assigned_returns_false_for_completed_agent() { fn is_story_assigned_returns_false_for_completed_agent() {
use crate::config::ProjectConfig;
let config = ProjectConfig::default();
let pool = AgentPool::new(3001); let pool = AgentPool::new(3001);
pool.inject_test_agent("42_story_foo", "coder-1", AgentStatus::Completed); pool.inject_test_agent("42_story_foo", "coder-1", AgentStatus::Completed);
let agents = pool.agents.lock().unwrap(); let agents = pool.agents.lock().unwrap();
// Completed agents don't count as assigned // Completed agents don't count as assigned
assert!(!is_story_assigned_for_stage( assert!(!is_story_assigned_for_stage(
&config,
&agents, &agents,
"42_story_foo", "42_story_foo",
&PipelineStage::Coder &PipelineStage::Coder
@@ -4240,6 +4276,78 @@ name = "qa"
assert_eq!(free_qa, Some("qa")); assert_eq!(free_qa, Some("qa"));
} }
// ── agent_config_stage / stage field tests ────────────────────────────────
#[test]
fn find_free_agent_uses_config_stage_field_not_name() {
// Agents named "qa-2" and "coder-opus" don't match the legacy name heuristic
// but should be picked up via their explicit stage field.
use crate::config::ProjectConfig;
let config = ProjectConfig::parse(
r#"
[[agent]]
name = "qa-2"
stage = "qa"
[[agent]]
name = "coder-opus"
stage = "coder"
"#,
)
.unwrap();
let agents: HashMap<String, StoryAgent> = HashMap::new();
// qa-2 should be found for PipelineStage::Qa via config stage field
let free_qa = find_free_agent_for_stage(&config, &agents, &PipelineStage::Qa);
assert_eq!(free_qa, Some("qa-2"), "qa-2 with stage=qa should be found");
// coder-opus should be found for PipelineStage::Coder via config stage field
let free_coder = find_free_agent_for_stage(&config, &agents, &PipelineStage::Coder);
assert_eq!(
free_coder,
Some("coder-opus"),
"coder-opus with stage=coder should be found"
);
// Neither should match the other stage
let free_merge = find_free_agent_for_stage(&config, &agents, &PipelineStage::Mergemaster);
assert!(free_merge.is_none());
}
#[test]
fn is_story_assigned_uses_config_stage_field_for_nonstandard_names() {
use crate::config::ProjectConfig;
let config = ProjectConfig::parse(
r#"
[[agent]]
name = "qa-2"
stage = "qa"
"#,
)
.unwrap();
let pool = AgentPool::new(3001);
pool.inject_test_agent("42_story_foo", "qa-2", AgentStatus::Running);
let agents = pool.agents.lock().unwrap();
// qa-2 with stage=qa should be recognised as a QA agent
assert!(
is_story_assigned_for_stage(&config, &agents, "42_story_foo", &PipelineStage::Qa),
"qa-2 should be detected as assigned to QA stage"
);
// Should NOT appear as a coder
assert!(
!is_story_assigned_for_stage(
&config,
&agents,
"42_story_foo",
&PipelineStage::Coder
),
"qa-2 should not be detected as a coder"
);
}
// ── find_active_story_stage tests ───────────────────────────────────────── // ── find_active_story_stage tests ─────────────────────────────────────────
#[test] #[test]

View File

@@ -45,6 +45,11 @@ pub struct AgentConfig {
pub max_budget_usd: Option<f64>, pub max_budget_usd: Option<f64>,
#[serde(default)] #[serde(default)]
pub system_prompt: Option<String>, pub system_prompt: Option<String>,
/// Pipeline stage this agent belongs to. Supported values: "coder", "qa",
/// "mergemaster", "other". When set, overrides the legacy name-based
/// detection used by `pipeline_stage()`.
#[serde(default)]
pub stage: Option<String>,
/// Inactivity timeout in seconds for the PTY read loop. /// Inactivity timeout in seconds for the PTY read loop.
/// If no output is received within this duration, the agent process is killed /// If no output is received within this duration, the agent process is killed
/// and marked as Failed. Default: 300 (5 minutes). Set to 0 to disable. /// and marked as Failed. Default: 300 (5 minutes). Set to 0 to disable.
@@ -99,6 +104,7 @@ impl Default for ProjectConfig {
max_turns: None, max_turns: None,
max_budget_usd: None, max_budget_usd: None,
system_prompt: None, system_prompt: None,
stage: None,
inactivity_timeout_secs: default_inactivity_timeout_secs(), inactivity_timeout_secs: default_inactivity_timeout_secs(),
}], }],
} }