huskies: merge 997

This commit is contained in:
dave
2026-05-14 11:01:06 +00:00
parent 0572af2193
commit c7a7cb4281
40 changed files with 256 additions and 253 deletions
+2
View File
@@ -152,6 +152,7 @@ mod tests {
after: Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
event: PipelineEvent::DepsMet,
at: Utc::now(),
@@ -172,6 +173,7 @@ mod tests {
feature_branch: BranchName("feature/story-1".into()),
commits_ahead: NonZeroU32::new(3).unwrap(),
claim: None,
retries: 0,
};
// Stage::Merge has exactly two fields: feature_branch and commits_ahead.
// There is no way to attach an agent name to it. The type system
+7 -10
View File
@@ -52,14 +52,11 @@ impl TryFrom<&PipelineItemView> for PipelineItem {
.map(|d| StoryId(d.to_string()))
.collect();
let retry_count = view.retry_count();
Ok(PipelineItem {
story_id,
name,
stage: view.stage().clone(),
depends_on,
retry_count,
})
}
}
@@ -117,7 +114,6 @@ mod tests {
stage,
name.unwrap_or("(unnamed)"),
None,
0u32,
vec![],
None,
None,
@@ -139,7 +135,6 @@ mod tests {
Stage::Backlog,
"Test Story",
None,
0u32,
vec![10, 20],
None,
None,
@@ -150,7 +145,6 @@ mod tests {
assert_eq!(item.name, "Test Story");
assert!(matches!(item.stage, Stage::Backlog));
assert_eq!(item.depends_on.len(), 2);
assert_eq!(item.retry_count, 0);
}
#[test]
@@ -160,10 +154,10 @@ mod tests {
Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 2,
},
"Test",
Some(crate::config::AgentName::Coder1),
2u32,
vec![],
None,
None,
@@ -171,7 +165,7 @@ mod tests {
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Coding { .. }));
assert_eq!(item.retry_count, 2);
assert_eq!(item.retry_count(), 2);
}
#[test]
@@ -182,6 +176,7 @@ mod tests {
feature_branch: fb("feature/story-42_story_test"),
commits_ahead: nz(1),
claim: None,
retries: 0,
},
Some("Test"),
);
@@ -223,7 +218,6 @@ mod tests {
},
"Test",
None,
0u32,
vec![],
None,
None,
@@ -249,7 +243,6 @@ mod tests {
},
"Test",
None,
0u32,
vec![],
None,
None,
@@ -273,6 +266,7 @@ mod tests {
resume_to: Box::new(Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
}),
},
Some("Frozen Story"),
@@ -308,6 +302,7 @@ mod tests {
Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
Some("Test"),
);
@@ -328,6 +323,7 @@ mod tests {
Stage::Coding {
claim: None,
plan: PlanState::Drafted,
retries: 0,
},
Some("Test"),
);
@@ -348,6 +344,7 @@ mod tests {
Stage::Coding {
claim: None,
plan: PlanState::Confirmed,
retries: 0,
},
Some("Test"),
);
+25
View File
@@ -55,6 +55,7 @@ fn happy_path_with_qa() {
let s = Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
};
let s = transition(s, PipelineEvent::GatesStarted).unwrap();
assert!(matches!(s, Stage::Qa));
@@ -75,6 +76,7 @@ fn qa_retry_loop() {
let s = Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
};
let s = transition(s, PipelineEvent::GatesStarted).unwrap();
assert!(matches!(s, Stage::Qa));
@@ -164,6 +166,7 @@ fn cannot_accept_from_coding() {
Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
PipelineEvent::Accepted,
);
@@ -182,6 +185,7 @@ fn block_from_any_active_stage() {
Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
Stage::Qa,
] {
@@ -198,6 +202,7 @@ fn block_from_any_active_stage() {
feature_branch: fb("f"),
commits_ahead: nz(1),
claim: None,
retries: 0,
};
let result = transition(
m,
@@ -274,6 +279,7 @@ fn abandon_from_any_active_or_done() {
Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
Stage::Qa,
Stage::Done {
@@ -293,6 +299,7 @@ fn supersede_from_any_active_or_done() {
Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
Stage::Qa,
Stage::Done {
@@ -322,6 +329,7 @@ fn review_hold_from_active_stages() {
Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
Stage::Qa,
] {
@@ -350,6 +358,7 @@ fn merge_failed_final() {
feature_branch: fb("f"),
commits_ahead: nz(1),
claim: None,
retries: 0,
};
let result = transition(
s,
@@ -373,6 +382,7 @@ fn merge_failed_only_from_merge() {
Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
PipelineEvent::MergeFailedFinal {
reason: "conflicts".into(),
@@ -451,6 +461,7 @@ fn bug_502_agent_not_in_stage() {
feature_branch: BranchName("feature/story-1".into()),
commits_ahead: NonZeroU32::new(3).unwrap(),
claim: None,
retries: 0,
};
// Stage::Merge has exactly two fields: feature_branch and commits_ahead.
// There is no way to attach an agent name to it. The type system
@@ -523,6 +534,7 @@ fn reject_from_active_stages() {
Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
Stage::Qa,
] {
@@ -539,6 +551,7 @@ fn reject_from_active_stages() {
feature_branch: fb("f"),
commits_ahead: nz(1),
claim: None,
retries: 0,
};
let result = transition(
m,
@@ -931,6 +944,7 @@ fn merge_aborted_returns_to_coding() {
feature_branch: fb("feature/story-73"),
commits_ahead: nz(2),
claim: None,
retries: 0,
};
let result = transition(s, PipelineEvent::MergeAborted).unwrap();
assert!(
@@ -1034,12 +1048,14 @@ fn hotfix_requested_rejected_from_non_done_stages() {
Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
Stage::Qa,
Stage::Merge {
feature_branch: fb("feature/story-1"),
commits_ahead: nz(1),
claim: None,
retries: 0,
},
] {
let result = transition(stage.clone(), PipelineEvent::HotfixRequested);
@@ -1064,6 +1080,7 @@ fn audit_entry_backlog_to_coding_exact_format() {
after: Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
event: PipelineEvent::DepsMet,
at,
@@ -1083,6 +1100,7 @@ fn audit_entry_is_single_line_with_all_fields() {
feature_branch: fb("feature/story-42"),
commits_ahead: nz(3),
claim: None,
retries: 0,
},
event: PipelineEvent::GatesPassed {
feature_branch: fb("feature/story-42"),
@@ -1120,6 +1138,7 @@ fn audit_entry_merge_to_done() {
feature_branch: fb("f"),
commits_ahead: nz(1),
claim: None,
retries: 0,
},
after: Stage::Done {
merged_at: chrono::Utc::now(),
@@ -1167,6 +1186,7 @@ fn audit_entry_coding_to_blocked() {
before: Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
after: Stage::Blocked {
reason: "waiting".into(),
@@ -1192,6 +1212,7 @@ fn audit_entry_blocked_to_coding() {
after: Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
event: PipelineEvent::Unblock,
at: chrono::Utc::now(),
@@ -1210,6 +1231,7 @@ fn audit_entry_merge_to_merge_failure() {
feature_branch: fb("f"),
commits_ahead: nz(1),
claim: None,
retries: 0,
},
after: Stage::MergeFailure {
kind: MergeFailureKind::Other("conflicts".into()),
@@ -1234,11 +1256,13 @@ fn audit_entry_coding_to_frozen() {
before: Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
after: Stage::Frozen {
resume_to: Box::new(Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
}),
},
event: PipelineEvent::Freeze,
@@ -1257,6 +1281,7 @@ fn audit_entry_coding_to_abandoned() {
before: Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
after: Stage::Abandoned {
ts: chrono::Utc::now(),
+10
View File
@@ -152,6 +152,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
(Backlog, DepsMet) => Ok(Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
}),
(Coding { .. }, GatesStarted) => Ok(Qa),
(
@@ -164,6 +165,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
feature_branch,
commits_ahead,
claim: None,
retries: 0,
}),
(
Qa,
@@ -175,10 +177,12 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
feature_branch,
commits_ahead,
claim: None,
retries: 0,
}),
(Qa, GatesFailed { .. }) => Ok(Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
}),
(Merge { .. }, MergeSucceeded { merge_commit }) => Ok(Done {
merged_at: now,
@@ -323,6 +327,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
(MergeFailure { .. }, FixupRequested) => Ok(Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
}),
// ── FixupRequested: MergeFailureFinal → Coding (operator override)
@@ -335,6 +340,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
(MergeFailureFinal { .. }, FixupRequested) => Ok(Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
}),
// ── ReQueuedForQa: MergeFailure → Qa (re-review) ────────────────
@@ -344,6 +350,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
(Merge { .. }, MergeAborted) => Ok(Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
}),
// ── HotfixRequested: Done → Coding (post-merge hotfix) ───────────
@@ -353,6 +360,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
(Done { .. }, HotfixRequested) => Ok(Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
}),
// ── MergemasterAttempted: MergeFailure → MergeFailureFinal ─────
@@ -367,6 +375,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
(Blocked { .. }, Unblock) => Ok(Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
}),
// ── Unblock MergeFailure → Merge (re-attempt) ────────────────────
@@ -384,6 +393,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
feature_branch,
commits_ahead,
claim: None,
retries: 0,
}),
// ── Demote MergeFailure → Backlog (manual parking) ───────────────
+28 -1
View File
@@ -212,9 +212,14 @@ pub enum Stage {
///
/// `plan` tracks the lifecycle of the `PLAN.md` file in the worktree,
/// updated by the filesystem watcher on create/modify/remove events.
///
/// `retries` counts how many times the coder agent has been restarted for
/// this item. Replaces the separate `retry_count` CRDT register (story 997).
Coding {
claim: Option<AgentClaim>,
plan: PlanState,
/// Number of coder restarts for this item. Zero on the first attempt.
retries: u32,
},
/// Coder has run; gates are running.
@@ -225,11 +230,16 @@ pub enum Stage {
/// `commits_ahead: NonZeroU32` makes "Merge with nothing to merge"
/// structurally impossible (eliminates bug 519). The optional
/// [`AgentClaim`] carries the mergemaster agent that owns this merge.
///
/// `retries` counts how many times the mergemaster agent has been restarted
/// for this item. Replaces the separate `retry_count` CRDT register (story 997).
Merge {
feature_branch: BranchName,
commits_ahead: NonZeroU32,
/// Agent currently running the merge, or `None` when unclaimed.
claim: Option<AgentClaim>,
/// Number of mergemaster restarts for this item. Zero on the first attempt.
retries: u32,
},
/// Mergemaster squashed to master. Always carries merge metadata.
@@ -350,6 +360,7 @@ impl Stage {
"coding" => Some(Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
}),
"blocked" => Some(Stage::Blocked {
reason: String::new(),
@@ -359,6 +370,7 @@ impl Stage {
feature_branch: BranchName(String::new()),
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
claim: None,
retries: 0,
}),
"merge_failure" => Some(Stage::MergeFailure {
kind: MergeFailureKind::Other(String::new()),
@@ -372,12 +384,14 @@ impl Stage {
resume_to: Box::new(Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
}),
}),
"review_hold" => Some(Stage::ReviewHold {
resume_to: Box::new(Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
}),
reason: String::new(),
}),
@@ -438,13 +452,26 @@ pub enum ExecutionState {
// ── Pipeline item (the aggregate) ───────────────────────────────────────────
/// A fully typed pipeline item. Every field is validated by construction.
///
/// The retry count is no longer a top-level field — callers read it from the
/// Stage variant (`Stage::Coding { retries }` / `Stage::Merge { retries }`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PipelineItem {
pub story_id: StoryId,
pub name: String,
pub stage: Stage,
pub depends_on: Vec<StoryId>,
pub retry_count: u32,
}
impl PipelineItem {
/// Returns the retry count embedded in the stage payload.
pub fn retry_count(&self) -> u32 {
match &self.stage {
Stage::Coding { retries, .. } => *retries,
Stage::Merge { retries, .. } => *retries,
_ => 0,
}
}
}
// ── Transition errors ───────────────────────────────────────────────────────