storkit: merge 379_bug_start_agent_ignores_story_front_matter_agent_assignment

This commit is contained in:
dave
2026-03-24 14:56:26 +00:00
parent 0b3ce0f33e
commit 9fb1bd5711

View File

@@ -253,6 +253,24 @@ impl AgentPool {
}
}
// Read the preferred agent from the story's front matter before acquiring
// the lock. When no explicit agent_name is given, this lets start_agent
// honour `agent: coder-opus` written by the `assign` command — mirroring
// the auto_assign path (bug 379).
let front_matter_agent: Option<String> = if agent_name.is_none() {
find_active_story_stage(project_root, story_id).and_then(|stage_dir| {
let path = project_root
.join(".storkit")
.join("work")
.join(stage_dir)
.join(format!("{story_id}.md"));
let contents = std::fs::read_to_string(path).ok()?;
crate::io::story_metadata::parse_front_matter(&contents).ok()?.agent
})
} else {
None
};
// Atomically resolve agent name, check availability, and register as
// Pending. When `agent_name` is `None` the first idle coder is
// selected inside the lock so no TOCTOU race can occur between the
@@ -268,7 +286,32 @@ impl AgentPool {
resolved_name = match agent_name {
Some(name) => name.to_string(),
None => auto_assign::find_free_agent_for_stage(&config, &agents, &PipelineStage::Coder)
None => {
// Honour the `agent:` field in the story's front matter so that
// `start 368` after `assign 368 opus` picks the right agent
// (bug 379). Mirrors the auto_assign selection logic.
if let Some(ref pref) = front_matter_agent {
let stage_matches = config
.find_agent(pref)
.map(|cfg| agent_config_stage(cfg) == PipelineStage::Coder)
.unwrap_or(false);
if stage_matches {
if auto_assign::is_agent_free(&agents, pref) {
pref.clone()
} else {
return Err(format!(
"Preferred agent '{pref}' from story front matter is busy; \
story '{story_id}' has been queued in work/2_current/ and will \
be auto-assigned when it becomes available"
));
}
} else {
// Stage mismatch — fall back to any free coder.
auto_assign::find_free_agent_for_stage(
&config,
&agents,
&PipelineStage::Coder,
)
.map(|s| s.to_string())
.ok_or_else(|| {
if config
@@ -285,7 +328,33 @@ impl AgentPool {
"No coder agent configured. Specify an agent_name explicitly."
.to_string()
}
})?,
})?
}
} else {
auto_assign::find_free_agent_for_stage(
&config,
&agents,
&PipelineStage::Coder,
)
.map(|s| s.to_string())
.ok_or_else(|| {
if config
.agent
.iter()
.any(|a| agent_config_stage(a) == PipelineStage::Coder)
{
format!(
"All coder agents are busy; story '{story_id}' has been \
queued in work/2_current/ and will be auto-assigned when \
one becomes available"
)
} else {
"No coder agent configured. Specify an agent_name explicitly."
.to_string()
}
})?
}
}
};
key = composite_key(story_id, &resolved_name);
@@ -2196,6 +2265,108 @@ stage = "coder"
assert_eq!(agents.len(), 1, "existing agents should not be affected");
}
// ── front matter agent preference (bug 379) ──────────────────────────────
#[tokio::test]
async fn start_agent_honours_front_matter_agent_when_idle() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
let backlog = sk.join("work/1_backlog");
std::fs::create_dir_all(&backlog).unwrap();
std::fs::write(
sk.join("project.toml"),
r#"
[[agent]]
name = "coder-sonnet"
stage = "coder"
[[agent]]
name = "coder-opus"
stage = "coder"
"#,
)
.unwrap();
// Story file with agent preference in front matter.
std::fs::write(
backlog.join("368_story_test.md"),
"---\nname: Test Story\nagent: coder-opus\n---\n# Story 368\n",
)
.unwrap();
let pool = AgentPool::new_test(3010);
// coder-sonnet is busy so without front matter the auto-selection
// would skip coder-opus and try something else.
pool.inject_test_agent("other-story", "coder-sonnet", AgentStatus::Running);
let result = pool
.start_agent(tmp.path(), "368_story_test", None, None)
.await;
match result {
Ok(info) => {
assert_eq!(
info.agent_name, "coder-opus",
"should pick the front-matter preferred agent"
);
}
Err(err) => {
// Allowed to fail for infrastructure reasons (no git repo),
// but NOT due to agent selection ignoring the preference.
assert!(
!err.contains("All coder agents are busy"),
"should not report busy when coder-opus is idle: {err}"
);
assert!(
!err.contains("coder-sonnet"),
"should not have picked coder-sonnet: {err}"
);
}
}
}
#[tokio::test]
async fn start_agent_returns_error_when_front_matter_agent_busy() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
let backlog = sk.join("work/1_backlog");
std::fs::create_dir_all(&backlog).unwrap();
std::fs::write(
sk.join("project.toml"),
r#"
[[agent]]
name = "coder-sonnet"
stage = "coder"
[[agent]]
name = "coder-opus"
stage = "coder"
"#,
)
.unwrap();
std::fs::write(
backlog.join("368_story_test.md"),
"---\nname: Test Story\nagent: coder-opus\n---\n# Story 368\n",
)
.unwrap();
let pool = AgentPool::new_test(3011);
// Preferred agent is busy — should NOT fall back to coder-sonnet.
pool.inject_test_agent("other-story", "coder-opus", AgentStatus::Running);
let result = pool
.start_agent(tmp.path(), "368_story_test", None, None)
.await;
assert!(result.is_err(), "expected error when preferred agent is busy");
let err = result.unwrap_err();
assert!(
err.contains("coder-opus"),
"error should mention the preferred agent: {err}"
);
assert!(
err.contains("busy") || err.contains("queued"),
"error should say agent is busy or story is queued: {err}"
);
}
// ── archive + cleanup integration test ───────────────────────────────────
#[tokio::test]