From b9f3449021a9039467de431ce76f7a2d3d0b2abc Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 17 Mar 2026 00:03:49 +0000 Subject: [PATCH] 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 --- server/src/agents/pool.rs | 60 +++++++++++++++++++++++++++++---- server/src/io/story_metadata.rs | 3 ++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/server/src/agents/pool.rs b/server/src/agents/pool.rs index 092845b..9a8147c 100644 --- a/server/src/agents/pool.rs +++ b/server/src/agents/pool.rs @@ -1377,7 +1377,13 @@ impl AgentPool { for story_id in &items { // Re-acquire the lock on each iteration to see state changes // 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() { Ok(a) => a, Err(e) => { @@ -1386,13 +1392,20 @@ impl AgentPool { } }; let assigned = is_story_assigned_for_stage(&config, &agents, story_id, stage); - let free = if assigned { - None + if assigned { + (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 { - find_free_agent_for_stage(&config, &agents, stage) - .map(|s| s.to_string()) - }; - (assigned, free) + let free = find_free_agent_for_stage(&config, &agents, stage) + .map(|s| s.to_string()); + (false, free, false) + } }; if already_assigned { @@ -1400,6 +1413,16 @@ impl AgentPool { 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 { Some(agent_name) => { 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. /// 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 { + 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, 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 { let dir = project_root .join(".story_kit") diff --git a/server/src/io/story_metadata.rs b/server/src/io/story_metadata.rs index 40b00c2..4acef86 100644 --- a/server/src/io/story_metadata.rs +++ b/server/src/io/story_metadata.rs @@ -7,6 +7,7 @@ pub struct StoryMetadata { pub name: Option, pub coverage_baseline: Option, pub merge_failure: Option, + pub agent: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -29,6 +30,7 @@ struct FrontMatter { name: Option, coverage_baseline: Option, merge_failure: Option, + agent: Option, } pub fn parse_front_matter(contents: &str) -> Result { @@ -61,6 +63,7 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata { name: front.name, coverage_baseline: front.coverage_baseline, merge_failure: front.merge_failure, + agent: front.agent, } }