huskies: merge 919
This commit is contained in:
@@ -687,6 +687,8 @@ fn merge_failure_transition_emits_event_with_full_reason() {
|
||||
fn merge_failure_plus_merge_failed_is_self_loop() {
|
||||
let s = Stage::MergeFailure {
|
||||
reason: "initial failure".into(),
|
||||
feature_branch: fb("feature/story-1"),
|
||||
commits_ahead: nz(1),
|
||||
};
|
||||
let result = transition(
|
||||
s,
|
||||
@@ -758,18 +760,20 @@ fn repeated_merge_failure_apply_transition_no_error_no_duplicate_notification()
|
||||
);
|
||||
}
|
||||
|
||||
// ── Story 893: MergeFailure + Unblock → Coding (retry) ─────────────
|
||||
// ── Story 919: MergeFailure + Unblock → Merge (re-attempt) ─────────
|
||||
|
||||
/// AC1: `MergeFailure + Unblock` transitions to `Coding` (retry), not `Backlog`.
|
||||
/// AC1: `MergeFailure + Unblock` transitions to `Merge` (re-attempt), not `Coding` or `Backlog`.
|
||||
#[test]
|
||||
fn merge_failure_unblock_returns_to_coding() {
|
||||
fn merge_failure_unblock_returns_to_merge() {
|
||||
let s = Stage::MergeFailure {
|
||||
reason: "conflicts in server/src/main.rs".into(),
|
||||
feature_branch: fb("feature/story-42"),
|
||||
commits_ahead: nz(3),
|
||||
};
|
||||
let result = transition(s, PipelineEvent::Unblock).unwrap();
|
||||
assert!(
|
||||
matches!(result, Stage::Coding),
|
||||
"MergeFailure + Unblock should return to Coding for immediate retry, got: {result:?}"
|
||||
matches!(result, Stage::Merge { .. }),
|
||||
"MergeFailure + Unblock should return to Merge for immediate re-attempt, got: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -778,6 +782,8 @@ fn merge_failure_unblock_returns_to_coding() {
|
||||
fn merge_failure_demote_returns_to_backlog() {
|
||||
let s = Stage::MergeFailure {
|
||||
reason: "conflicts".into(),
|
||||
feature_branch: fb("feature/story-1"),
|
||||
commits_ahead: nz(1),
|
||||
};
|
||||
let result = transition(s, PipelineEvent::Demote).unwrap();
|
||||
assert!(
|
||||
@@ -794,6 +800,8 @@ fn merge_failure_demote_returns_to_backlog() {
|
||||
fn merge_failure_accept_pure_transition() {
|
||||
let s = Stage::MergeFailure {
|
||||
reason: "conflicts unresolvable".into(),
|
||||
feature_branch: fb("feature/story-1"),
|
||||
commits_ahead: nz(1),
|
||||
};
|
||||
let result = transition(s, PipelineEvent::Accepted).unwrap();
|
||||
assert!(
|
||||
@@ -852,4 +860,49 @@ fn merge_failure_accept_moves_to_done_via_crdt() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Story 919: MergeFailure + Unblock → Merge (regression) ─────────
|
||||
|
||||
/// AC3: CRDT-based regression — set stage to `MergeFailure`, call `unblock_story`
|
||||
/// via `apply_transition`, assert the Stage register becomes `Stage::Merge`.
|
||||
#[test]
|
||||
fn merge_failure_unblock_moves_to_merge_via_crdt() {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
|
||||
let story_id = "99919_story_merge_failure_unblock";
|
||||
crate::db::write_item_with_content(
|
||||
story_id,
|
||||
"merge_failure",
|
||||
"---\nname: MergeFailure Unblock Regression\n---\n# Story\n",
|
||||
crate::db::ItemMeta::named("MergeFailure Unblock Regression"),
|
||||
);
|
||||
|
||||
let fired = super::apply::apply_transition(story_id, PipelineEvent::Unblock, None)
|
||||
.expect("MergeFailure + Unblock should succeed");
|
||||
|
||||
assert!(
|
||||
matches!(fired.before, Stage::MergeFailure { .. }),
|
||||
"fired.before should be MergeFailure: {:?}",
|
||||
fired.before
|
||||
);
|
||||
assert!(
|
||||
matches!(fired.after, Stage::Merge { .. }),
|
||||
"fired.after should be Merge, not Coding or Backlog: {:?}",
|
||||
fired.after
|
||||
);
|
||||
|
||||
let item = read_typed(story_id)
|
||||
.expect("CRDT read should succeed")
|
||||
.expect("item should exist");
|
||||
assert_eq!(
|
||||
item.stage.dir_name(),
|
||||
"merge",
|
||||
"CRDT stage should be merge after MergeFailure + Unblock"
|
||||
);
|
||||
assert!(
|
||||
!matches!(item.stage, Stage::MergeFailure { .. }),
|
||||
"MergeFailure variant must not remain after Unblock"
|
||||
);
|
||||
}
|
||||
|
||||
// ── ProjectionError Display ─────────────────────────────────────────
|
||||
|
||||
@@ -197,13 +197,34 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
|
||||
}
|
||||
|
||||
// ── MergeFailed: Merge → MergeFailure (recoverable intermediate) ──
|
||||
(Merge { .. }, MergeFailed { reason }) => Ok(MergeFailure { reason }),
|
||||
(
|
||||
Merge {
|
||||
feature_branch,
|
||||
commits_ahead,
|
||||
},
|
||||
MergeFailed { reason },
|
||||
) => Ok(MergeFailure {
|
||||
reason,
|
||||
feature_branch,
|
||||
commits_ahead,
|
||||
}),
|
||||
|
||||
// ── MergeFailure self-loop: repeated failure is a no-op ─────────────
|
||||
// When the mergemaster retries and fails again while the story is already
|
||||
// in MergeFailure, treat it as a silent self-transition so callers can
|
||||
// detect the no-op via `fired.before == MergeFailure` and skip re-notifying.
|
||||
(MergeFailure { .. }, MergeFailed { reason }) => Ok(MergeFailure { reason }),
|
||||
(
|
||||
MergeFailure {
|
||||
feature_branch,
|
||||
commits_ahead,
|
||||
..
|
||||
},
|
||||
MergeFailed { reason },
|
||||
) => Ok(MergeFailure {
|
||||
reason,
|
||||
feature_branch,
|
||||
commits_ahead,
|
||||
}),
|
||||
|
||||
(Merge { .. }, MergeFailedFinal { reason }) => Ok(Archived {
|
||||
archived_at: now,
|
||||
@@ -278,7 +299,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
|
||||
(Stage::ReviewHold { resume_to, .. }, ReviewHoldCleared) => Ok(*resume_to),
|
||||
|
||||
// ── MergemasterAttempted: MergeFailure → MergeFailureFinal ─────
|
||||
(MergeFailure { reason }, MergemasterAttempted) => Ok(MergeFailureFinal { reason }),
|
||||
(MergeFailure { reason, .. }, MergemasterAttempted) => Ok(MergeFailureFinal { reason }),
|
||||
(MergeFailureFinal { reason }, MergemasterAttempted) => Ok(MergeFailureFinal { reason }),
|
||||
|
||||
// ── Unblock: from Frozen/ReviewHold → resume_to ────────────────
|
||||
@@ -288,11 +309,21 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
|
||||
// ── Unblock: Blocked → Coding ─────────────────────────────────
|
||||
(Blocked { .. }, Unblock) => Ok(Coding),
|
||||
|
||||
// ── Unblock MergeFailure → Coding (retry) ────────────────────────
|
||||
// `unblock_story` on a failed merge re-enters the coding stage so a
|
||||
// coder agent can rework the branch, rather than routing back through
|
||||
// the backlog (which would require an extra DepsMet promotion step).
|
||||
(MergeFailure { .. }, Unblock) => Ok(Coding),
|
||||
// ── Unblock MergeFailure → Merge (re-attempt) ────────────────────
|
||||
// `unblock_story` on a failed merge re-queues it for merge, restoring
|
||||
// the exact `Merge { feature_branch, commits_ahead }` that was in place
|
||||
// before the failure so the mergemaster can retry immediately.
|
||||
(
|
||||
MergeFailure {
|
||||
feature_branch,
|
||||
commits_ahead,
|
||||
..
|
||||
},
|
||||
Unblock,
|
||||
) => Ok(Merge {
|
||||
feature_branch,
|
||||
commits_ahead,
|
||||
}),
|
||||
|
||||
// ── Demote MergeFailure → Backlog (manual parking) ───────────────
|
||||
// Lets operators park a failed-merge story in the backlog without an
|
||||
|
||||
@@ -108,9 +108,15 @@ pub enum Stage {
|
||||
|
||||
/// Merge pipeline failed (conflicts or gate failures). Story is held here
|
||||
/// awaiting human intervention or retry. Unlike `Archived(MergeFailed)`,
|
||||
/// this is a recoverable intermediate state — `Unblock` returns to `Coding`
|
||||
/// (immediate agent retry) and `Demote` returns to `Backlog` (manual park).
|
||||
MergeFailure { reason: String },
|
||||
/// this is a recoverable intermediate state — `Unblock` returns to `Merge`
|
||||
/// (re-queues the merge) and `Demote` returns to `Backlog` (manual park).
|
||||
MergeFailure {
|
||||
reason: String,
|
||||
/// Branch and commit count preserved from the preceding `Merge` state
|
||||
/// so `Unblock` can reconstruct the exact `Merge` variant.
|
||||
feature_branch: BranchName,
|
||||
commits_ahead: NonZeroU32,
|
||||
},
|
||||
|
||||
/// Merge pipeline failed AND mergemaster has already been auto-spawned to
|
||||
/// recover; the agent gave up. The story stays here awaiting human
|
||||
@@ -234,6 +240,8 @@ impl Stage {
|
||||
}),
|
||||
"merge_failure" => Some(Stage::MergeFailure {
|
||||
reason: String::new(),
|
||||
feature_branch: BranchName(String::new()),
|
||||
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
|
||||
}),
|
||||
"merge_failure_final" => Some(Stage::MergeFailureFinal {
|
||||
reason: String::new(),
|
||||
|
||||
Reference in New Issue
Block a user