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
+7 -2
View File
@@ -95,8 +95,13 @@ pub async fn run(
if let Some(mut crdt_rx) = crdt_state::subscribe() {
tokio::spawn(async move {
while let Ok(evt) = crdt_rx.recv().await {
if matches!(evt.to_stage, crate::pipeline_state::Stage::Archived { .. })
&& let Some(root) = crdt_prune_root.as_ref().cloned()
if matches!(
evt.to_stage,
crate::pipeline_state::Stage::Archived { .. }
| crate::pipeline_state::Stage::Abandoned { .. }
| crate::pipeline_state::Stage::Superseded { .. }
| crate::pipeline_state::Stage::Rejected { .. }
) && let Some(root) = crdt_prune_root.as_ref().cloned()
{
let story_id = evt.story_id.clone();
tokio::spawn(async move {
+139 -3
View File
@@ -11,7 +11,7 @@ use std::path::Path;
use std::process::Command;
use crate::pipeline_state::{
ApplyError, ArchiveReason, BranchName, GitSha, MergeFailureKind, PipelineEvent, Stage,
ApplyError, ArchiveReason, BranchName, GitSha, MergeFailureKind, PipelineEvent, Stage, StoryId,
TransitionFired, apply_transition, stage_label,
};
use crate::slog;
@@ -106,7 +106,14 @@ pub fn move_story_to_done(story_id: &str) -> Result<(), String> {
let item = read_typed_or_err(story_id)?;
// Idempotent: already at or past done.
if matches!(item.stage, Stage::Done { .. } | Stage::Archived { .. }) {
if matches!(
item.stage,
Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. }
) {
return Ok(());
}
@@ -142,6 +149,9 @@ pub fn move_story_to_merge(story_id: &str) -> Result<(), String> {
| Stage::MergeFailure { .. }
| Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. }
) {
return Ok(());
}
@@ -184,6 +194,9 @@ pub fn move_story_to_qa(story_id: &str) -> Result<(), String> {
| Stage::MergeFailure { .. }
| Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. }
) {
return Ok(());
}
@@ -313,6 +326,51 @@ pub fn transition_to_unblocked(story_id: &str) -> Result<(), String> {
Ok(())
}
/// Abandon a work item, transitioning it to `Stage::Abandoned`.
///
/// Valid from any active or done stage. Returns `Err` when the item is not
/// found or the transition is invalid for the current stage.
#[allow(dead_code)]
pub fn abandon_story(story_id: &str) -> Result<(), String> {
apply_transition(story_id, PipelineEvent::Abandon, None)
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Mark a work item as superseded by another, transitioning to `Stage::Superseded`.
///
/// `superseded_by` is the story ID of the replacement work item. Valid from
/// any active or done stage. Returns `Err` on unknown item or invalid transition.
#[allow(dead_code)]
pub fn supersede_story(story_id: &str, superseded_by: &str) -> Result<(), String> {
apply_transition(
story_id,
PipelineEvent::Supersede {
by: StoryId(superseded_by.to_string()),
},
None,
)
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Permanently reject a work item, transitioning it to `Stage::Rejected`.
///
/// `reason` must be non-empty. Valid from any active stage (backlog, coding,
/// qa, or merge). Returns `Err` on unknown item or invalid transition.
#[allow(dead_code)]
pub fn reject_story(story_id: &str, reason: &str) -> Result<(), String> {
apply_transition(
story_id,
PipelineEvent::Reject {
reason: reason.to_string(),
},
None,
)
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Map a (current stage, target stage name) pair to the appropriate PipelineEvent.
fn map_stage_move_to_event(
from: &Stage,
@@ -441,7 +499,14 @@ pub fn move_story_to_stage(story_id: &str, target_stage: &str) -> Result<(String
pub fn close_bug_to_archive(bug_id: &str) -> Result<(), String> {
let item = read_typed_or_err(bug_id)?;
if matches!(item.stage, Stage::Done { .. } | Stage::Archived { .. }) {
if matches!(
item.stage,
Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. }
) {
return Ok(());
}
@@ -472,6 +537,9 @@ fn stage_to_name(s: &Stage) -> &'static str {
Stage::ReviewHold { .. } => "review_hold",
Stage::Done { .. } => "done",
Stage::Archived { .. } => "archived",
Stage::Abandoned { .. } => "abandoned",
Stage::Superseded { .. } => "superseded",
Stage::Rejected { .. } => "rejected",
}
}
@@ -877,6 +945,74 @@ mod tests {
);
}
// ── Story 984: abandon_story / supersede_story / reject_story ───────────────
#[test]
fn abandon_story_transitions_to_abandoned() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "99984_story_abandon";
crate::db::write_item_with_content(
story_id,
"2_current",
"---\nname: Abandon Test\n---\n",
crate::db::ItemMeta::named("Abandon Test"),
);
abandon_story(story_id).expect("abandon_story must succeed");
let item = crate::pipeline_state::read_typed(story_id)
.expect("read must succeed")
.expect("item must exist");
assert!(
matches!(item.stage, Stage::Abandoned { .. }),
"stage must be Abandoned after abandon_story: {:?}",
item.stage
);
}
#[test]
fn supersede_story_transitions_to_superseded() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "99985_story_supersede";
crate::db::write_item_with_content(
story_id,
"1_backlog",
"---\nname: Supersede Test\n---\n",
crate::db::ItemMeta::named("Supersede Test"),
);
supersede_story(story_id, "999_story_replacement").expect("supersede_story must succeed");
let item = crate::pipeline_state::read_typed(story_id)
.expect("read must succeed")
.expect("item must exist");
assert!(
matches!(item.stage, Stage::Superseded { ref superseded_by, .. } if superseded_by.0 == "999_story_replacement"),
"stage must be Superseded with correct ID: {:?}",
item.stage
);
}
#[test]
fn reject_story_transitions_to_rejected() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "99986_story_reject";
crate::db::write_item_with_content(
story_id,
"3_qa",
"---\nname: Reject Test\n---\n",
crate::db::ItemMeta::named("Reject Test"),
);
reject_story(story_id, "not aligned with roadmap").expect("reject_story must succeed");
let item = crate::pipeline_state::read_typed(story_id)
.expect("read must succeed")
.expect("item must exist");
assert!(
matches!(item.stage, Stage::Rejected { ref reason, .. } if reason == "not aligned with roadmap"),
"stage must be Rejected with correct reason: {:?}",
item.stage
);
}
/// Bug 226: feature_branch_has_unmerged_changes returns false when no
/// feature branch exists.
#[test]
@@ -506,6 +506,9 @@ impl AgentPool {
typed_item.stage,
crate::pipeline_state::Stage::Done { .. }
| crate::pipeline_state::Stage::Archived { .. }
| crate::pipeline_state::Stage::Abandoned { .. }
| crate::pipeline_state::Stage::Superseded { .. }
| crate::pipeline_state::Stage::Rejected { .. }
)
{
let current_dir = typed_item.stage.dir_name();
+1 -1
View File
@@ -10,7 +10,7 @@ pub(super) use labels::{story_short_label, traffic_light_dot};
pub(super) use render::{build_pipeline_status, unmet_deps_from_items};
#[cfg(test)]
pub(super) use render::{build_status_from_items, first_non_empty_snippet};
pub(super) use render::{build_status_from_items, display_section, first_non_empty_snippet};
use super::CommandContext;
+36 -3
View File
@@ -28,7 +28,10 @@ pub(crate) fn display_section(s: &Stage) -> Option<&'static str> {
}
Stage::Done { .. } => Some("Done"),
Stage::Frozen { resume_to } => display_section(resume_to),
Stage::Archived { .. } => None, // other archived variants are hidden
Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } => {
Some("Closed")
}
Stage::Archived { .. } => None, // Completed/MergeFailed/ReviewHeld stay hidden
}
}
@@ -50,7 +53,18 @@ pub(crate) fn unmet_deps_from_items(item: &PipelineItem, all_items: &[PipelineIt
|| i.story_id.0.split('_').next() == Some(dep_id.0.as_str())
});
match dep {
Some(d) if matches!(d.stage, Stage::Done { .. } | Stage::Archived { .. }) => None,
Some(d)
if matches!(
d.stage,
Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. }
) =>
{
None
}
Some(_) => Some(dep_num), // Found but not done = unmet
None => None, // Not in CRDT; treat as met
}
@@ -126,7 +140,7 @@ pub(crate) fn build_status_from_items(
// under their stage section (determined by `display_section`); there is
// no separate "Blocked" section. Frozen items appear under the section
// their `resume_to` stage maps to.
let sections = ["Backlog", "In Progress", "QA", "Merge", "Done"];
let sections = ["Backlog", "In Progress", "QA", "Merge", "Done", "Closed"];
for label in sections {
let mut section_items: Vec<&PipelineItem> = items
@@ -225,6 +239,25 @@ fn render_item_line(
format!(" *(waiting on: {})*", nums.join(", "))
};
// Closed-stage items (abandoned / superseded / rejected) each get a
// distinct indicator and optionally display their metadata.
match &item.stage {
Stage::Abandoned { .. } => {
return format!(" \u{1F5D1}\u{FE0F} {display}{cost_suffix}\n"); // 🗑️
}
Stage::Superseded { superseded_by, .. } => {
return format!(
" \u{1F500} {display}{cost_suffix} — superseded by {}\n", // 🔀
superseded_by.0
);
}
Stage::Rejected { reason, .. } => {
let snippet = first_non_empty_snippet(reason, 120);
return format!(" \u{1F6AB} {display}{cost_suffix}{snippet}\n"); // 🚫
}
_ => {}
}
// Merge-stage items get dedicated breakdown indicators instead of the
// generic traffic-light dot. MergeFailure / MergeFailureFinal items
// now also appear in the Merge section (in-place) so they are handled
+124
View File
@@ -938,3 +938,127 @@ fn merge_failure_item_appears_in_merge_section_not_blocked() {
"merge failure reason should be shown: {output}"
);
}
// -- Story 984: Abandoned / Superseded / Rejected appear in Closed section ----
#[test]
fn abandoned_item_appears_in_closed_section_with_wastebasket() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let items = vec![make_item(
"984_story_abandoned",
"Abandoned Story",
Stage::Abandoned { ts: Utc::now() },
)];
let agents = AgentPool::new_test(3000);
let output = build_status_from_items(tmp.path(), &agents, &items);
assert!(
output.contains("**Closed**"),
"output must have a Closed section: {output}"
);
let closed_pos = output.find("**Closed**").unwrap();
let story_pos = output
.find("984 [story]")
.expect("story must appear in output");
assert!(
story_pos > closed_pos,
"abandoned story should be after Closed header: {output}"
);
assert!(
output.contains("\u{1F5D1}\u{FE0F}"), // 🗑️
"abandoned story should show wastebasket icon: {output}"
);
}
#[test]
fn superseded_item_appears_in_closed_section_with_shuffle() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let items = vec![make_item(
"985_story_superseded",
"Superseded Story",
Stage::Superseded {
ts: Utc::now(),
superseded_by: crate::pipeline_state::StoryId("999_story_new".to_string()),
},
)];
let agents = AgentPool::new_test(3000);
let output = build_status_from_items(tmp.path(), &agents, &items);
let closed_pos = output
.find("**Closed**")
.expect("Closed section must exist");
let story_pos = output
.find("985 [story]")
.expect("story must appear in output");
assert!(story_pos > closed_pos, "superseded story must be in Closed");
assert!(
output.contains("\u{1F500}"), // 🔀
"superseded story should show shuffle icon: {output}"
);
assert!(
output.contains("999_story_new"),
"superseded story should show the replacement ID: {output}"
);
}
#[test]
fn rejected_item_appears_in_closed_section_with_no_entry() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let items = vec![make_item(
"986_story_rejected",
"Rejected Story",
Stage::Rejected {
ts: Utc::now(),
reason: "not aligned with roadmap".to_string(),
},
)];
let agents = AgentPool::new_test(3000);
let output = build_status_from_items(tmp.path(), &agents, &items);
let closed_pos = output
.find("**Closed**")
.expect("Closed section must exist");
let story_pos = output
.find("986 [story]")
.expect("story must appear in output");
assert!(story_pos > closed_pos, "rejected story must be in Closed");
assert!(
output.contains("\u{1F6AB}"), // 🚫
"rejected story should show no-entry icon: {output}"
);
assert!(
output.contains("not aligned with roadmap"),
"rejected story should show the rejection reason: {output}"
);
}
#[test]
fn display_section_returns_closed_for_new_terminal_variants() {
assert_eq!(
display_section(&Stage::Abandoned { ts: Utc::now() }),
Some("Closed")
);
assert_eq!(
display_section(&Stage::Superseded {
ts: Utc::now(),
superseded_by: crate::pipeline_state::StoryId("1".to_string()),
}),
Some("Closed")
);
assert_eq!(
display_section(&Stage::Rejected {
ts: Utc::now(),
reason: "x".to_string(),
}),
Some("Closed")
);
}
@@ -118,6 +118,9 @@ fn stage_display_label(stage: &crate::pipeline_state::Stage) -> &'static str {
Stage::MergeFailureFinal { .. } => "merge-failure-final",
Stage::Frozen { .. } => "frozen",
Stage::ReviewHold { .. } => "review-hold",
Stage::Abandoned { .. } => "abandoned",
Stage::Superseded { .. } => "superseded",
Stage::Rejected { .. } => "rejected",
}
}
+2 -1
View File
@@ -54,7 +54,8 @@ pub use types::{
pub use write::{
bump_retry_count, migrate_legacy_stage_strings, migrate_merge_job, migrate_names_from_slugs,
migrate_story_ids_to_numeric, name_from_story_id, set_agent, set_depends_on, set_epic,
set_item_type, set_name, set_qa_mode, set_resume_to, set_retry_count, write_item,
set_item_type, set_name, set_qa_mode, set_resume_to, set_resume_to_raw, set_retry_count,
write_item,
};
#[cfg(test)]
+24 -2
View File
@@ -487,6 +487,15 @@ fn project_stage_for_view(
archived_at: Utc::now(),
reason: ArchiveReason::Completed,
}),
"abandoned" => Some(Stage::Abandoned { ts: Utc::now() }),
"superseded" => Some(Stage::Superseded {
ts: Utc::now(),
superseded_by: crate::pipeline_state::StoryId(resume_to.unwrap_or("").to_string()),
}),
"rejected" => Some(Stage::Rejected {
ts: Utc::now(),
reason: resume_to.unwrap_or("").to_string(),
}),
_ => None,
}
}
@@ -504,7 +513,14 @@ pub fn dep_is_done_crdt(dep_number: u32) -> bool {
let prefix = format!("{dep_number}_");
read_all_typed().into_iter().any(|item| {
(item.story_id.0 == exact || item.story_id.0.starts_with(&prefix))
&& matches!(item.stage, Stage::Done { .. } | Stage::Archived { .. })
&& matches!(
item.stage,
Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. }
)
})
}
@@ -520,7 +536,13 @@ pub fn dep_is_archived_crdt(dep_number: u32) -> bool {
let prefix = format!("{dep_number}_");
read_all_typed().into_iter().any(|item| {
(item.story_id.0 == exact || item.story_id.0.starts_with(&prefix))
&& matches!(item.stage, Stage::Archived { .. })
&& matches!(
item.stage,
Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. }
)
})
}
+4
View File
@@ -92,6 +92,10 @@ pub struct PipelineItemCrdt {
/// `Stage::ReviewHold` variants. Stored as a clean wire-form stage
/// name (e.g. `"coding"`, `"qa"`). Empty string means "no resume target
/// stored" (defaults to `Coding` on read).
/// Story 984: also reused to carry `superseded_by` (story ID) when the
/// stage is `"superseded"`, and the rejection `reason` when the stage is
/// `"rejected"`. These stages never have a resume target, so the
/// register is exclusively available for their metadata.
pub resume_to: LwwRegisterCrdt<String>,
}
+24
View File
@@ -111,6 +111,30 @@ pub fn set_resume_to(story_id: &str, stage: &Stage) -> bool {
true
}
/// Set the `resume_to` CRDT register to an arbitrary raw string.
///
/// Story 984: reuses `resume_to` to carry metadata for `Superseded`
/// (`superseded_by` story ID) and `Rejected` (`reason` string). These
/// stages never have a resume target, so the register is exclusively
/// available for their metadata.
///
/// Returns `true` if the item was found and the op was applied.
pub fn set_resume_to_raw(story_id: &str, value: &str) -> bool {
let Some(state_mutex) = get_crdt() else {
return false;
};
let Ok(mut state) = state_mutex.lock() else {
return false;
};
let Some(&idx) = state.index.get(story_id) else {
return false;
};
apply_and_persist(&mut state, |s| {
s.crdt.doc.items[idx].resume_to.set(value.to_string())
});
true
}
/// Set the `name` field for a pipeline item by its story ID.
///
/// `Some(name)` writes the human-readable name into the CRDT register.
+1 -1
View File
@@ -11,7 +11,7 @@ mod tests;
pub use item::{
bump_retry_count, set_agent, set_depends_on, set_epic, set_item_type, set_name, set_qa_mode,
set_resume_to, set_retry_count, write_item,
set_resume_to, set_resume_to_raw, set_retry_count, write_item,
};
#[cfg(test)]
+3
View File
@@ -21,6 +21,9 @@ pub(super) async fn tool_merge_agent_work(
item.stage(),
crate::pipeline_state::Stage::Done { .. }
| crate::pipeline_state::Stage::Archived { .. }
| crate::pipeline_state::Stage::Abandoned { .. }
| crate::pipeline_state::Stage::Superseded { .. }
| crate::pipeline_state::Stage::Rejected { .. }
)
{
let stage_name = item.stage().dir_name().to_string();
+8 -1
View File
@@ -62,6 +62,8 @@ pub struct PipelineState {
pub qa: Vec<UpcomingStory>,
pub merge: Vec<UpcomingStory>,
pub done: Vec<UpcomingStory>,
/// Abandoned, superseded, and rejected items (story 984).
pub closed: Vec<UpcomingStory>,
/// Story IDs that currently have a deterministic merge in progress.
pub deterministic_merges_in_flight: Vec<String>,
}
@@ -101,6 +103,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
qa: Vec::new(),
merge: Vec::new(),
done: Vec::new(),
closed: Vec::new(),
deterministic_merges_in_flight,
};
@@ -179,7 +182,10 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
}
}
Stage::Done { .. } => state.done.push(story),
Stage::Archived { .. } => {} // skip archived
Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } => {
state.closed.push(story)
}
Stage::Archived { .. } => {} // Completed/MergeFailed/ReviewHeld stay hidden
}
}
@@ -189,6 +195,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
state.qa.sort_by(|a, b| a.story_id.cmp(&b.story_id));
state.merge.sort_by(|a, b| a.story_id.cmp(&b.story_id));
state.done.sort_by(|a, b| a.story_id.cmp(&b.story_id));
state.closed.sort_by(|a, b| a.story_id.cmp(&b.story_id));
Ok(state)
}
+3
View File
@@ -69,6 +69,9 @@ pub fn stage_metadata(
Stage::ReviewHold { .. } => ("review_hold", format!("huskies: review_hold {item_id}")),
Stage::Done { .. } => ("done", format!("huskies: done {item_id}")),
Stage::Archived { .. } => ("accept", format!("huskies: accept {item_id}")),
Stage::Abandoned { .. } => ("abandon", format!("huskies: abandon {item_id}")),
Stage::Superseded { .. } => ("supersede", format!("huskies: supersede {item_id}")),
Stage::Rejected { .. } => ("reject", format!("huskies: reject {item_id}")),
}
}
+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",
}
}
@@ -22,6 +22,9 @@ pub fn stage_display_name(stage: &Stage) -> &'static str {
Stage::MergeFailureFinal { .. } => "MergeFailureFinal",
Stage::Frozen { .. } => "Frozen",
Stage::ReviewHold { .. } => "ReviewHold",
Stage::Abandoned { .. } => "Abandoned",
Stage::Superseded { .. } => "Superseded",
Stage::Rejected { .. } => "Rejected",
}
}
+7 -1
View File
@@ -242,7 +242,13 @@ pub async fn tick_once(
if let Ok(Some(item)) = crate::pipeline_state::read_typed(&entry.story_id) {
use crate::pipeline_state::Stage;
match &item.stage {
Stage::Qa | Stage::Merge { .. } | Stage::Done { .. } | Stage::Archived { .. } => {
Stage::Qa
| Stage::Merge { .. }
| Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. } => {
crate::slog!(
"[timer] Skipping timer for story {} — currently in '{}', \
not in backlog/current; timer is stale",
+3
View File
@@ -248,6 +248,7 @@ mod tests {
depends_on: None,
epic_id: None,
}],
closed: vec![],
deterministic_merges_in_flight: vec![],
};
let resp = pipeline_state_to_response(state);
@@ -271,6 +272,7 @@ mod tests {
qa: vec![],
merge: vec![],
done: vec![],
closed: vec![],
deterministic_merges_in_flight: vec![],
};
let resp = pipeline_state_to_response(state);
@@ -308,6 +310,7 @@ mod tests {
qa: vec![],
merge: vec![],
done: vec![],
closed: vec![],
deterministic_merges_in_flight: vec![],
};
let resp: WsResponse = state.into();
+7 -2
View File
@@ -28,8 +28,13 @@ pub(crate) fn spawn_event_bridges(
if let Some(mut crdt_rx) = crate::crdt_state::subscribe() {
tokio::spawn(async move {
while let Ok(evt) = crdt_rx.recv().await {
if matches!(evt.to_stage, crate::pipeline_state::Stage::Archived { .. })
&& let Some(root) = crdt_prune_root.as_ref().cloned()
if matches!(
evt.to_stage,
crate::pipeline_state::Stage::Archived { .. }
| crate::pipeline_state::Stage::Abandoned { .. }
| crate::pipeline_state::Stage::Superseded { .. }
| crate::pipeline_state::Stage::Rejected { .. }
) && let Some(root) = crdt_prune_root.as_ref().cloned()
{
let story_id = evt.story_id.clone();
tokio::spawn(async move {
+7 -1
View File
@@ -16,7 +16,13 @@ use super::{list_worktrees, remove_worktree_by_story_id};
pub fn worktree_should_be_swept(stage: Option<&Stage>) -> bool {
match stage {
None => true,
Some(Stage::Done { .. }) | Some(Stage::Archived { .. }) => true,
Some(
Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. },
) => true,
Some(_) => false,
}
}