fix(502): don't demote merge-stage stories on mergemaster attach
start_agent unconditionally called move_story_to_current at the top of its body, before the agent-stage check. When called for mergemaster (or qa) on a story in 4_merge/ AND a stale 1_backlog/ shadow of the story existed (post-491/492 split-brain artifact), the move would find the shadow and yank it to 2_current/, find_active_story_stage would then report 2_current/, the stage check would expect a Coder agent, and mergemaster would be rejected — leaving the story in 2_current/ to be re-promoted by the next auto-assign tick. Infinite loop. Gate the move so it only fires for Coder-stage agents. QA and Mergemaster now attach to the story at its existing stage. Adds a regression test that reproduces the split-brain scenario by seeding both 4_merge/ and 1_backlog/ copies of the same story and asserting (1) the stage check does not reject mergemaster, and (2) the 4_merge/ copy is preserved (i.e. not demoted to 2_current/). Observed live on 2026-04-09 while story 478 was looping. Filed as bug 502. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
// 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]
|
||||
|
||||
Reference in New Issue
Block a user