diff --git a/server/src/agents/pool/start.rs b/server/src/agents/pool/start.rs index 3e4373eb..4fff0527 100644 --- a/server/src/agents/pool/start.rs +++ b/server/src/agents/pool/start.rs @@ -47,10 +47,18 @@ impl AgentPool { // Move story from backlog/ 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. - crate::agents::lifecycle::move_story_to_current(project_root, story_id)?; + // when all coders are currently busy (story 203). Only do this for + // Coder-stage agents — QA and Mergemaster must attach to the story + // at its existing stage (3_qa or 4_merge) and must NOT be demoted + // back to 2_current/ on attach (bug 502). When `agent_name` is None + // we are auto-selecting an idle coder, so still move. + let starting_a_coder = agent_name + .and_then(|n| config.find_agent(n).map(agent_config_stage)) + .map(|s| s == PipelineStage::Coder) + .unwrap_or(true); + if starting_a_coder { + crate::agents::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, @@ -1377,6 +1385,73 @@ stage = "coder" } } + /// Bug 502: when start_agent is called for a non-Coder agent (mergemaster + /// or qa) on a story that's in 4_merge/, the unconditional + /// move_story_to_current at the top of start_agent must NOT fire — even + /// when a stale split-brain shadow of the story exists in 1_backlog/. + /// + /// Pre-fix behaviour: move_story_to_current would find the 1_backlog + /// shadow and move it to 2_current/. find_active_story_stage would then + /// report 2_current/, the stage check would expect a Coder-stage agent, + /// and mergemaster would be rejected — leaving the story in 2_current/ + /// to be picked up by the next auto-assign tick as a coder. Infinite loop. + /// Observed live on 2026-04-09 against story 478. + #[tokio::test] + async fn start_agent_does_not_demote_merge_stage_story_with_backlog_shadow() { + use std::fs; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + let sk_dir = root.join(".huskies"); + fs::create_dir_all(sk_dir.join("work/1_backlog")).unwrap(); + 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(); + // Real copy in 4_merge/ (where the story actually is per the DB). + fs::write( + sk_dir.join("work/4_merge/502_story_split_brain.md"), + "---\nname: Split Brain\n---\n", + ) + .unwrap(); + // Stale split-brain shadow in 1_backlog/ (post-491/492 migration + // artifact — the filesystem shadow that bit us in production). + fs::write( + sk_dir.join("work/1_backlog/502_story_split_brain.md"), + "---\nname: Split Brain\n---\n", + ) + .unwrap(); + + let pool = AgentPool::new_test(3098); + let result = pool + .start_agent(root, "502_story_split_brain", Some("mergemaster"), None) + .await; + + // Stage check must not reject mergemaster. + if let Err(ref e) = result { + assert!( + !e.contains("cannot be assigned"), + "mergemaster on 4_merge/ story must not fail stage check even \ + when a 1_backlog shadow exists, got: '{e}'" + ); + } + + // Critical: the story must still be in 4_merge/ after the call. + // Before the fix, line 53 of start.rs would have demoted it to + // 2_current/ via move_story_to_current finding the 1_backlog shadow. + assert!( + sk_dir.join("work/4_merge/502_story_split_brain.md").exists(), + "story must still be in 4_merge/ after start_agent(mergemaster, ...)" + ); + assert!( + !sk_dir.join("work/2_current/502_story_split_brain.md").exists(), + "story must NOT have been demoted to 2_current/ — that's bug 502" + ); + } + // ── front matter agent preference (bug 379) ────────────────────────────── #[tokio::test]