story-kit: merge 203_story_move_story_to_current_before_checking_agent_availability_in_start_agent

This commit is contained in:
Dave
2026-02-26 12:41:12 +00:00
parent 3a9790a277
commit 819b72523f

View File

@@ -352,6 +352,13 @@ impl AgentPool {
let event_log: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new())); let event_log: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
let log_session_id = uuid::Uuid::new_v4().to_string(); 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 // 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
@@ -376,8 +383,9 @@ impl AgentPool {
.any(|a| agent_config_stage(a) == PipelineStage::Coder) .any(|a| agent_config_stage(a) == PipelineStage::Coder)
{ {
format!( format!(
"All coder agents are busy; story '{story_id}' will be \ "All coder agents are busy; story '{story_id}' has been \
picked up when one becomes available" queued in work/2_current/ and will be auto-assigned when \
one becomes available"
) )
} else { } else {
"No coder agent configured. Specify an agent_name explicitly." "No coder agent configured. Specify an agent_name explicitly."
@@ -467,9 +475,6 @@ impl AgentPool {
status: "pending".to_string(), 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. // Extract inactivity timeout from the agent config before cloning config.
let inactivity_timeout_secs = config let inactivity_timeout_secs = config
.find_agent(&resolved_name) .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(&current).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(&current).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] #[tokio::test]
async fn start_agent_explicit_name_unchanged_when_busy() { async fn start_agent_explicit_name_unchanged_when_busy() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();