story-kit: merge 203_story_move_story_to_current_before_checking_agent_availability_in_start_agent
This commit is contained in:
@@ -352,6 +352,13 @@ impl AgentPool {
|
||||
let event_log: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let log_session_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// Move story from upcoming/ to current/ before checking agent
|
||||
// availability so that auto_assign_available_work can pick it up even
|
||||
// when all coders are currently busy (story 203). This is idempotent:
|
||||
// if the story is already in 2_current/ or a later stage, the call is
|
||||
// a no-op.
|
||||
move_story_to_current(project_root, story_id)?;
|
||||
|
||||
// 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
|
||||
@@ -376,8 +383,9 @@ impl AgentPool {
|
||||
.any(|a| agent_config_stage(a) == PipelineStage::Coder)
|
||||
{
|
||||
format!(
|
||||
"All coder agents are busy; story '{story_id}' will be \
|
||||
picked up when one becomes available"
|
||||
"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."
|
||||
@@ -467,9 +475,6 @@ impl AgentPool {
|
||||
status: "pending".to_string(),
|
||||
});
|
||||
|
||||
// Move story from upcoming/ to current/ and auto-commit before creating the worktree.
|
||||
move_story_to_current(project_root, story_id)?;
|
||||
|
||||
// Extract inactivity timeout from the agent config before cloning config.
|
||||
let inactivity_timeout_secs = config
|
||||
.find_agent(&resolved_name)
|
||||
@@ -6503,6 +6508,142 @@ stage = "coder"
|
||||
);
|
||||
}
|
||||
|
||||
/// Story 203: when all coders are busy the story file must be moved from
|
||||
/// 1_upcoming/ to 2_current/ so that auto_assign_available_work can pick
|
||||
/// it up once a coder finishes.
|
||||
#[tokio::test]
|
||||
async fn start_agent_moves_story_to_current_when_coders_busy() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
let upcoming = sk.join("work/1_upcoming");
|
||||
std::fs::create_dir_all(&upcoming).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
r#"
|
||||
[[agent]]
|
||||
name = "coder-1"
|
||||
stage = "coder"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Place the story in 1_upcoming/.
|
||||
std::fs::write(
|
||||
upcoming.join("story-3.md"),
|
||||
"---\nname: Story 3\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.inject_test_agent("story-1", "coder-1", AgentStatus::Running);
|
||||
|
||||
let result = pool
|
||||
.start_agent(tmp.path(), "story-3", None, None)
|
||||
.await;
|
||||
|
||||
// Should fail because all coders are busy.
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("All coder agents are busy"),
|
||||
"expected busy error, got: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.contains("queued in work/2_current/"),
|
||||
"expected story-to-current message, got: {err}"
|
||||
);
|
||||
|
||||
// Story must have been moved to 2_current/.
|
||||
let current_path = sk.join("work/2_current/story-3.md");
|
||||
assert!(
|
||||
current_path.exists(),
|
||||
"story should be in 2_current/ after busy error, but was not"
|
||||
);
|
||||
let upcoming_path = upcoming.join("story-3.md");
|
||||
assert!(
|
||||
!upcoming_path.exists(),
|
||||
"story should no longer be in 1_upcoming/"
|
||||
);
|
||||
}
|
||||
|
||||
/// Story 203: auto_assign_available_work must detect a story in 2_current/
|
||||
/// with no active agent and start an agent for it.
|
||||
#[tokio::test]
|
||||
async fn auto_assign_picks_up_story_queued_in_current() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
let current = sk.join("work/2_current");
|
||||
std::fs::create_dir_all(¤t).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
// Place the story in 2_current/ (simulating the "queued" state).
|
||||
std::fs::write(
|
||||
current.join("story-3.md"),
|
||||
"---\nname: Story 3\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
// No agents are running — coder-1 is free.
|
||||
|
||||
// auto_assign will try to call start_agent, which will attempt to create
|
||||
// a worktree (will fail without a git repo) — that is fine. We only need
|
||||
// to verify the agent is registered as Pending before the background
|
||||
// task eventually fails.
|
||||
pool.auto_assign_available_work(tmp.path()).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let has_pending = agents.values().any(|a| {
|
||||
a.agent_name == "coder-1"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
});
|
||||
assert!(
|
||||
has_pending,
|
||||
"auto_assign should have started coder-1 for story-3, but pool is empty"
|
||||
);
|
||||
}
|
||||
|
||||
/// Story 203: if a story is already in 2_current/ or later, start_agent
|
||||
/// must not fail — the move is a no-op.
|
||||
#[tokio::test]
|
||||
async fn start_agent_story_already_in_current_is_noop() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
let current = sk.join("work/2_current");
|
||||
std::fs::create_dir_all(¤t).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
// Place the story directly in 2_current/.
|
||||
std::fs::write(
|
||||
current.join("story-5.md"),
|
||||
"---\nname: Story 5\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
|
||||
// start_agent should attempt to assign coder-1 (no infra, so it will
|
||||
// fail for git reasons), but must NOT fail due to the story already
|
||||
// being in 2_current/.
|
||||
let result = pool
|
||||
.start_agent(tmp.path(), "story-5", None, None)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
assert!(
|
||||
!e.contains("Failed to move"),
|
||||
"should not fail on idempotent move, got: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_agent_explicit_name_unchanged_when_busy() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user