story-kit: merge 312_bug_auto_assign_assigns_mergemaster_to_coding_stage_stories
This commit is contained in:
@@ -219,6 +219,33 @@ impl AgentPool {
|
|||||||
// a no-op.
|
// a no-op.
|
||||||
super::lifecycle::move_story_to_current(project_root, story_id)?;
|
super::lifecycle::move_story_to_current(project_root, story_id)?;
|
||||||
|
|
||||||
|
// Validate that the agent's configured stage matches the story's
|
||||||
|
// pipeline stage. This prevents any caller (auto-assign, MCP tool,
|
||||||
|
// pipeline advance, supervisor) from starting a wrong-stage agent on
|
||||||
|
// a story — e.g. mergemaster on a coding-stage story (bug 312).
|
||||||
|
if let Some(name) = agent_name {
|
||||||
|
let agent_stage = config
|
||||||
|
.find_agent(name)
|
||||||
|
.map(agent_config_stage)
|
||||||
|
.unwrap_or_else(|| pipeline_stage(name));
|
||||||
|
if agent_stage != PipelineStage::Other
|
||||||
|
&& let Some(story_stage_dir) = find_active_story_stage(project_root, story_id)
|
||||||
|
{
|
||||||
|
let expected_stage = match story_stage_dir {
|
||||||
|
"2_current" => PipelineStage::Coder,
|
||||||
|
"3_qa" => PipelineStage::Qa,
|
||||||
|
"4_merge" => PipelineStage::Mergemaster,
|
||||||
|
_ => PipelineStage::Other,
|
||||||
|
};
|
||||||
|
if expected_stage != PipelineStage::Other && expected_stage != agent_stage {
|
||||||
|
return Err(format!(
|
||||||
|
"Agent '{name}' (stage: {agent_stage:?}) cannot be assigned to \
|
||||||
|
story '{story_id}' in {story_stage_dir}/ (requires stage: {expected_stage:?})"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Atomically resolve agent name, check availability, and register as
|
// Atomically resolve agent name, check availability, and register as
|
||||||
// Pending. When `agent_name` is `None` the first idle coder is
|
// Pending. When `agent_name` is `None` the first idle coder is
|
||||||
// selected inside the lock so no TOCTOU race can occur between the
|
// selected inside the lock so no TOCTOU race can occur between the
|
||||||
@@ -4205,10 +4232,14 @@ stage = "coder"
|
|||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
|
|
||||||
// Minimal project.toml with a "qa" agent.
|
// Minimal project.toml with a coder agent (must match 2_current/ stage).
|
||||||
let sk_dir = root.join(".story_kit");
|
let sk_dir = root.join(".story_kit");
|
||||||
fs::create_dir_all(&sk_dir).unwrap();
|
fs::create_dir_all(&sk_dir).unwrap();
|
||||||
fs::write(sk_dir.join("project.toml"), "[[agent]]\nname = \"qa\"\n").unwrap();
|
fs::write(
|
||||||
|
sk_dir.join("project.toml"),
|
||||||
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Create the story in upcoming so `move_story_to_current` succeeds,
|
// Create the story in upcoming so `move_story_to_current` succeeds,
|
||||||
// but do NOT init a git repo — `create_worktree` will fail in the spawn.
|
// but do NOT init a git repo — `create_worktree` will fail in the spawn.
|
||||||
@@ -4219,7 +4250,7 @@ stage = "coder"
|
|||||||
let pool = AgentPool::new_test(3099);
|
let pool = AgentPool::new_test(3099);
|
||||||
|
|
||||||
let result = pool
|
let result = pool
|
||||||
.start_agent(root, "50_story_test", Some("qa"), None)
|
.start_agent(root, "50_story_test", Some("coder-1"), None)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// With the non-blocking flow, start_agent returns Ok(Pending) immediately.
|
// With the non-blocking flow, start_agent returns Ok(Pending) immediately.
|
||||||
@@ -4238,7 +4269,7 @@ stage = "coder"
|
|||||||
// Wait for the background task to reach a terminal state.
|
// Wait for the background task to reach a terminal state.
|
||||||
// It must fail (no git repo → create_worktree returns an error).
|
// It must fail (no git repo → create_worktree returns an error).
|
||||||
let final_info = pool
|
let final_info = pool
|
||||||
.wait_for_agent("50_story_test", "qa", 5000)
|
.wait_for_agent("50_story_test", "coder-1", 5000)
|
||||||
.await
|
.await
|
||||||
.expect("wait_for_agent should not time out");
|
.expect("wait_for_agent should not time out");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -4251,7 +4282,7 @@ stage = "coder"
|
|||||||
let agents = pool.agents.lock().unwrap();
|
let agents = pool.agents.lock().unwrap();
|
||||||
let failed_entry = agents
|
let failed_entry = agents
|
||||||
.values()
|
.values()
|
||||||
.find(|a| a.agent_name == "qa" && a.status == AgentStatus::Failed);
|
.find(|a| a.agent_name == "coder-1" && a.status == AgentStatus::Failed);
|
||||||
assert!(
|
assert!(
|
||||||
failed_entry.is_some(),
|
failed_entry.is_some(),
|
||||||
"agent pool must retain a Failed entry so the UI can show the error state"
|
"agent pool must retain a Failed entry so the UI can show the error state"
|
||||||
@@ -4261,7 +4292,7 @@ stage = "coder"
|
|||||||
// The AgentEvent::Error must be persisted in the event_log so late
|
// The AgentEvent::Error must be persisted in the event_log so late
|
||||||
// subscribers / polling clients can see the failure reason.
|
// subscribers / polling clients can see the failure reason.
|
||||||
let events = pool
|
let events = pool
|
||||||
.drain_events("50_story_test", "qa")
|
.drain_events("50_story_test", "coder-1")
|
||||||
.expect("drain_events should succeed");
|
.expect("drain_events should succeed");
|
||||||
let has_error_event = events.iter().any(|e| matches!(e, AgentEvent::Error { .. }));
|
let has_error_event = events.iter().any(|e| matches!(e, AgentEvent::Error { .. }));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -4661,6 +4692,198 @@ stage = "coder"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── bug 312: stage-pipeline mismatch guard in start_agent ──────────────
|
||||||
|
|
||||||
|
/// Bug 312: start_agent must reject a mergemaster on a story in 2_current/.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn start_agent_rejects_mergemaster_on_coding_stage_story() {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
|
||||||
|
let sk_dir = root.join(".story_kit");
|
||||||
|
fs::create_dir_all(sk_dir.join("work/2_current")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk_dir.join("project.toml"),
|
||||||
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
|
||||||
|
[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk_dir.join("work/2_current/310_story_foo.md"),
|
||||||
|
"---\nname: Foo\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pool = AgentPool::new_test(3099);
|
||||||
|
let result = pool
|
||||||
|
.start_agent(root, "310_story_foo", Some("mergemaster"), None)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"mergemaster must not be assigned to a story in 2_current/"
|
||||||
|
);
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("stage") && err.contains("2_current"),
|
||||||
|
"error must mention stage mismatch, got: '{err}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 312: start_agent must reject a coder on a story in 3_qa/.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn start_agent_rejects_coder_on_qa_stage_story() {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
|
||||||
|
let sk_dir = root.join(".story_kit");
|
||||||
|
fs::create_dir_all(sk_dir.join("work/3_qa")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk_dir.join("project.toml"),
|
||||||
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
|
||||||
|
[[agent]]\nname = \"qa\"\nstage = \"qa\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk_dir.join("work/3_qa/42_story_bar.md"),
|
||||||
|
"---\nname: Bar\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pool = AgentPool::new_test(3099);
|
||||||
|
let result = pool
|
||||||
|
.start_agent(root, "42_story_bar", Some("coder-1"), None)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"coder must not be assigned to a story in 3_qa/"
|
||||||
|
);
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("stage") && err.contains("3_qa"),
|
||||||
|
"error must mention stage mismatch, got: '{err}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 312: start_agent must reject a QA agent on a story in 4_merge/.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn start_agent_rejects_qa_on_merge_stage_story() {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
|
||||||
|
let sk_dir = root.join(".story_kit");
|
||||||
|
fs::create_dir_all(sk_dir.join("work/4_merge")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk_dir.join("project.toml"),
|
||||||
|
"[[agent]]\nname = \"qa\"\nstage = \"qa\"\n\n\
|
||||||
|
[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk_dir.join("work/4_merge/55_story_baz.md"),
|
||||||
|
"---\nname: Baz\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pool = AgentPool::new_test(3099);
|
||||||
|
let result = pool
|
||||||
|
.start_agent(root, "55_story_baz", Some("qa"), None)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"qa must not be assigned to a story in 4_merge/"
|
||||||
|
);
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("stage") && err.contains("4_merge"),
|
||||||
|
"error must mention stage mismatch, got: '{err}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 312: supervisor (stage=other) should be allowed on any pipeline stage.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn start_agent_allows_supervisor_on_any_stage() {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
|
||||||
|
let sk_dir = root.join(".story_kit");
|
||||||
|
fs::create_dir_all(sk_dir.join("work/2_current")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk_dir.join("project.toml"),
|
||||||
|
"[[agent]]\nname = \"supervisor\"\nstage = \"other\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk_dir.join("work/2_current/77_story_sup.md"),
|
||||||
|
"---\nname: Sup\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pool = AgentPool::new_test(3099);
|
||||||
|
// start_agent will fail for git/worktree reasons, but NOT for stage
|
||||||
|
// mismatch. We just need to verify it doesn't fail with a stage error.
|
||||||
|
let result = pool
|
||||||
|
.start_agent(root, "77_story_sup", Some("supervisor"), None)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {} // Fine — no stage error.
|
||||||
|
Err(e) => {
|
||||||
|
assert!(
|
||||||
|
!e.contains("stage:") || !e.contains("cannot be assigned"),
|
||||||
|
"supervisor should not be rejected for stage mismatch, got: '{e}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 312: correct stage agent should still be allowed.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn start_agent_allows_correct_stage_agent() {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
|
||||||
|
let sk_dir = root.join(".story_kit");
|
||||||
|
fs::create_dir_all(sk_dir.join("work/4_merge")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk_dir.join("project.toml"),
|
||||||
|
"[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk_dir.join("work/4_merge/88_story_ok.md"),
|
||||||
|
"---\nname: OK\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pool = AgentPool::new_test(3099);
|
||||||
|
let result = pool
|
||||||
|
.start_agent(root, "88_story_ok", Some("mergemaster"), None)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {} // Fine — correct stage.
|
||||||
|
Err(e) => {
|
||||||
|
assert!(
|
||||||
|
!e.contains("cannot be assigned"),
|
||||||
|
"mergemaster on 4_merge/ story should not fail stage check, got: '{e}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── merge_agent_work tests ────────────────────────────────────────────────
|
// ── merge_agent_work tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Helper: start a merge and poll until terminal state.
|
/// Helper: start a merge and poll until terminal state.
|
||||||
|
|||||||
Reference in New Issue
Block a user