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:
Dave
2026-03-17 00:03:49 +00:00
parent cd7444ac5c
commit b9f3449021
2 changed files with 56 additions and 7 deletions

View File

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

View File

@@ -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,
} }
} }