story-kit: support agent assignment via story front matter (story 249)
Adds an optional `agent:` field to story file front matter so that a specific agent can be requested for a story. The auto-assign loop now: 1. Reads the front-matter `agent` field for each story before picking a free agent. 2. If a preferred agent is named, uses it when free; skips the story (without falling back) when that agent is busy. 3. Falls back to the existing `find_free_agent_for_stage` behaviour when no preference is specified. Ported from feature branch that predated the agents.rs module refactoring. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1377,7 +1377,13 @@ impl AgentPool {
|
|||||||
for story_id in &items {
|
for story_id in &items {
|
||||||
// Re-acquire the lock on each iteration to see state changes
|
// Re-acquire the lock on each iteration to see state changes
|
||||||
// from previous start_agent calls in the same pass.
|
// from previous start_agent calls in the same pass.
|
||||||
let (already_assigned, free_agent) = {
|
let preferred_agent =
|
||||||
|
read_story_front_matter_agent(project_root, stage_dir, story_id);
|
||||||
|
|
||||||
|
// Outcome: (already_assigned, chosen_agent, preferred_busy)
|
||||||
|
// preferred_busy=true means the story has a specific agent requested but it is
|
||||||
|
// currently occupied — the story should wait rather than fall back.
|
||||||
|
let (already_assigned, free_agent, preferred_busy) = {
|
||||||
let agents = match self.agents.lock() {
|
let agents = match self.agents.lock() {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1386,13 +1392,20 @@ impl AgentPool {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let assigned = is_story_assigned_for_stage(&config, &agents, story_id, stage);
|
let assigned = is_story_assigned_for_stage(&config, &agents, story_id, stage);
|
||||||
let free = if assigned {
|
if assigned {
|
||||||
None
|
(true, None, false)
|
||||||
|
} else if let Some(ref pref) = preferred_agent {
|
||||||
|
// Story has a front-matter agent preference.
|
||||||
|
if is_agent_free(&agents, pref) {
|
||||||
|
(false, Some(pref.clone()), false)
|
||||||
|
} else {
|
||||||
|
(false, None, true)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
find_free_agent_for_stage(&config, &agents, stage)
|
let free = find_free_agent_for_stage(&config, &agents, stage)
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string());
|
||||||
};
|
(false, free, false)
|
||||||
(assigned, free)
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if already_assigned {
|
if already_assigned {
|
||||||
@@ -1400,6 +1413,16 @@ impl AgentPool {
|
|||||||
continue;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
match free_agent {
|
match free_agent {
|
||||||
Some(agent_name) => {
|
Some(agent_name) => {
|
||||||
slog!(
|
slog!(
|
||||||
@@ -1815,6 +1838,29 @@ fn find_active_story_stage(project_root: &Path, story_id: &str) -> Option<&'stat
|
|||||||
|
|
||||||
/// Scan a work pipeline stage directory and return story IDs, sorted alphabetically.
|
/// Scan a work pipeline stage directory and return story IDs, sorted alphabetically.
|
||||||
/// Returns an empty `Vec` if the directory does not exist.
|
/// Returns an empty `Vec` if the directory does not exist.
|
||||||
|
/// Read the optional `agent:` field from the front matter of a story file.
|
||||||
|
///
|
||||||
|
/// Returns `Some(agent_name)` if the front matter specifies an agent, or `None`
|
||||||
|
/// if the field is absent or the file cannot be read / parsed.
|
||||||
|
fn read_story_front_matter_agent(project_root: &Path, stage_dir: &str, story_id: &str) -> Option<String> {
|
||||||
|
use crate::io::story_metadata::parse_front_matter;
|
||||||
|
let path = project_root
|
||||||
|
.join(".story_kit")
|
||||||
|
.join("work")
|
||||||
|
.join(stage_dir)
|
||||||
|
.join(format!("{story_id}.md"));
|
||||||
|
let contents = std::fs::read_to_string(path).ok()?;
|
||||||
|
parse_front_matter(&contents).ok()?.agent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return `true` if `agent_name` has no active (pending/running) entry in the pool.
|
||||||
|
fn is_agent_free(agents: &HashMap<String, StoryAgent>, agent_name: &str) -> bool {
|
||||||
|
!agents.values().any(|a| {
|
||||||
|
a.agent_name == agent_name
|
||||||
|
&& matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn scan_stage_items(project_root: &Path, stage_dir: &str) -> Vec<String> {
|
fn scan_stage_items(project_root: &Path, stage_dir: &str) -> Vec<String> {
|
||||||
let dir = project_root
|
let dir = project_root
|
||||||
.join(".story_kit")
|
.join(".story_kit")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub struct StoryMetadata {
|
|||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub coverage_baseline: Option<String>,
|
pub coverage_baseline: Option<String>,
|
||||||
pub merge_failure: Option<String>,
|
pub merge_failure: Option<String>,
|
||||||
|
pub agent: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -29,6 +30,7 @@ struct FrontMatter {
|
|||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
coverage_baseline: Option<String>,
|
coverage_baseline: Option<String>,
|
||||||
merge_failure: Option<String>,
|
merge_failure: Option<String>,
|
||||||
|
agent: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
|
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
|
||||||
@@ -61,6 +63,7 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata {
|
|||||||
name: front.name,
|
name: front.name,
|
||||||
coverage_baseline: front.coverage_baseline,
|
coverage_baseline: front.coverage_baseline,
|
||||||
merge_failure: front.merge_failure,
|
merge_failure: front.merge_failure,
|
||||||
|
agent: front.agent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user