huskies: merge 1009

This commit is contained in:
dave
2026-05-13 22:50:13 +00:00
parent a5cd3a2152
commit 4e007bb770
56 changed files with 453 additions and 384 deletions
+2 -1
View File
@@ -123,7 +123,7 @@ mod tests {
bus.fire(TransitionFired {
story_id: StoryId("test".into()),
before: Stage::Backlog,
after: Stage::Coding,
after: Stage::Coding { claim: None },
event: PipelineEvent::DepsMet,
at: Utc::now(),
});
@@ -142,6 +142,7 @@ mod tests {
let merge = Stage::Merge {
feature_branch: BranchName("feature/story-1".into()),
commits_ahead: NonZeroU32::new(3).unwrap(),
claim: None,
};
// 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
+2 -2
View File
@@ -40,8 +40,8 @@ mod tests;
#[allow(unused_imports)]
pub use types::{
AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind, NodePubkey,
PipelineItem, Stage, StoryId, TransitionError, stage_dir_name, stage_label,
AgentClaim, AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind,
NodePubkey, PipelineItem, Stage, StoryId, TransitionError, stage_dir_name, stage_label,
};
#[allow(unused_imports)]
+5 -8
View File
@@ -122,7 +122,6 @@ mod tests {
None,
None,
None,
None,
)
}
@@ -145,7 +144,6 @@ mod tests {
None,
None,
None,
None,
);
let item = PipelineItem::try_from(&view).unwrap();
assert_eq!(item.story_id, StoryId("42_story_test".to_string()));
@@ -159,7 +157,7 @@ mod tests {
fn project_current_item() {
let view = PipelineItemView::for_test(
"42_story_test",
Stage::Coding,
Stage::Coding { claim: None },
"Test",
Some(crate::config::AgentName::Coder1),
2u32,
@@ -167,10 +165,9 @@ mod tests {
None,
None,
None,
None,
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Coding));
assert!(matches!(item.stage, Stage::Coding { .. }));
assert_eq!(item.retry_count, 2);
}
@@ -181,6 +178,7 @@ mod tests {
Stage::Merge {
feature_branch: fb("feature/story-42_story_test"),
commits_ahead: nz(1),
claim: None,
},
Some("Test"),
);
@@ -189,6 +187,7 @@ mod tests {
if let Stage::Merge {
feature_branch,
commits_ahead,
..
} = &item.stage
{
assert_eq!(feature_branch.0, "feature/story-42_story_test");
@@ -226,7 +225,6 @@ mod tests {
None,
None,
None,
None,
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(
@@ -253,7 +251,6 @@ mod tests {
None,
None,
None,
None,
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(
@@ -270,7 +267,7 @@ mod tests {
let view = make_view(
"42_story_test",
Stage::Frozen {
resume_to: Box::new(Stage::Coding),
resume_to: Box::new(Stage::Coding { claim: None }),
},
Some("Frozen Story"),
);
+26 -20
View File
@@ -19,7 +19,7 @@ fn sid(s: &str) -> StoryId {
fn happy_path_backlog_through_archived() {
let s = Stage::Backlog;
let s = transition(s, PipelineEvent::DepsMet).unwrap();
assert!(matches!(s, Stage::Coding));
assert!(matches!(s, Stage::Coding { .. }));
let s = transition(
s,
@@ -52,7 +52,7 @@ fn happy_path_backlog_through_archived() {
#[test]
fn happy_path_with_qa() {
let s = Stage::Coding;
let s = Stage::Coding { claim: None };
let s = transition(s, PipelineEvent::GatesStarted).unwrap();
assert!(matches!(s, Stage::Qa));
@@ -69,7 +69,7 @@ fn happy_path_with_qa() {
#[test]
fn qa_retry_loop() {
let s = Stage::Coding;
let s = Stage::Coding { claim: None };
let s = transition(s, PipelineEvent::GatesStarted).unwrap();
assert!(matches!(s, Stage::Qa));
@@ -80,7 +80,7 @@ fn qa_retry_loop() {
},
)
.unwrap();
assert!(matches!(s, Stage::Coding));
assert!(matches!(s, Stage::Coding { .. }));
}
// ── Bug 519: Merge with zero commits is unrepresentable ─────────────
@@ -154,7 +154,7 @@ fn cannot_start_gates_from_backlog() {
#[test]
fn cannot_accept_from_coding() {
let result = transition(Stage::Coding, PipelineEvent::Accepted);
let result = transition(Stage::Coding { claim: None }, PipelineEvent::Accepted);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
@@ -165,7 +165,7 @@ fn cannot_accept_from_coding() {
#[test]
fn block_from_any_active_stage() {
for s in [Stage::Backlog, Stage::Coding, Stage::Qa] {
for s in [Stage::Backlog, Stage::Coding { claim: None }, Stage::Qa] {
let result = transition(
s.clone(),
PipelineEvent::Block {
@@ -178,6 +178,7 @@ fn block_from_any_active_stage() {
let m = Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
claim: None,
};
let result = transition(
m,
@@ -194,7 +195,7 @@ fn unblock_returns_to_coding() {
reason: "test".into(),
};
let result = transition(s, PipelineEvent::Unblock).unwrap();
assert!(matches!(result, Stage::Coding));
assert!(matches!(result, Stage::Coding { .. }));
}
#[test]
@@ -251,7 +252,7 @@ fn legacy_unblock_archived_blocked_returns_to_backlog() {
fn abandon_from_any_active_or_done() {
for s in [
Stage::Backlog,
Stage::Coding,
Stage::Coding { claim: None },
Stage::Qa,
Stage::Done {
merged_at: chrono::Utc::now(),
@@ -267,7 +268,7 @@ fn abandon_from_any_active_or_done() {
fn supersede_from_any_active_or_done() {
for s in [
Stage::Backlog,
Stage::Coding,
Stage::Coding { claim: None },
Stage::Qa,
Stage::Done {
merged_at: chrono::Utc::now(),
@@ -291,7 +292,7 @@ fn review_hold_from_active_stages() {
// Story 945: `ReviewHold` transitions to `Stage::ReviewHold { resume_to }`
// with the resume_to set to the originating stage, replacing the legacy
// boolean flag.
for s in [Stage::Backlog, Stage::Coding, Stage::Qa] {
for s in [Stage::Backlog, Stage::Coding { claim: None }, Stage::Qa] {
let result = transition(
s.clone(),
PipelineEvent::ReviewHold {
@@ -316,6 +317,7 @@ fn merge_failed_final() {
let s = Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
claim: None,
};
let result = transition(
s,
@@ -336,7 +338,7 @@ fn merge_failed_final() {
#[test]
fn merge_failed_only_from_merge() {
let result = transition(
Stage::Coding,
Stage::Coding { claim: None },
PipelineEvent::MergeFailedFinal {
reason: "conflicts".into(),
},
@@ -413,6 +415,7 @@ fn bug_502_agent_not_in_stage() {
let merge = Stage::Merge {
feature_branch: BranchName("feature/story-1".into()),
commits_ahead: NonZeroU32::new(3).unwrap(),
claim: None,
};
// 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
@@ -480,7 +483,7 @@ fn cannot_deps_met_from_upcoming() {
#[test]
fn reject_from_active_stages() {
for s in [Stage::Backlog, Stage::Coding, Stage::Qa] {
for s in [Stage::Backlog, Stage::Coding { claim: None }, Stage::Qa] {
let result = transition(
s.clone(),
PipelineEvent::Reject {
@@ -493,6 +496,7 @@ fn reject_from_active_stages() {
let m = Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
claim: None,
};
let result = transition(
m,
@@ -561,7 +565,7 @@ fn freeze_transitions_to_frozen_variant_with_resume_to() {
);
let item = read_typed(story_id).unwrap().unwrap();
assert!(matches!(item.stage, Stage::Coding));
assert!(matches!(item.stage, Stage::Coding { .. }));
assert!(!matches!(item.stage, Stage::Frozen { .. }));
super::apply::transition_to_frozen(story_id).expect("freeze should succeed");
@@ -569,7 +573,7 @@ fn freeze_transitions_to_frozen_variant_with_resume_to() {
let item = read_typed(story_id).unwrap().unwrap();
match &item.stage {
Stage::Frozen { resume_to } => assert!(
matches!(**resume_to, Stage::Coding),
matches!(**resume_to, Stage::Coding { .. }),
"resume_to should preserve the previous stage; got {resume_to:?}"
),
other => panic!("stage should be Stage::Frozen after freeze; got {other:?}"),
@@ -583,7 +587,7 @@ fn freeze_transitions_to_frozen_variant_with_resume_to() {
let item = read_typed(story_id).unwrap().unwrap();
assert!(
matches!(item.stage, Stage::Coding),
matches!(item.stage, Stage::Coding { .. }),
"stage should return to Coding after unfreeze: {:?}",
item.stage
);
@@ -884,10 +888,11 @@ fn merge_aborted_returns_to_coding() {
let s = Stage::Merge {
feature_branch: fb("feature/story-73"),
commits_ahead: nz(2),
claim: None,
};
let result = transition(s, PipelineEvent::MergeAborted).unwrap();
assert!(
matches!(result, Stage::Coding),
matches!(result, Stage::Coding { .. }),
"Merge + MergeAborted should return to Coding, got: {result:?}"
);
}
@@ -915,7 +920,7 @@ fn merge_aborted_moves_to_coding_via_crdt() {
fired.before
);
assert!(
matches!(fired.after, Stage::Coding),
matches!(fired.after, Stage::Coding { .. }),
"fired.after should be Coding: {:?}",
fired.after
);
@@ -958,7 +963,7 @@ fn move_story_merge_to_current_succeeds() {
.expect("CRDT read should succeed")
.expect("item should exist");
assert!(
matches!(item.stage, Stage::Coding),
matches!(item.stage, Stage::Coding { .. }),
"story should be in Coding after move_story_to_stage(merge → current): {:?}",
item.stage
);
@@ -974,7 +979,7 @@ fn hotfix_requested_from_done_lands_in_coding() {
};
let result = transition(done, PipelineEvent::HotfixRequested).unwrap();
assert!(
matches!(result, Stage::Coding),
matches!(result, Stage::Coding { .. }),
"Done + HotfixRequested must land in Coding; got: {:?}",
result
);
@@ -984,11 +989,12 @@ fn hotfix_requested_from_done_lands_in_coding() {
fn hotfix_requested_rejected_from_non_done_stages() {
for stage in [
Stage::Backlog,
Stage::Coding,
Stage::Coding { claim: None },
Stage::Qa,
Stage::Merge {
feature_branch: fb("feature/story-1"),
commits_ahead: nz(1),
claim: None,
},
] {
let result = transition(stage.clone(), PipelineEvent::HotfixRequested);
+35 -27
View File
@@ -149,10 +149,10 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
(Upcoming, Triage) => Ok(Backlog),
// ── Forward path ────────────────────────────────────────────────
(Backlog, DepsMet) => Ok(Coding),
(Coding, GatesStarted) => Ok(Qa),
(Backlog, DepsMet) => Ok(Coding { claim: None }),
(Coding { .. }, GatesStarted) => Ok(Qa),
(
Coding,
Coding { .. },
QaSkipped {
feature_branch,
commits_ahead,
@@ -160,6 +160,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
) => Ok(Merge {
feature_branch,
commits_ahead,
claim: None,
}),
(
Qa,
@@ -170,8 +171,9 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
) => Ok(Merge {
feature_branch,
commits_ahead,
claim: None,
}),
(Qa, GatesFailed { .. }) => Ok(Coding),
(Qa, GatesFailed { .. }) => Ok(Coding { claim: None }),
(Merge { .. }, MergeSucceeded { merge_commit }) => Ok(Done {
merged_at: now,
merge_commit,
@@ -193,7 +195,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
// ── Block: any active → Blocked ──────────────────────────────
(Backlog, Block { reason })
| (Coding, Block { reason })
| (Coding { .. }, Block { reason })
| (Qa, Block { reason })
| (Merge { .. }, Block { reason }) => Ok(Blocked { reason }),
@@ -201,18 +203,20 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
// story to `Stage::ReviewHold { resume_to, reason }`, preserving the
// current stage as the resume target so a reviewer can clear the
// hold and continue.
(s @ (Backlog | Coding | Qa | Merge { .. }), PipelineEvent::ReviewHold { reason }) => {
Ok(Stage::ReviewHold {
resume_to: Box::new(s),
reason,
})
}
(
s @ (Backlog | Coding { .. } | Qa | Merge { .. }),
PipelineEvent::ReviewHold { reason },
) => Ok(Stage::ReviewHold {
resume_to: Box::new(s),
reason,
}),
// ── MergeFailed: Merge → MergeFailure (recoverable intermediate) ──
(
Merge {
feature_branch,
commits_ahead,
..
},
MergeFailed { kind },
) => Ok(MergeFailure {
@@ -246,14 +250,14 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
// ── Abandon / supersede from any active or done stage ───────────
(Upcoming, Abandon)
| (Backlog, Abandon)
| (Coding, Abandon)
| (Coding { .. }, Abandon)
| (Qa, Abandon)
| (Merge { .. }, Abandon)
| (Done { .. }, Abandon) => Ok(Abandoned { ts: now }),
(Upcoming, Supersede { by })
| (Backlog, Supersede { by })
| (Coding, Supersede { by })
| (Coding { .. }, Supersede { by })
| (Qa, Supersede { by })
| (Merge { .. }, Supersede { by })
| (Done { .. }, Supersede { by }) => Ok(Superseded {
@@ -263,7 +267,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
// ── Reject from any active stage or QA ──────────────────────────
(Backlog, Reject { reason })
| (Coding, Reject { reason })
| (Coding { .. }, Reject { reason })
| (Qa, Reject { reason })
| (Merge { .. }, Reject { reason }) => Ok(Rejected { ts: now, reason }),
@@ -272,21 +276,24 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
// the backlog while waiting on dependent fixes, without losing it to
// Archived. Unlike `Unblock` (Blocked → Coding), this does not
// re-enter the active flow.
(Coding, Demote) | (Qa, Demote) | (Merge { .. }, Demote) | (Blocked { .. }, Demote) => {
Ok(Backlog)
}
(Coding { .. }, Demote)
| (Qa, Demote)
| (Merge { .. }, Demote)
| (Blocked { .. }, Demote) => Ok(Backlog),
// ── Close: direct completion from any active stage ─────────────
(Backlog, Close) | (Coding, Close) | (Qa, Close) | (Merge { .. }, Close) => Ok(Done {
merged_at: now,
merge_commit: GitSha("closed".to_string()),
}),
(Backlog, Close) | (Coding { .. }, Close) | (Qa, Close) | (Merge { .. }, Close) => {
Ok(Done {
merged_at: now,
merge_commit: GitSha("closed".to_string()),
})
}
// ── Freeze: any non-terminal stage → Frozen { resume_to } ──────
(
s @ (Upcoming
| Backlog
| Coding
| Coding { .. }
| Qa
| Merge { .. }
| Blocked { .. }
@@ -305,7 +312,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
(Stage::ReviewHold { resume_to, .. }, ReviewHoldCleared) => Ok(*resume_to),
// ── FixupRequested: MergeFailure → Coding (coder fixup) ────────
(MergeFailure { .. }, FixupRequested) => Ok(Coding),
(MergeFailure { .. }, FixupRequested) => Ok(Coding { claim: None }),
// ── FixupRequested: MergeFailureFinal → Coding (operator override)
//
@@ -314,19 +321,19 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
// the gate failure is fixable and send the story back for another
// coder attempt. The budget counter is a mergemaster bookkeeping
// detail, not a hard ceiling.
(MergeFailureFinal { .. }, FixupRequested) => Ok(Coding),
(MergeFailureFinal { .. }, FixupRequested) => Ok(Coding { claim: None }),
// ── ReQueuedForQa: MergeFailure → Qa (re-review) ────────────────
(MergeFailure { .. }, ReQueuedForQa) => Ok(Qa),
// ── MergeAborted: Merge → Coding (abort in-flight merge) ─────────
(Merge { .. }, MergeAborted) => Ok(Coding),
(Merge { .. }, MergeAborted) => Ok(Coding { claim: None }),
// ── 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),
(Done { .. }, HotfixRequested) => Ok(Coding { claim: None }),
// ── MergemasterAttempted: MergeFailure → MergeFailureFinal ─────
(MergeFailure { kind, .. }, MergemasterAttempted) => Ok(MergeFailureFinal { kind }),
@@ -337,7 +344,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
(Stage::ReviewHold { resume_to, .. }, Unblock) => Ok(*resume_to),
// ── Unblock: Blocked → Coding ─────────────────────────────────
(Blocked { .. }, Unblock) => Ok(Coding),
(Blocked { .. }, Unblock) => Ok(Coding { claim: None }),
// ── Unblock MergeFailure → Merge (re-attempt) ────────────────────
// `unblock_story` on a failed merge re-queues it for merge, restoring
@@ -353,6 +360,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
) => Ok(Merge {
feature_branch,
commits_ahead,
claim: None,
}),
// ── Demote MergeFailure → Backlog (manual parking) ───────────────
+33 -7
View File
@@ -109,6 +109,22 @@ impl MergeFailureKind {
}
}
// ── Agent claim payload ────────────────────────────────────────────────────
/// Active claim on a pipeline item by a specific agent.
///
/// Embedded directly in [`Stage::Coding`] and [`Stage::Merge`] rather than
/// stored in separate CRDT registers. Readers access the claim via
/// `item.stage()` rather than through a separate `item.claim()` accessor
/// (story 1009).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentClaim {
/// The agent (e.g. `"coder-1"`) that has claimed this work item.
pub agent: AgentName,
/// When the claim was written.
pub claimed_at: DateTime<Utc>,
}
// ── Synced pipeline stage (lives in CRDT, converges across nodes) ───────────
/// The pipeline stage for a work item.
@@ -146,17 +162,26 @@ pub enum Stage {
Backlog,
/// Story is being actively coded somewhere in the mesh.
Coding,
///
/// Carries an optional [`AgentClaim`] identifying which agent is currently
/// working on this item. `None` means the item is in the coding stage but
/// no agent has claimed it yet (e.g. just transitioned from Backlog and
/// waiting for an agent to pick it up).
Coding { claim: Option<AgentClaim> },
/// Coder has run; gates are running.
Qa,
/// Gates passed; ready to merge.
///
/// `commits_ahead: NonZeroU32` makes "Merge with nothing to merge"
/// structurally impossible (eliminates bug 519).
/// structurally impossible (eliminates bug 519). The optional
/// [`AgentClaim`] carries the mergemaster agent that owns this merge.
Merge {
feature_branch: BranchName,
commits_ahead: NonZeroU32,
/// Agent currently running the merge, or `None` when unclaimed.
claim: Option<AgentClaim>,
},
/// Mergemaster squashed to master. Always carries merge metadata.
@@ -274,7 +299,7 @@ impl Stage {
match s {
"upcoming" => Some(Stage::Upcoming),
"backlog" => Some(Stage::Backlog),
"coding" => Some(Stage::Coding),
"coding" => Some(Stage::Coding { claim: None }),
"blocked" => Some(Stage::Blocked {
reason: String::new(),
}),
@@ -282,6 +307,7 @@ impl Stage {
"merge" => Some(Stage::Merge {
feature_branch: BranchName(String::new()),
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
claim: None,
}),
"merge_failure" => Some(Stage::MergeFailure {
kind: MergeFailureKind::Other(String::new()),
@@ -292,10 +318,10 @@ impl Stage {
kind: MergeFailureKind::Other(String::new()),
}),
"frozen" => Some(Stage::Frozen {
resume_to: Box::new(Stage::Coding),
resume_to: Box::new(Stage::Coding { claim: None }),
}),
"review_hold" => Some(Stage::ReviewHold {
resume_to: Box::new(Stage::Coding),
resume_to: Box::new(Stage::Coding { claim: None }),
reason: String::new(),
}),
"done" => Some(Stage::Done {
@@ -391,7 +417,7 @@ pub fn stage_label(s: &Stage) -> &'static str {
match s {
Stage::Upcoming => "Upcoming",
Stage::Backlog => "Backlog",
Stage::Coding => "Coding",
Stage::Coding { .. } => "Coding",
Stage::Qa => "Qa",
Stage::Merge { .. } => "Merge",
Stage::MergeFailure { .. } => "MergeFailure",
@@ -416,7 +442,7 @@ pub fn stage_dir_name(s: &Stage) -> &'static str {
match s {
Stage::Upcoming => "upcoming",
Stage::Backlog => "backlog",
Stage::Coding => "coding",
Stage::Coding { .. } => "coding",
Stage::Blocked { .. } => "blocked",
Stage::Qa => "qa",
Stage::Merge { .. } => "merge",