From 819b72523fdb102800a422c0ffe25ced769510b7 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 26 Feb 2026 12:41:12 +0000 Subject: [PATCH] story-kit: merge 203_story_move_story_to_current_before_checking_agent_availability_in_start_agent --- server/src/agents.rs | 151 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 146 insertions(+), 5 deletions(-) diff --git a/server/src/agents.rs b/server/src/agents.rs index a9735ac..44eb371 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -352,6 +352,13 @@ impl AgentPool { let event_log: Arc>> = 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();