From 4b18c01835a09ea1683f22750e2f06f6c1632fc3 Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 13 May 2026 14:02:16 +0000 Subject: [PATCH] huskies: merge 973 --- server/src/agents/lifecycle.rs | 11 +++ .../src/agents/pool/pipeline/merge/runner.rs | 12 +++ server/src/pipeline_state/tests.rs | 88 +++++++++++++++++++ server/src/pipeline_state/transition.rs | 6 ++ 4 files changed, 117 insertions(+) diff --git a/server/src/agents/lifecycle.rs b/server/src/agents/lifecycle.rs index 0a9656e4..2f4ceca2 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 973: abort an in-flight merge, sending the story back to Coding. + (Stage::Merge { .. }, "current") => Ok(PipelineEvent::MergeAborted), // Story 971: send MergeFailure story back to Coding so a coder can fix it. (Stage::MergeFailure { .. }, "current") => Ok(PipelineEvent::FixupRequested), // Story 972: send MergeFailure story back to Qa for a QA agent to re-review. @@ -408,6 +410,15 @@ pub fn move_story_to_stage(story_id: &str, target_stage: &str) -> Result<(String crate::crdt_state::set_retry_count(story_id, 1); } + // Story 973: when aborting an in-flight merge, mark the CRDT merge job as + // "cancelled" so the background task skips state-machine transitions and + // watcher notifications once the git operation finishes. + if matches!(event, PipelineEvent::MergeAborted) + && let Some(job) = crate::crdt_state::read_merge_job(story_id) + { + crate::crdt_state::write_merge_job(story_id, "cancelled", job.started_at, None, None); + } + Ok((from_name.to_string(), target_stage.to_string())) } diff --git a/server/src/agents/pool/pipeline/merge/runner.rs b/server/src/agents/pool/pipeline/merge/runner.rs index 93aea7f6..52504d25 100644 --- a/server/src/agents/pool/pipeline/merge/runner.rs +++ b/server/src/agents/pool/pipeline/merge/runner.rs @@ -111,6 +111,18 @@ impl AgentPool { tokio::spawn(async move { let report = pool.run_merge_pipeline(&root, &sid).await; + + // Story 973: if the story was aborted (Merge → Coding) while the git + // operation was running, skip state-machine transitions and watcher + // notifications — they would reference the wrong stage. + if let Some(job) = crate::crdt_state::read_merge_job(&sid) + && job.status == "cancelled" + { + crate::crdt_state::delete_merge_job(&sid); + pool.auto_assign_available_work(&root).await; + return; + } + let success = matches!(&report, Ok(r) if r.success); let finished_at = unix_now(); diff --git a/server/src/pipeline_state/tests.rs b/server/src/pipeline_state/tests.rs index 283e4986..1d535e38 100644 --- a/server/src/pipeline_state/tests.rs +++ b/server/src/pipeline_state/tests.rs @@ -905,4 +905,92 @@ fn merge_failure_unblock_moves_to_merge_via_crdt() { ); } +// ── Story 973: Merge → Coding (abort in-flight merge) ─────────────── + +/// AC1 (pure): `Merge + MergeAborted` transitions to `Coding`. +#[test] +fn merge_aborted_returns_to_coding() { + let s = Stage::Merge { + feature_branch: fb("feature/story-73"), + commits_ahead: nz(2), + }; + let result = transition(s, PipelineEvent::MergeAborted).unwrap(); + assert!( + matches!(result, Stage::Coding), + "Merge + MergeAborted should return to Coding, got: {result:?}" + ); +} + +/// AC1 (CRDT): set stage to `Merge`, apply `MergeAborted`, assert CRDT stage is `coding`. +#[test] +fn merge_aborted_moves_to_coding_via_crdt() { + crate::crdt_state::init_for_test(); + crate::db::ensure_content_store(); + + let story_id = "99973_story_merge_aborted"; + crate::db::write_item_with_content( + story_id, + "merge", + "---\nname: Merge Aborted Test\n---\n# Story\n", + crate::db::ItemMeta::named("Merge Aborted Test"), + ); + + let fired = super::apply::apply_transition(story_id, PipelineEvent::MergeAborted, None) + .expect("Merge + MergeAborted should succeed"); + + assert!( + matches!(fired.before, Stage::Merge { .. }), + "fired.before should be Merge: {:?}", + fired.before + ); + assert!( + matches!(fired.after, Stage::Coding), + "fired.after should be Coding: {:?}", + fired.after + ); + + let item = read_typed(story_id) + .expect("CRDT read should succeed") + .expect("item should exist"); + assert_eq!( + item.stage.dir_name(), + "coding", + "CRDT stage should be coding after Merge + MergeAborted" + ); +} + +/// AC1 (move_story): `move_story_to_stage` with target "current" on a Merge story succeeds. +#[test] +fn move_story_merge_to_current_succeeds() { + crate::crdt_state::init_for_test(); + crate::db::ensure_content_store(); + + let story_id = "99973_story_move_merge_to_current"; + crate::db::write_item_with_content( + story_id, + "merge", + "---\nname: Move Merge To Current\n---\n", + crate::db::ItemMeta::named("Move Merge To Current"), + ); + + let result = crate::agents::lifecycle::move_story_to_stage(story_id, "current"); + assert!( + result.is_ok(), + "move_story_to_stage(merge → current) should succeed: {result:?}" + ); + + let (from, to) = result.unwrap(); + assert_eq!(from, "merge", "from_stage should be 'merge'"); + assert_eq!(to, "current", "to_stage should be 'current'"); + + let item = read_typed(story_id) + .expect("CRDT read should succeed") + .expect("item should exist"); + assert!( + matches!(item.stage, Stage::Coding), + "story should be in Coding after move_story_to_stage(merge → current): {:?}", + item.stage + ); +} + // ── ProjectionError Display ───────────────────────────────────────── diff --git a/server/src/pipeline_state/transition.rs b/server/src/pipeline_state/transition.rs index 6d699e16..5154bc75 100644 --- a/server/src/pipeline_state/transition.rs +++ b/server/src/pipeline_state/transition.rs @@ -72,6 +72,8 @@ pub enum PipelineEvent { FixupRequested, /// Story 972: user sends a MergeFailure story back to Qa for re-review. ReQueuedForQa, + /// Story 973: user aborts an in-flight merge, sending the story back to Coding. + MergeAborted, } // ── Per-node execution events ─────────────────────────────────────────────── @@ -117,6 +119,7 @@ pub fn event_label(e: &PipelineEvent) -> &'static str { PipelineEvent::MergemasterAttempted => "MergemasterAttempted", PipelineEvent::FixupRequested => "FixupRequested", PipelineEvent::ReQueuedForQa => "ReQueuedForQa", + PipelineEvent::MergeAborted => "MergeAborted", } } @@ -310,6 +313,9 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result Ok(Qa), + // ── MergeAborted: Merge → Coding (abort in-flight merge) ───────── + (Merge { .. }, MergeAborted) => Ok(Coding), + // ── MergemasterAttempted: MergeFailure → MergeFailureFinal ───── (MergeFailure { reason, .. }, MergemasterAttempted) => Ok(MergeFailureFinal { reason }), (MergeFailureFinal { reason }, MergemasterAttempted) => Ok(MergeFailureFinal { reason }),