huskies: merge 984
This commit is contained in:
@@ -95,8 +95,13 @@ pub async fn run(
|
|||||||
if let Some(mut crdt_rx) = crdt_state::subscribe() {
|
if let Some(mut crdt_rx) = crdt_state::subscribe() {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(evt) = crdt_rx.recv().await {
|
while let Ok(evt) = crdt_rx.recv().await {
|
||||||
if matches!(evt.to_stage, crate::pipeline_state::Stage::Archived { .. })
|
if matches!(
|
||||||
&& let Some(root) = crdt_prune_root.as_ref().cloned()
|
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();
|
let story_id = evt.story_id.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::path::Path;
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::pipeline_state::{
|
use crate::pipeline_state::{
|
||||||
ApplyError, ArchiveReason, BranchName, GitSha, MergeFailureKind, PipelineEvent, Stage,
|
ApplyError, ArchiveReason, BranchName, GitSha, MergeFailureKind, PipelineEvent, Stage, StoryId,
|
||||||
TransitionFired, apply_transition, stage_label,
|
TransitionFired, apply_transition, stage_label,
|
||||||
};
|
};
|
||||||
use crate::slog;
|
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)?;
|
let item = read_typed_or_err(story_id)?;
|
||||||
|
|
||||||
// Idempotent: already at or past done.
|
// 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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +149,9 @@ pub fn move_story_to_merge(story_id: &str) -> Result<(), String> {
|
|||||||
| Stage::MergeFailure { .. }
|
| Stage::MergeFailure { .. }
|
||||||
| Stage::Done { .. }
|
| Stage::Done { .. }
|
||||||
| Stage::Archived { .. }
|
| Stage::Archived { .. }
|
||||||
|
| Stage::Abandoned { .. }
|
||||||
|
| Stage::Superseded { .. }
|
||||||
|
| Stage::Rejected { .. }
|
||||||
) {
|
) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -184,6 +194,9 @@ pub fn move_story_to_qa(story_id: &str) -> Result<(), String> {
|
|||||||
| Stage::MergeFailure { .. }
|
| Stage::MergeFailure { .. }
|
||||||
| Stage::Done { .. }
|
| Stage::Done { .. }
|
||||||
| Stage::Archived { .. }
|
| Stage::Archived { .. }
|
||||||
|
| Stage::Abandoned { .. }
|
||||||
|
| Stage::Superseded { .. }
|
||||||
|
| Stage::Rejected { .. }
|
||||||
) {
|
) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -313,6 +326,51 @@ pub fn transition_to_unblocked(story_id: &str) -> Result<(), String> {
|
|||||||
Ok(())
|
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.
|
/// Map a (current stage, target stage name) pair to the appropriate PipelineEvent.
|
||||||
fn map_stage_move_to_event(
|
fn map_stage_move_to_event(
|
||||||
from: &Stage,
|
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> {
|
pub fn close_bug_to_archive(bug_id: &str) -> Result<(), String> {
|
||||||
let item = read_typed_or_err(bug_id)?;
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,6 +537,9 @@ fn stage_to_name(s: &Stage) -> &'static str {
|
|||||||
Stage::ReviewHold { .. } => "review_hold",
|
Stage::ReviewHold { .. } => "review_hold",
|
||||||
Stage::Done { .. } => "done",
|
Stage::Done { .. } => "done",
|
||||||
Stage::Archived { .. } => "archived",
|
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
|
/// Bug 226: feature_branch_has_unmerged_changes returns false when no
|
||||||
/// feature branch exists.
|
/// feature branch exists.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -506,6 +506,9 @@ impl AgentPool {
|
|||||||
typed_item.stage,
|
typed_item.stage,
|
||||||
crate::pipeline_state::Stage::Done { .. }
|
crate::pipeline_state::Stage::Done { .. }
|
||||||
| crate::pipeline_state::Stage::Archived { .. }
|
| 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();
|
let current_dir = typed_item.stage.dir_name();
|
||||||
|
|||||||
@@ -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};
|
pub(super) use render::{build_pipeline_status, unmet_deps_from_items};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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;
|
use super::CommandContext;
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ pub(crate) fn display_section(s: &Stage) -> Option<&'static str> {
|
|||||||
}
|
}
|
||||||
Stage::Done { .. } => Some("Done"),
|
Stage::Done { .. } => Some("Done"),
|
||||||
Stage::Frozen { resume_to } => display_section(resume_to),
|
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())
|
|| i.story_id.0.split('_').next() == Some(dep_id.0.as_str())
|
||||||
});
|
});
|
||||||
match dep {
|
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
|
Some(_) => Some(dep_num), // Found but not done = unmet
|
||||||
None => None, // Not in CRDT; treat as met
|
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
|
// under their stage section (determined by `display_section`); there is
|
||||||
// no separate "Blocked" section. Frozen items appear under the section
|
// no separate "Blocked" section. Frozen items appear under the section
|
||||||
// their `resume_to` stage maps to.
|
// 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 {
|
for label in sections {
|
||||||
let mut section_items: Vec<&PipelineItem> = items
|
let mut section_items: Vec<&PipelineItem> = items
|
||||||
@@ -225,6 +239,25 @@ fn render_item_line(
|
|||||||
format!(" *(waiting on: {})*", nums.join(", "))
|
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
|
// Merge-stage items get dedicated breakdown indicators instead of the
|
||||||
// generic traffic-light dot. MergeFailure / MergeFailureFinal items
|
// generic traffic-light dot. MergeFailure / MergeFailureFinal items
|
||||||
// now also appear in the Merge section (in-place) so they are handled
|
// now also appear in the Merge section (in-place) so they are handled
|
||||||
|
|||||||
@@ -938,3 +938,127 @@ fn merge_failure_item_appears_in_merge_section_not_blocked() {
|
|||||||
"merge failure reason should be shown: {output}"
|
"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::MergeFailureFinal { .. } => "merge-failure-final",
|
||||||
Stage::Frozen { .. } => "frozen",
|
Stage::Frozen { .. } => "frozen",
|
||||||
Stage::ReviewHold { .. } => "review-hold",
|
Stage::ReviewHold { .. } => "review-hold",
|
||||||
|
Stage::Abandoned { .. } => "abandoned",
|
||||||
|
Stage::Superseded { .. } => "superseded",
|
||||||
|
Stage::Rejected { .. } => "rejected",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ pub use types::{
|
|||||||
pub use write::{
|
pub use write::{
|
||||||
bump_retry_count, migrate_legacy_stage_strings, migrate_merge_job, migrate_names_from_slugs,
|
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,
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -487,6 +487,15 @@ fn project_stage_for_view(
|
|||||||
archived_at: Utc::now(),
|
archived_at: Utc::now(),
|
||||||
reason: ArchiveReason::Completed,
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,7 +513,14 @@ pub fn dep_is_done_crdt(dep_number: u32) -> bool {
|
|||||||
let prefix = format!("{dep_number}_");
|
let prefix = format!("{dep_number}_");
|
||||||
read_all_typed().into_iter().any(|item| {
|
read_all_typed().into_iter().any(|item| {
|
||||||
(item.story_id.0 == exact || item.story_id.0.starts_with(&prefix))
|
(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}_");
|
let prefix = format!("{dep_number}_");
|
||||||
read_all_typed().into_iter().any(|item| {
|
read_all_typed().into_iter().any(|item| {
|
||||||
(item.story_id.0 == exact || item.story_id.0.starts_with(&prefix))
|
(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 { .. }
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ pub struct PipelineItemCrdt {
|
|||||||
/// `Stage::ReviewHold` variants. Stored as a clean wire-form stage
|
/// `Stage::ReviewHold` variants. Stored as a clean wire-form stage
|
||||||
/// name (e.g. `"coding"`, `"qa"`). Empty string means "no resume target
|
/// name (e.g. `"coding"`, `"qa"`). Empty string means "no resume target
|
||||||
/// stored" (defaults to `Coding` on read).
|
/// 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>,
|
pub resume_to: LwwRegisterCrdt<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,30 @@ pub fn set_resume_to(story_id: &str, stage: &Stage) -> bool {
|
|||||||
true
|
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.
|
/// Set the `name` field for a pipeline item by its story ID.
|
||||||
///
|
///
|
||||||
/// `Some(name)` writes the human-readable name into the CRDT register.
|
/// `Some(name)` writes the human-readable name into the CRDT register.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ mod tests;
|
|||||||
|
|
||||||
pub use item::{
|
pub use item::{
|
||||||
bump_retry_count, set_agent, set_depends_on, set_epic, set_item_type, set_name, set_qa_mode,
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ pub(super) async fn tool_merge_agent_work(
|
|||||||
item.stage(),
|
item.stage(),
|
||||||
crate::pipeline_state::Stage::Done { .. }
|
crate::pipeline_state::Stage::Done { .. }
|
||||||
| crate::pipeline_state::Stage::Archived { .. }
|
| 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();
|
let stage_name = item.stage().dir_name().to_string();
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ pub struct PipelineState {
|
|||||||
pub qa: Vec<UpcomingStory>,
|
pub qa: Vec<UpcomingStory>,
|
||||||
pub merge: Vec<UpcomingStory>,
|
pub merge: Vec<UpcomingStory>,
|
||||||
pub done: 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.
|
/// Story IDs that currently have a deterministic merge in progress.
|
||||||
pub deterministic_merges_in_flight: Vec<String>,
|
pub deterministic_merges_in_flight: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -101,6 +103,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
|||||||
qa: Vec::new(),
|
qa: Vec::new(),
|
||||||
merge: Vec::new(),
|
merge: Vec::new(),
|
||||||
done: Vec::new(),
|
done: Vec::new(),
|
||||||
|
closed: Vec::new(),
|
||||||
deterministic_merges_in_flight,
|
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::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.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.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.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)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ pub fn stage_metadata(
|
|||||||
Stage::ReviewHold { .. } => ("review_hold", format!("huskies: review_hold {item_id}")),
|
Stage::ReviewHold { .. } => ("review_hold", format!("huskies: review_hold {item_id}")),
|
||||||
Stage::Done { .. } => ("done", format!("huskies: done {item_id}")),
|
Stage::Done { .. } => ("done", format!("huskies: done {item_id}")),
|
||||||
Stage::Archived { .. } => ("accept", format!("huskies: accept {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}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,20 @@ pub fn apply_transition(
|
|||||||
// Write the new stage to the CRDT (with optional content transform).
|
// Write the new stage to the CRDT (with optional content transform).
|
||||||
crate::db::move_item_stage(story_id, new_dir, 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 {
|
let fired = TransitionFired {
|
||||||
story_id: StoryId(story_id.to_string()),
|
story_id: StoryId(story_id.to_string()),
|
||||||
before,
|
before,
|
||||||
|
|||||||
@@ -71,7 +71,14 @@ impl TransitionSubscriber for AutoAssignSubscriber {
|
|||||||
"auto-assign"
|
"auto-assign"
|
||||||
}
|
}
|
||||||
fn on_transition(&self, f: &TransitionFired) {
|
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!(
|
crate::slog!(
|
||||||
"[pipeline/auto-assign] story {} reached {}; checking for promotable backlog items",
|
"[pipeline/auto-assign] story {} reached {}; checking for promotable backlog items",
|
||||||
f.story_id,
|
f.story_id,
|
||||||
|
|||||||
@@ -259,13 +259,7 @@ fn abandon_from_any_active_or_done() {
|
|||||||
},
|
},
|
||||||
] {
|
] {
|
||||||
let result = transition(s, PipelineEvent::Abandon);
|
let result = transition(s, PipelineEvent::Abandon);
|
||||||
assert!(matches!(
|
assert!(matches!(result, Ok(Stage::Abandoned { .. })));
|
||||||
result,
|
|
||||||
Ok(Stage::Archived {
|
|
||||||
reason: ArchiveReason::Abandoned,
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,13 +280,7 @@ fn supersede_from_any_active_or_done() {
|
|||||||
by: sid("999_story_new"),
|
by: sid("999_story_new"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(result, Ok(Stage::Superseded { .. })));
|
||||||
result,
|
|
||||||
Ok(Stage::Archived {
|
|
||||||
reason: ArchiveReason::Superseded { .. },
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,13 +452,7 @@ fn cannot_triage_from_backlog() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn abandon_from_upcoming() {
|
fn abandon_from_upcoming() {
|
||||||
let result = transition(Stage::Upcoming, PipelineEvent::Abandon).unwrap();
|
let result = transition(Stage::Upcoming, PipelineEvent::Abandon).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(result, Stage::Abandoned { .. }));
|
||||||
result,
|
|
||||||
Stage::Archived {
|
|
||||||
reason: ArchiveReason::Abandoned,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -482,13 +464,7 @@ fn supersede_from_upcoming() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(result, Stage::Superseded { .. }));
|
||||||
result,
|
|
||||||
Stage::Archived {
|
|
||||||
reason: ArchiveReason::Superseded { .. },
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -511,13 +487,7 @@ fn reject_from_active_stages() {
|
|||||||
reason: "not needed".into(),
|
reason: "not needed".into(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(result, Ok(Stage::Rejected { .. })));
|
||||||
result,
|
|
||||||
Ok(Stage::Archived {
|
|
||||||
reason: ArchiveReason::Rejected { .. },
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let m = Stage::Merge {
|
let m = Stage::Merge {
|
||||||
@@ -530,13 +500,7 @@ fn reject_from_active_stages() {
|
|||||||
reason: "not needed".into(),
|
reason: "not needed".into(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(result, Ok(Stage::Rejected { .. })));
|
||||||
result,
|
|
||||||
Ok(Stage::Archived {
|
|
||||||
reason: ArchiveReason::Rejected { .. },
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -249,29 +249,23 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
|
|||||||
| (Coding, Abandon)
|
| (Coding, Abandon)
|
||||||
| (Qa, Abandon)
|
| (Qa, Abandon)
|
||||||
| (Merge { .. }, Abandon)
|
| (Merge { .. }, Abandon)
|
||||||
| (Done { .. }, Abandon) => Ok(Archived {
|
| (Done { .. }, Abandon) => Ok(Abandoned { ts: now }),
|
||||||
archived_at: now,
|
|
||||||
reason: ArchiveReason::Abandoned,
|
|
||||||
}),
|
|
||||||
|
|
||||||
(Upcoming, Supersede { by })
|
(Upcoming, Supersede { by })
|
||||||
| (Backlog, Supersede { by })
|
| (Backlog, Supersede { by })
|
||||||
| (Coding, Supersede { by })
|
| (Coding, Supersede { by })
|
||||||
| (Qa, Supersede { by })
|
| (Qa, Supersede { by })
|
||||||
| (Merge { .. }, Supersede { by })
|
| (Merge { .. }, Supersede { by })
|
||||||
| (Done { .. }, Supersede { by }) => Ok(Archived {
|
| (Done { .. }, Supersede { by }) => Ok(Superseded {
|
||||||
archived_at: now,
|
ts: now,
|
||||||
reason: ArchiveReason::Superseded { by },
|
superseded_by: by,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ── Reject from any active stage or QA ──────────────────────────
|
// ── Reject from any active stage or QA ──────────────────────────
|
||||||
(Backlog, Reject { reason })
|
(Backlog, Reject { reason })
|
||||||
| (Coding, Reject { reason })
|
| (Coding, Reject { reason })
|
||||||
| (Qa, Reject { reason })
|
| (Qa, Reject { reason })
|
||||||
| (Merge { .. }, Reject { reason }) => Ok(Archived {
|
| (Merge { .. }, Reject { reason }) => Ok(Rejected { ts: now, reason }),
|
||||||
archived_at: now,
|
|
||||||
reason: ArchiveReason::Rejected { reason },
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ── Demote: send an active item back to backlog ────────────────
|
// ── Demote: send an active item back to backlog ────────────────
|
||||||
// `Blocked + Demote → Backlog` lets operators park a stuck story in
|
// `Blocked + Demote → Backlog` lets operators park a stuck story in
|
||||||
|
|||||||
@@ -119,12 +119,12 @@ impl MergeFailureKind {
|
|||||||
/// - `retry_count` — also local
|
/// - `retry_count` — also local
|
||||||
/// - `blocked` — now a first-class `Blocked { reason }` stage
|
/// - `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:
|
/// The following named lifecycle states map to `Stage` variants:
|
||||||
///
|
///
|
||||||
/// | Lifecycle state | Stage variant |
|
/// | Lifecycle state | Stage variant |
|
||||||
/// |-----------------|-----------------------------------|
|
/// |-----------------|------------------------|
|
||||||
/// | upcoming | `Upcoming` |
|
/// | upcoming | `Upcoming` |
|
||||||
/// | backlog | `Backlog` |
|
/// | backlog | `Backlog` |
|
||||||
/// | current | `Coding` |
|
/// | current | `Coding` |
|
||||||
@@ -133,10 +133,10 @@ impl MergeFailureKind {
|
|||||||
/// | merge_failure | `MergeFailure { .. }` |
|
/// | merge_failure | `MergeFailure { .. }` |
|
||||||
/// | done | `Done { .. }` |
|
/// | done | `Done { .. }` |
|
||||||
/// | blocked | `Blocked { .. }` |
|
/// | blocked | `Blocked { .. }` |
|
||||||
/// | archived | `Archived { Completed }` |
|
/// | archived | `Archived { .. }` |
|
||||||
/// | superseded | `Archived { Superseded { .. } }` |
|
/// | superseded | `Superseded { .. }` |
|
||||||
/// | rejected | `Archived { Rejected { .. } }` |
|
/// | rejected | `Rejected { .. }` |
|
||||||
/// | abandoned | `Archived { Abandoned }` |
|
/// | abandoned | `Abandoned { .. }` |
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum Stage {
|
pub enum Stage {
|
||||||
/// Story has been created but not yet triaged into the backlog.
|
/// Story has been created but not yet triaged into the backlog.
|
||||||
@@ -215,26 +215,43 @@ pub enum Stage {
|
|||||||
resume_to: Box<Stage>,
|
resume_to: Box<Stage>,
|
||||||
reason: String,
|
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`,
|
/// Why a story was archived.
|
||||||
/// and `review_hold` front-matter fields (story 436).
|
///
|
||||||
|
/// 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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum ArchiveReason {
|
pub enum ArchiveReason {
|
||||||
/// Normal happy-path completion.
|
/// Normal happy-path completion.
|
||||||
Completed,
|
Completed,
|
||||||
/// User explicitly abandoned the story.
|
/// Manually blocked, awaiting human resolution (legacy — kept for CRDT
|
||||||
Abandoned,
|
/// backward compatibility; new blocked stories use `Stage::Blocked`).
|
||||||
/// Replaced by another story.
|
|
||||||
Superseded { by: StoryId },
|
|
||||||
/// Manually blocked, awaiting human resolution.
|
|
||||||
Blocked { reason: String },
|
Blocked { reason: String },
|
||||||
/// Mergemaster failed beyond the retry budget.
|
/// Mergemaster failed beyond the retry budget.
|
||||||
MergeFailed { reason: String },
|
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 },
|
ReviewHeld { reason: String },
|
||||||
/// Story rejected by QA or reviewer with an explanation.
|
|
||||||
Rejected { reason: String },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stage convenience methods ──────────────────────────────────────────────
|
// ── Stage convenience methods ──────────────────────────────────────────────
|
||||||
@@ -326,6 +343,17 @@ impl Stage {
|
|||||||
archived_at: DateTime::<Utc>::UNIX_EPOCH,
|
archived_at: DateTime::<Utc>::UNIX_EPOCH,
|
||||||
reason: ArchiveReason::Completed,
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -418,6 +446,9 @@ pub fn stage_label(s: &Stage) -> &'static str {
|
|||||||
Stage::Frozen { .. } => "Frozen",
|
Stage::Frozen { .. } => "Frozen",
|
||||||
Stage::ReviewHold { .. } => "ReviewHold",
|
Stage::ReviewHold { .. } => "ReviewHold",
|
||||||
Stage::Archived { .. } => "Archived",
|
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::ReviewHold { .. } => "review_hold",
|
||||||
Stage::Done { .. } => "done",
|
Stage::Done { .. } => "done",
|
||||||
Stage::Archived { .. } => "archived",
|
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::MergeFailureFinal { .. } => "MergeFailureFinal",
|
||||||
Stage::Frozen { .. } => "Frozen",
|
Stage::Frozen { .. } => "Frozen",
|
||||||
Stage::ReviewHold { .. } => "ReviewHold",
|
Stage::ReviewHold { .. } => "ReviewHold",
|
||||||
|
Stage::Abandoned { .. } => "Abandoned",
|
||||||
|
Stage::Superseded { .. } => "Superseded",
|
||||||
|
Stage::Rejected { .. } => "Rejected",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -242,7 +242,13 @@ pub async fn tick_once(
|
|||||||
if let Ok(Some(item)) = crate::pipeline_state::read_typed(&entry.story_id) {
|
if let Ok(Some(item)) = crate::pipeline_state::read_typed(&entry.story_id) {
|
||||||
use crate::pipeline_state::Stage;
|
use crate::pipeline_state::Stage;
|
||||||
match &item.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!(
|
crate::slog!(
|
||||||
"[timer] Skipping timer for story {} — currently in '{}', \
|
"[timer] Skipping timer for story {} — currently in '{}', \
|
||||||
not in backlog/current; timer is stale",
|
not in backlog/current; timer is stale",
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ mod tests {
|
|||||||
depends_on: None,
|
depends_on: None,
|
||||||
epic_id: None,
|
epic_id: None,
|
||||||
}],
|
}],
|
||||||
|
closed: vec![],
|
||||||
deterministic_merges_in_flight: vec![],
|
deterministic_merges_in_flight: vec![],
|
||||||
};
|
};
|
||||||
let resp = pipeline_state_to_response(state);
|
let resp = pipeline_state_to_response(state);
|
||||||
@@ -271,6 +272,7 @@ mod tests {
|
|||||||
qa: vec![],
|
qa: vec![],
|
||||||
merge: vec![],
|
merge: vec![],
|
||||||
done: vec![],
|
done: vec![],
|
||||||
|
closed: vec![],
|
||||||
deterministic_merges_in_flight: vec![],
|
deterministic_merges_in_flight: vec![],
|
||||||
};
|
};
|
||||||
let resp = pipeline_state_to_response(state);
|
let resp = pipeline_state_to_response(state);
|
||||||
@@ -308,6 +310,7 @@ mod tests {
|
|||||||
qa: vec![],
|
qa: vec![],
|
||||||
merge: vec![],
|
merge: vec![],
|
||||||
done: vec![],
|
done: vec![],
|
||||||
|
closed: vec![],
|
||||||
deterministic_merges_in_flight: vec![],
|
deterministic_merges_in_flight: vec![],
|
||||||
};
|
};
|
||||||
let resp: WsResponse = state.into();
|
let resp: WsResponse = state.into();
|
||||||
|
|||||||
@@ -28,8 +28,13 @@ pub(crate) fn spawn_event_bridges(
|
|||||||
if let Some(mut crdt_rx) = crate::crdt_state::subscribe() {
|
if let Some(mut crdt_rx) = crate::crdt_state::subscribe() {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(evt) = crdt_rx.recv().await {
|
while let Ok(evt) = crdt_rx.recv().await {
|
||||||
if matches!(evt.to_stage, crate::pipeline_state::Stage::Archived { .. })
|
if matches!(
|
||||||
&& let Some(root) = crdt_prune_root.as_ref().cloned()
|
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();
|
let story_id = evt.story_id.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ use super::{list_worktrees, remove_worktree_by_story_id};
|
|||||||
pub fn worktree_should_be_swept(stage: Option<&Stage>) -> bool {
|
pub fn worktree_should_be_swept(stage: Option<&Stage>) -> bool {
|
||||||
match stage {
|
match stage {
|
||||||
None => true,
|
None => true,
|
||||||
Some(Stage::Done { .. }) | Some(Stage::Archived { .. }) => true,
|
Some(
|
||||||
|
Stage::Done { .. }
|
||||||
|
| Stage::Archived { .. }
|
||||||
|
| Stage::Abandoned { .. }
|
||||||
|
| Stage::Superseded { .. }
|
||||||
|
| Stage::Rejected { .. },
|
||||||
|
) => true,
|
||||||
Some(_) => false,
|
Some(_) => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user