huskies: merge 982

This commit is contained in:
dave
2026-05-13 15:30:03 +00:00
parent e6d051d016
commit 91fbad568a
15 changed files with 357 additions and 117 deletions
+2 -2
View File
@@ -40,8 +40,8 @@ mod tests;
#[allow(unused_imports)]
pub use types::{
AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, NodePubkey, PipelineItem, Stage,
StoryId, TransitionError, stage_dir_name, stage_label,
AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind, NodePubkey,
PipelineItem, Stage, StoryId, TransitionError, stage_dir_name, stage_label,
};
#[allow(unused_imports)]
+14 -10
View File
@@ -648,16 +648,20 @@ fn merge_failure_transition_emits_event_with_full_reason() {
let fired = super::apply::apply_transition(
story_id,
PipelineEvent::MergeFailed {
reason: reason.to_string(),
kind: MergeFailureKind::Other(reason.to_string()),
},
None,
)
.expect("MergeFailed transition should succeed");
// The emitted event payload carries the full reason string.
// The emitted event payload carries the full reason via display_reason().
match &fired.event {
PipelineEvent::MergeFailed { reason: r } => {
assert_eq!(r, reason, "emitted event should carry the full reason");
PipelineEvent::MergeFailed { kind } => {
assert_eq!(
kind.display_reason(),
reason,
"emitted event should carry the full reason"
);
}
other => panic!("expected MergeFailed event, got: {other:?}"),
}
@@ -686,14 +690,14 @@ fn merge_failure_transition_emits_event_with_full_reason() {
#[test]
fn merge_failure_plus_merge_failed_is_self_loop() {
let s = Stage::MergeFailure {
reason: "initial failure".into(),
kind: MergeFailureKind::Other("initial failure".into()),
feature_branch: fb("feature/story-1"),
commits_ahead: nz(1),
};
let result = transition(
s,
PipelineEvent::MergeFailed {
reason: "second failure".into(),
kind: MergeFailureKind::Other("second failure".into()),
},
);
assert!(
@@ -722,7 +726,7 @@ fn repeated_merge_failure_apply_transition_no_error_no_duplicate_notification()
let fired = super::apply::apply_transition(
story_id,
PipelineEvent::MergeFailed {
reason: "duplicate failure".into(),
kind: MergeFailureKind::Other("duplicate failure".into()),
},
None,
)
@@ -766,7 +770,7 @@ fn repeated_merge_failure_apply_transition_no_error_no_duplicate_notification()
#[test]
fn merge_failure_unblock_returns_to_merge() {
let s = Stage::MergeFailure {
reason: "conflicts in server/src/main.rs".into(),
kind: MergeFailureKind::ConflictDetected(Some("conflicts in server/src/main.rs".into())),
feature_branch: fb("feature/story-42"),
commits_ahead: nz(3),
};
@@ -781,7 +785,7 @@ fn merge_failure_unblock_returns_to_merge() {
#[test]
fn merge_failure_demote_returns_to_backlog() {
let s = Stage::MergeFailure {
reason: "conflicts".into(),
kind: MergeFailureKind::Other("conflicts".into()),
feature_branch: fb("feature/story-1"),
commits_ahead: nz(1),
};
@@ -799,7 +803,7 @@ fn merge_failure_demote_returns_to_backlog() {
#[test]
fn merge_failure_accept_pure_transition() {
let s = Stage::MergeFailure {
reason: "conflicts unresolvable".into(),
kind: MergeFailureKind::ConflictDetected(Some("conflicts unresolvable".into())),
feature_branch: fb("feature/story-1"),
commits_ahead: nz(1),
};
+9 -9
View File
@@ -4,8 +4,8 @@ use chrono::Utc;
use serde::{Deserialize, Serialize};
use super::{
AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, Stage, StoryId, TransitionError,
stage_label,
AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind, Stage, StoryId,
TransitionError, stage_label,
};
// ── Pipeline events ─────────────────────────────────────────────────────────
@@ -35,7 +35,7 @@ pub enum PipelineEvent {
MergeSucceeded { merge_commit: GitSha },
/// Merge pipeline failed (conflicts or gate failures); story moves to
/// `Stage::MergeFailure` awaiting human intervention or retry.
MergeFailed { reason: String },
MergeFailed { kind: MergeFailureKind },
/// Mergemaster gave up after retry budget.
MergeFailedFinal { reason: String },
/// Story accepted (Done → Archived).
@@ -214,9 +214,9 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
feature_branch,
commits_ahead,
},
MergeFailed { reason },
MergeFailed { kind },
) => Ok(MergeFailure {
reason,
kind,
feature_branch,
commits_ahead,
}),
@@ -231,9 +231,9 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
commits_ahead,
..
},
MergeFailed { reason },
MergeFailed { kind },
) => Ok(MergeFailure {
reason,
kind,
feature_branch,
commits_ahead,
}),
@@ -326,8 +326,8 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
(Done { .. }, HotfixRequested) => Ok(Coding),
// ── MergemasterAttempted: MergeFailure → MergeFailureFinal ─────
(MergeFailure { reason, .. }, MergemasterAttempted) => Ok(MergeFailureFinal { reason }),
(MergeFailureFinal { reason }, MergemasterAttempted) => Ok(MergeFailureFinal { reason }),
(MergeFailure { kind, .. }, MergemasterAttempted) => Ok(MergeFailureFinal { kind }),
(MergeFailureFinal { kind }, MergemasterAttempted) => Ok(MergeFailureFinal { kind }),
// ── Unblock: from Frozen/ReviewHold → resume_to ────────────────
(Frozen { resume_to }, Unblock) => Ok(*resume_to),
+79 -4
View File
@@ -39,6 +39,76 @@ impl fmt::Display for AgentName {
}
}
// ── Merge failure kind ──────────────────────────────────────────────────────
/// Typed reason for a merge pipeline failure.
///
/// Replaces the freeform `reason: String` that was previously stored on
/// `Stage::MergeFailure` (story 982). Consumers match on the variant instead
/// of scanning substrings of an opaque string.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MergeFailureKind {
/// Git content conflicts detected during the squash-rebase. The payload
/// is the raw conflict output (e.g. the git CONFLICT lines), if available.
ConflictDetected(Option<String>),
/// Quality gates (fmt, clippy, tests) failed after the squash merge.
/// The payload is the gate runner output.
GatesFailed(String),
/// Feature branch has no code changes ahead of master (empty diff).
EmptyDiff,
/// Squash merge produced no commits (e.g. empty-commit history).
NoCommits,
/// Unclassified merge failure; the payload carries the original message.
Other(String),
}
impl MergeFailureKind {
/// Human-readable description for display tools and `MergeJob.error`.
pub fn display_reason(&self) -> String {
match self {
MergeFailureKind::ConflictDetected(details) => format!(
"Merge conflict: {}",
details.as_deref().unwrap_or("conflicts detected")
),
MergeFailureKind::GatesFailed(output) => format!("Quality gates failed: {output}"),
MergeFailureKind::EmptyDiff => "Feature branch has no code changes".to_string(),
MergeFailureKind::NoCommits => "No commits to merge".to_string(),
MergeFailureKind::Other(reason) => reason.clone(),
}
}
/// String to persist in `ContentKey::GateOutput` so the CRDT projection
/// layer can reconstruct the kind after a server restart.
pub fn to_gate_output(&self) -> String {
match self {
// Always prefix with "Merge conflict:" so `infer_from_gate_output`
// can identify ConflictDetected on reload.
MergeFailureKind::ConflictDetected(details) => format!(
"Merge conflict: {}",
details.as_deref().unwrap_or("conflicts detected")
),
// Raw gate output is the natural identifier for a GatesFailed kind.
MergeFailureKind::GatesFailed(output) => output.clone(),
MergeFailureKind::EmptyDiff => "empty diff".to_string(),
MergeFailureKind::NoCommits => "no commits to merge".to_string(),
MergeFailureKind::Other(reason) => reason.clone(),
}
}
/// Infer a kind from a `ContentKey::GateOutput` string persisted by a
/// previous call to [`Self::to_gate_output`] or by legacy code that wrote
/// conflict markers directly. Used by the CRDT projection layer.
pub fn infer_from_gate_output(gate_output: &str) -> Self {
if gate_output.contains("CONFLICT (content):") || gate_output.contains("Merge conflict") {
MergeFailureKind::ConflictDetected(Some(gate_output.to_string()))
} else if gate_output.is_empty() {
MergeFailureKind::Other(String::new())
} else {
MergeFailureKind::GatesFailed(gate_output.to_string())
}
}
}
// ── Synced pipeline stage (lives in CRDT, converges across nodes) ───────────
/// The pipeline stage for a work item.
@@ -111,7 +181,9 @@ pub enum Stage {
/// this is a recoverable intermediate state — `Unblock` returns to `Merge`
/// (re-queues the merge) and `Demote` returns to `Backlog` (manual park).
MergeFailure {
reason: String,
/// Typed failure reason — callers match on the variant instead of
/// substring-scanning a freeform string (story 982).
kind: MergeFailureKind,
/// Branch and commit count preserved from the preceding `Merge` state
/// so `Unblock` can reconstruct the exact `Merge` variant.
feature_branch: BranchName,
@@ -122,7 +194,10 @@ pub enum Stage {
/// recover; the agent gave up. The story stays here awaiting human
/// intervention — the auto-assigner will NOT spawn mergemaster again.
/// Replaces the legacy `mergemaster_attempted: true` boolean flag.
MergeFailureFinal { reason: String },
MergeFailureFinal {
/// Typed failure reason carried through from the preceding `MergeFailure`.
kind: MergeFailureKind,
},
/// Story is frozen — kept at this stage as a snapshot of its previous
/// stage. Replaces the legacy `frozen: true` boolean flag: there is no
@@ -229,12 +304,12 @@ impl Stage {
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
}),
"merge_failure" => Some(Stage::MergeFailure {
reason: String::new(),
kind: MergeFailureKind::Other(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(),
kind: MergeFailureKind::Other(String::new()),
}),
"frozen" => Some(Stage::Frozen {
resume_to: Box::new(Stage::Coding),