huskies: merge 984

This commit is contained in:
dave
2026-05-13 16:43:19 +00:00
parent c3c9db3d8b
commit 580480094e
25 changed files with 501 additions and 97 deletions
+14
View File
@@ -69,6 +69,20 @@ pub fn apply_transition(
// Write the new stage to the CRDT (with optional content transform).
crate::db::move_item_stage(story_id, new_dir, content_transform);
// Write stage-specific metadata into the shared `resume_to` register.
// Story 984: Superseded and Rejected stages reuse `resume_to` to carry
// their metadata (superseded_by ID and rejection reason respectively),
// since these stages never have a resume target.
match &after {
super::Stage::Superseded { superseded_by, .. } => {
crate::crdt_state::set_resume_to_raw(story_id, &superseded_by.0);
}
super::Stage::Rejected { reason, .. } => {
crate::crdt_state::set_resume_to_raw(story_id, reason);
}
_ => {}
}
let fired = TransitionFired {
story_id: StoryId(story_id.to_string()),
before,
+8 -1
View File
@@ -71,7 +71,14 @@ impl TransitionSubscriber for AutoAssignSubscriber {
"auto-assign"
}
fn on_transition(&self, f: &TransitionFired) {
if matches!(f.after, Stage::Done { .. } | Stage::Archived { .. }) {
if matches!(
f.after,
Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. }
) {
crate::slog!(
"[pipeline/auto-assign] story {} reached {}; checking for promotable backlog items",
f.story_id,
+6 -42
View File
@@ -259,13 +259,7 @@ fn abandon_from_any_active_or_done() {
},
] {
let result = transition(s, PipelineEvent::Abandon);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Abandoned,
..
})
));
assert!(matches!(result, Ok(Stage::Abandoned { .. })));
}
}
@@ -286,13 +280,7 @@ fn supersede_from_any_active_or_done() {
by: sid("999_story_new"),
},
);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Superseded { .. },
..
})
));
assert!(matches!(result, Ok(Stage::Superseded { .. })));
}
}
@@ -464,13 +452,7 @@ fn cannot_triage_from_backlog() {
#[test]
fn abandon_from_upcoming() {
let result = transition(Stage::Upcoming, PipelineEvent::Abandon).unwrap();
assert!(matches!(
result,
Stage::Archived {
reason: ArchiveReason::Abandoned,
..
}
));
assert!(matches!(result, Stage::Abandoned { .. }));
}
#[test]
@@ -482,13 +464,7 @@ fn supersede_from_upcoming() {
},
)
.unwrap();
assert!(matches!(
result,
Stage::Archived {
reason: ArchiveReason::Superseded { .. },
..
}
));
assert!(matches!(result, Stage::Superseded { .. }));
}
#[test]
@@ -511,13 +487,7 @@ fn reject_from_active_stages() {
reason: "not needed".into(),
},
);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Rejected { .. },
..
})
));
assert!(matches!(result, Ok(Stage::Rejected { .. })));
}
let m = Stage::Merge {
@@ -530,13 +500,7 @@ fn reject_from_active_stages() {
reason: "not needed".into(),
},
);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Rejected { .. },
..
})
));
assert!(matches!(result, Ok(Stage::Rejected { .. })));
}
#[test]
+5 -11
View File
@@ -249,29 +249,23 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
| (Coding, Abandon)
| (Qa, Abandon)
| (Merge { .. }, Abandon)
| (Done { .. }, Abandon) => Ok(Archived {
archived_at: now,
reason: ArchiveReason::Abandoned,
}),
| (Done { .. }, Abandon) => Ok(Abandoned { ts: now }),
(Upcoming, Supersede { by })
| (Backlog, Supersede { by })
| (Coding, Supersede { by })
| (Qa, Supersede { by })
| (Merge { .. }, Supersede { by })
| (Done { .. }, Supersede { by }) => Ok(Archived {
archived_at: now,
reason: ArchiveReason::Superseded { by },
| (Done { .. }, Supersede { by }) => Ok(Superseded {
ts: now,
superseded_by: by,
}),
// ── Reject from any active stage or QA ──────────────────────────
(Backlog, Reject { reason })
| (Coding, Reject { reason })
| (Qa, Reject { reason })
| (Merge { .. }, Reject { reason }) => Ok(Archived {
archived_at: now,
reason: ArchiveReason::Rejected { reason },
}),
| (Merge { .. }, Reject { reason }) => Ok(Rejected { ts: now, reason }),
// ── Demote: send an active item back to backlog ────────────────
// `Blocked + Demote → Backlog` lets operators park a stuck story in
+59 -25
View File
@@ -119,24 +119,24 @@ impl MergeFailureKind {
/// - `retry_count` — also local
/// - `blocked` — now a first-class `Blocked { reason }` stage
///
/// ## Canonical state machine (story 857)
/// ## Canonical state machine (story 857 / 984)
///
/// The following named lifecycle states map to `Stage` variants:
///
/// | Lifecycle state | Stage variant |
/// |-----------------|-----------------------------------|
/// | upcoming | `Upcoming` |
/// | backlog | `Backlog` |
/// | current | `Coding` |
/// | qa_pending | `Qa` |
/// | merge_pending | `Merge { .. }` |
/// | merge_failure | `MergeFailure { .. }` |
/// | done | `Done { .. }` |
/// | blocked | `Blocked { .. }` |
/// | archived | `Archived { Completed }` |
/// | superseded | `Archived { Superseded { .. } }` |
/// | rejected | `Archived { Rejected { .. } }` |
/// | abandoned | `Archived { Abandoned }` |
/// | Lifecycle state | Stage variant |
/// |-----------------|------------------------|
/// | upcoming | `Upcoming` |
/// | backlog | `Backlog` |
/// | current | `Coding` |
/// | qa_pending | `Qa` |
/// | merge_pending | `Merge { .. }` |
/// | merge_failure | `MergeFailure { .. }` |
/// | done | `Done { .. }` |
/// | blocked | `Blocked { .. }` |
/// | archived | `Archived { .. }` |
/// | superseded | `Superseded { .. }` |
/// | rejected | `Rejected { .. }` |
/// | abandoned | `Abandoned { .. }` |
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Stage {
/// Story has been created but not yet triaged into the backlog.
@@ -215,26 +215,43 @@ pub enum Stage {
resume_to: Box<Stage>,
reason: String,
},
/// Story was abandoned by the user — no further work planned.
/// Carries the timestamp of the abandonment. Replaces the legacy
/// `Archived { reason: ArchiveReason::Abandoned }` (story 984).
Abandoned { ts: DateTime<Utc> },
/// Story was superseded by another work item.
/// Carries the timestamp and the ID of the replacing story. Replaces
/// the legacy `Archived { reason: ArchiveReason::Superseded { .. } }` (story 984).
Superseded {
ts: DateTime<Utc>,
superseded_by: StoryId,
},
/// Story was permanently rejected (e.g. by QA or a reviewer).
/// Carries the timestamp and the rejection reason. Replaces the legacy
/// `Archived { reason: ArchiveReason::Rejected { .. } }` (story 984).
Rejected { ts: DateTime<Utc>, reason: String },
}
/// Why a story was archived. Subsumes the old `blocked`, `merge_failure`,
/// and `review_hold` front-matter fields (story 436).
/// Why a story was archived.
///
/// Story 984: `Abandoned`, `Superseded`, and `Rejected` are now first-class
/// `Stage` variants and are no longer stored here. The remaining variants
/// cover completion paths that stay under `Stage::Archived`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ArchiveReason {
/// Normal happy-path completion.
Completed,
/// User explicitly abandoned the story.
Abandoned,
/// Replaced by another story.
Superseded { by: StoryId },
/// Manually blocked, awaiting human resolution.
/// Manually blocked, awaiting human resolution (legacy — kept for CRDT
/// backward compatibility; new blocked stories use `Stage::Blocked`).
Blocked { reason: String },
/// Mergemaster failed beyond the retry budget.
MergeFailed { reason: String },
/// Held in review at human request.
/// Held in review at human request (legacy — kept for CRDT backward
/// compatibility; new review-held stories use `Stage::ReviewHold`).
ReviewHeld { reason: String },
/// Story rejected by QA or reviewer with an explanation.
Rejected { reason: String },
}
// ── Stage convenience methods ──────────────────────────────────────────────
@@ -326,6 +343,17 @@ impl Stage {
archived_at: DateTime::<Utc>::UNIX_EPOCH,
reason: ArchiveReason::Completed,
}),
"abandoned" => Some(Stage::Abandoned {
ts: DateTime::<Utc>::UNIX_EPOCH,
}),
"superseded" => Some(Stage::Superseded {
ts: DateTime::<Utc>::UNIX_EPOCH,
superseded_by: StoryId(String::new()),
}),
"rejected" => Some(Stage::Rejected {
ts: DateTime::<Utc>::UNIX_EPOCH,
reason: String::new(),
}),
_ => None,
}
}
@@ -418,6 +446,9 @@ pub fn stage_label(s: &Stage) -> &'static str {
Stage::Frozen { .. } => "Frozen",
Stage::ReviewHold { .. } => "ReviewHold",
Stage::Archived { .. } => "Archived",
Stage::Abandoned { .. } => "Abandoned",
Stage::Superseded { .. } => "Superseded",
Stage::Rejected { .. } => "Rejected",
}
}
@@ -440,5 +471,8 @@ pub fn stage_dir_name(s: &Stage) -> &'static str {
Stage::ReviewHold { .. } => "review_hold",
Stage::Done { .. } => "done",
Stage::Archived { .. } => "archived",
Stage::Abandoned { .. } => "abandoned",
Stage::Superseded { .. } => "superseded",
Stage::Rejected { .. } => "rejected",
}
}