From a47fbc41795501925132786429baed583755e402 Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 13 May 2026 13:10:43 +0000 Subject: [PATCH] huskies: merge 971 --- server/src/agents/lifecycle.rs | 80 ++++++++++++++++++++++++- server/src/pipeline_state/transition.rs | 6 ++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/server/src/agents/lifecycle.rs b/server/src/agents/lifecycle.rs index 8b56d241..b653ec5e 100644 --- a/server/src/agents/lifecycle.rs +++ b/server/src/agents/lifecycle.rs @@ -338,6 +338,8 @@ fn map_stage_move_to_event( // Story 919: MergeFailure + Unblock goes to Merge (re-attempt); manual // demotion to backlog uses Demote to park it without a retry. (Stage::MergeFailure { .. }, "backlog") => Ok(PipelineEvent::Demote), + // Story 971: send MergeFailure story back to Coding so a coder can fix it. + (Stage::MergeFailure { .. }, "current") => Ok(PipelineEvent::FixupRequested), ( Stage::Archived { reason: ArchiveReason::Blocked { .. }, @@ -395,7 +397,14 @@ pub fn move_story_to_stage(story_id: &str, target_stage: &str) -> Result<(String let event = map_stage_move_to_event(&item.stage, target_stage, story_id)?; - apply_transition(story_id, event, None).map_err(|e| e.to_string())?; + apply_transition(story_id, event.clone(), None).map_err(|e| e.to_string())?; + + // Story 971: after moving MergeFailure → Coding, set retry_count=1 so + // maybe_inject_gate_failure fires on the next spawn. Must happen AFTER + // apply_transition because move_item_stage resets retry_count to 0. + if matches!(event, PipelineEvent::FixupRequested) { + crate::crdt_state::set_retry_count(story_id, 1); + } Ok((from_name.to_string(), target_stage.to_string())) } @@ -718,6 +727,75 @@ mod tests { ); } + // ── Story 971: MergeFailure → Coding fixup tests ───────────────────────── + + /// AC1 (story 971): move_story_to_stage to "current" from MergeFailure + /// succeeds and lands the story in Coding stage. + #[test] + fn move_story_to_stage_from_merge_failure_lands_in_coding() { + crate::crdt_state::init_for_test(); + crate::db::ensure_content_store(); + + let story_id = "99960_story_merge_failure_fixup_971"; + crate::db::write_item_with_content( + story_id, + "merge_failure", + "---\nname: Merge Failure Fixup\n---\n", + crate::db::ItemMeta::named("Merge Failure Fixup"), + ); + + move_story_to_stage(story_id, "current").expect("move to current must succeed"); + + let item = crate::pipeline_state::read_typed(story_id) + .expect("CRDT read must succeed") + .expect("item must exist"); + assert_eq!( + item.stage.dir_name(), + "coding", + "story must be in coding after fixup move" + ); + } + + /// AC3 (story 971): retry_count is set to 1 after fixup move so that + /// spawn's maybe_inject_gate_failure will inject the pre-existing gate_output. + /// gate_output is seeded here the same way the merge pipeline seeds it. + #[test] + fn merge_failure_fixup_sets_retry_count_for_gate_output_injection() { + crate::crdt_state::init_for_test(); + crate::db::ensure_content_store(); + + let story_id = "99961_story_merge_failure_context_971"; + crate::db::write_item_with_content( + story_id, + "merge_failure", + "---\nname: Merge Failure Context\n---\n", + crate::db::ItemMeta::named("Merge Failure Context"), + ); + // Simulate what the merge pipeline stores when a merge fails. + crate::db::write_content( + crate::db::ContentKey::GateOutput(story_id), + "CONFLICT (content): server/src/lib.rs", + ); + + move_story_to_stage(story_id, "current").expect("move to current must succeed"); + + let retry_count = crate::crdt_state::read_item(story_id) + .expect("CRDT item must exist") + .retry_count(); + assert_eq!( + retry_count, 1, + "retry_count must be 1 after fixup move so gate_output injection fires on spawn" + ); + + // gate_output must still hold the merge pipeline's output unchanged. + let gate_output = crate::db::read_content(crate::db::ContentKey::GateOutput(story_id)) + .expect("gate_output must still be present after fixup move"); + assert!( + gate_output.contains("CONFLICT"), + "gate_output must retain merge failure details; got: {gate_output}" + ); + } + /// Bug 226: feature_branch_has_unmerged_changes returns false when no /// feature branch exists. #[test] diff --git a/server/src/pipeline_state/transition.rs b/server/src/pipeline_state/transition.rs index 3a5fe58e..e036eb49 100644 --- a/server/src/pipeline_state/transition.rs +++ b/server/src/pipeline_state/transition.rs @@ -68,6 +68,8 @@ pub enum PipelineEvent { /// Story 945: mergemaster has been auto-spawned and gave up; transitions /// `Stage::MergeFailure` → `Stage::MergeFailureFinal`. MergemasterAttempted, + /// Story 971: user sends a MergeFailure story back to Coding for coder fixup. + FixupRequested, } // ── Per-node execution events ─────────────────────────────────────────────── @@ -111,6 +113,7 @@ pub fn event_label(e: &PipelineEvent) -> &'static str { PipelineEvent::Unfreeze => "Unfreeze", PipelineEvent::ReviewHoldCleared => "ReviewHoldCleared", PipelineEvent::MergemasterAttempted => "MergemasterAttempted", + PipelineEvent::FixupRequested => "FixupRequested", } } @@ -298,6 +301,9 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result Ok(*resume_to), + // ── FixupRequested: MergeFailure → Coding (coder fixup) ──────── + (MergeFailure { .. }, FixupRequested) => Ok(Coding), + // ── MergemasterAttempted: MergeFailure → MergeFailureFinal ───── (MergeFailure { reason, .. }, MergemasterAttempted) => Ok(MergeFailureFinal { reason }), (MergeFailureFinal { reason }, MergemasterAttempted) => Ok(MergeFailureFinal { reason }),