From e5d2465f6638990c7ed79059f9896e7ee74101c5 Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 13 May 2026 14:21:49 +0000 Subject: [PATCH] huskies: merge 974 --- server/src/agents/lifecycle.rs | 57 +++++++++++++++++++++++++ server/src/pipeline_state/tests.rs | 36 ++++++++++++++++ server/src/pipeline_state/transition.rs | 9 ++++ 3 files changed, 102 insertions(+) diff --git a/server/src/agents/lifecycle.rs b/server/src/agents/lifecycle.rs index 2f4ceca2..64b650e7 100644 --- a/server/src/agents/lifecycle.rs +++ b/server/src/agents/lifecycle.rs @@ -344,6 +344,8 @@ fn map_stage_move_to_event( (Stage::MergeFailure { .. }, "current") => Ok(PipelineEvent::FixupRequested), // Story 972: send MergeFailure story back to Qa for a QA agent to re-review. (Stage::MergeFailure { .. }, "qa") => Ok(PipelineEvent::ReQueuedForQa), + // Story 974: reopen a Done story for a post-merge hotfix. + (Stage::Done { .. }, "current") => Ok(PipelineEvent::HotfixRequested), ( Stage::Archived { reason: ArchiveReason::Blocked { .. }, @@ -809,6 +811,61 @@ mod tests { ); } + // ── Story 974: Done → Coding hotfix tests ──────────────────────────────── + + /// AC1 (story 974): move_story_to_stage to "current" from Done succeeds + /// and lands the story in Coding stage so auto-assign can pick it up. + #[test] + fn move_story_to_stage_from_done_lands_in_coding() { + crate::crdt_state::init_for_test(); + crate::db::ensure_content_store(); + + let story_id = "99974_story_hotfix_done_to_coding"; + crate::db::write_item_with_content( + story_id, + "done", + "---\nname: Hotfix Test\n---\n", + crate::db::ItemMeta::named("Hotfix Test"), + ); + + move_story_to_stage(story_id, "current").expect("move from Done to Coding 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 hotfix move" + ); + } + + /// AC1 (story 974): retry_count is reset to 0 after Done→Coding so the + /// fresh coder session starts clean. + #[test] + fn move_story_from_done_to_coding_resets_retry_count() { + crate::crdt_state::init_for_test(); + crate::db::ensure_content_store(); + + let story_id = "99975_story_hotfix_retry_count"; + crate::db::write_item_with_content( + story_id, + "done", + "---\nname: Hotfix Retry Count\n---\n", + crate::db::ItemMeta::named("Hotfix Retry Count"), + ); + + move_story_to_stage(story_id, "current").expect("move from Done to Coding must succeed"); + + let retry_count = crate::crdt_state::read_item(story_id) + .expect("CRDT item must exist") + .retry_count(); + assert_eq!( + retry_count, 0, + "retry_count must be 0 after hotfix move so coder starts fresh" + ); + } + /// Bug 226: feature_branch_has_unmerged_changes returns false when no /// feature branch exists. #[test] diff --git a/server/src/pipeline_state/tests.rs b/server/src/pipeline_state/tests.rs index 1d535e38..e93deaa2 100644 --- a/server/src/pipeline_state/tests.rs +++ b/server/src/pipeline_state/tests.rs @@ -993,4 +993,40 @@ fn move_story_merge_to_current_succeeds() { ); } +// ── Story 974: Done → Coding (hotfix) ───────────────────────────── + +#[test] +fn hotfix_requested_from_done_lands_in_coding() { + let done = Stage::Done { + merged_at: chrono::Utc::now(), + merge_commit: sha("abc123"), + }; + let result = transition(done, PipelineEvent::HotfixRequested).unwrap(); + assert!( + matches!(result, Stage::Coding), + "Done + HotfixRequested must land in Coding; got: {:?}", + result + ); +} + +#[test] +fn hotfix_requested_rejected_from_non_done_stages() { + for stage in [ + Stage::Backlog, + Stage::Coding, + Stage::Qa, + Stage::Merge { + feature_branch: fb("feature/story-1"), + commits_ahead: nz(1), + }, + ] { + let result = transition(stage.clone(), PipelineEvent::HotfixRequested); + assert!( + result.is_err(), + "HotfixRequested must be rejected from {:?}", + stage + ); + } +} + // ── ProjectionError Display ───────────────────────────────────────── diff --git a/server/src/pipeline_state/transition.rs b/server/src/pipeline_state/transition.rs index 5154bc75..e2723642 100644 --- a/server/src/pipeline_state/transition.rs +++ b/server/src/pipeline_state/transition.rs @@ -74,6 +74,8 @@ pub enum PipelineEvent { ReQueuedForQa, /// Story 973: user aborts an in-flight merge, sending the story back to Coding. MergeAborted, + /// Story 974: user re-opens a Done story for a post-merge hotfix, sending it back to Coding. + HotfixRequested, } // ── Per-node execution events ─────────────────────────────────────────────── @@ -120,6 +122,7 @@ pub fn event_label(e: &PipelineEvent) -> &'static str { PipelineEvent::FixupRequested => "FixupRequested", PipelineEvent::ReQueuedForQa => "ReQueuedForQa", PipelineEvent::MergeAborted => "MergeAborted", + PipelineEvent::HotfixRequested => "HotfixRequested", } } @@ -316,6 +319,12 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result Ok(Coding), + // ── HotfixRequested: Done → Coding (post-merge hotfix) ─────────── + // Allows reopening a completed story so a coder can apply a hotfix. + // A fresh feature branch is forked from master when auto-assign spawns + // the coder. + (Done { .. }, HotfixRequested) => Ok(Coding), + // ── MergemasterAttempted: MergeFailure → MergeFailureFinal ───── (MergeFailure { reason, .. }, MergemasterAttempted) => Ok(MergeFailureFinal { reason }), (MergeFailureFinal { reason }, MergemasterAttempted) => Ok(MergeFailureFinal { reason }),