From 580480094ead9e04be669c4fe653118ebb6d5557 Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 13 May 2026 16:43:19 +0000 Subject: [PATCH] huskies: merge 984 --- server/src/agent_mode/mod.rs | 9 +- server/src/agents/lifecycle.rs | 142 +++++++++++++++++- .../src/agents/pool/pipeline/advance/mod.rs | 3 + server/src/chat/commands/status/mod.rs | 2 +- server/src/chat/commands/status/render.rs | 39 ++++- server/src/chat/commands/status/tests.rs | 124 +++++++++++++++ server/src/chat/transport/matrix/delete.rs | 3 + server/src/crdt_state/mod.rs | 3 +- server/src/crdt_state/read.rs | 26 +++- server/src/crdt_state/types.rs | 4 + server/src/crdt_state/write/item.rs | 24 +++ server/src/crdt_state/write/mod.rs | 2 +- server/src/http/mcp/merge_tools.rs | 3 + server/src/http/workflow/pipeline.rs | 9 +- server/src/io/watcher/mod.rs | 3 + server/src/pipeline_state/apply.rs | 14 ++ server/src/pipeline_state/subscribers.rs | 9 +- server/src/pipeline_state/tests.rs | 48 +----- server/src/pipeline_state/transition.rs | 16 +- server/src/pipeline_state/types.rs | 84 ++++++++--- server/src/service/notifications/format.rs | 3 + server/src/service/timer/io.rs | 8 +- server/src/service/ws/message/convert.rs | 3 + server/src/startup/tick_loop.rs | 9 +- server/src/worktree/sweep.rs | 8 +- 25 files changed, 501 insertions(+), 97 deletions(-) diff --git a/server/src/agent_mode/mod.rs b/server/src/agent_mode/mod.rs index 89af8d9c..e24f8d16 100644 --- a/server/src/agent_mode/mod.rs +++ b/server/src/agent_mode/mod.rs @@ -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 { diff --git a/server/src/agents/lifecycle.rs b/server/src/agents/lifecycle.rs index 8835c1af..ca054704 100644 --- a/server/src/agents/lifecycle.rs +++ b/server/src/agents/lifecycle.rs @@ -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] diff --git a/server/src/agents/pool/pipeline/advance/mod.rs b/server/src/agents/pool/pipeline/advance/mod.rs index c7628c31..fdc596d6 100644 --- a/server/src/agents/pool/pipeline/advance/mod.rs +++ b/server/src/agents/pool/pipeline/advance/mod.rs @@ -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(); diff --git a/server/src/chat/commands/status/mod.rs b/server/src/chat/commands/status/mod.rs index fde36507..af439361 100644 --- a/server/src/chat/commands/status/mod.rs +++ b/server/src/chat/commands/status/mod.rs @@ -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; diff --git a/server/src/chat/commands/status/render.rs b/server/src/chat/commands/status/render.rs index f5f67030..55ea8b77 100644 --- a/server/src/chat/commands/status/render.rs +++ b/server/src/chat/commands/status/render.rs @@ -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 diff --git a/server/src/chat/commands/status/tests.rs b/server/src/chat/commands/status/tests.rs index afc9202b..285cdbfe 100644 --- a/server/src/chat/commands/status/tests.rs +++ b/server/src/chat/commands/status/tests.rs @@ -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") + ); +} diff --git a/server/src/chat/transport/matrix/delete.rs b/server/src/chat/transport/matrix/delete.rs index 63a09554..a9404dc3 100644 --- a/server/src/chat/transport/matrix/delete.rs +++ b/server/src/chat/transport/matrix/delete.rs @@ -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", } } diff --git a/server/src/crdt_state/mod.rs b/server/src/crdt_state/mod.rs index f84462b3..dcfd2bcd 100644 --- a/server/src/crdt_state/mod.rs +++ b/server/src/crdt_state/mod.rs @@ -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)] diff --git a/server/src/crdt_state/read.rs b/server/src/crdt_state/read.rs index bd522aa7..5efa93d1 100644 --- a/server/src/crdt_state/read.rs +++ b/server/src/crdt_state/read.rs @@ -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 { .. } + ) }) } diff --git a/server/src/crdt_state/types.rs b/server/src/crdt_state/types.rs index 5faefc9a..f3c8ee82 100644 --- a/server/src/crdt_state/types.rs +++ b/server/src/crdt_state/types.rs @@ -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, } diff --git a/server/src/crdt_state/write/item.rs b/server/src/crdt_state/write/item.rs index 9a4d05a6..7a6f9934 100644 --- a/server/src/crdt_state/write/item.rs +++ b/server/src/crdt_state/write/item.rs @@ -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. diff --git a/server/src/crdt_state/write/mod.rs b/server/src/crdt_state/write/mod.rs index c5489ad0..27f11243 100644 --- a/server/src/crdt_state/write/mod.rs +++ b/server/src/crdt_state/write/mod.rs @@ -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)] diff --git a/server/src/http/mcp/merge_tools.rs b/server/src/http/mcp/merge_tools.rs index fd32dde6..7109050e 100644 --- a/server/src/http/mcp/merge_tools.rs +++ b/server/src/http/mcp/merge_tools.rs @@ -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(); diff --git a/server/src/http/workflow/pipeline.rs b/server/src/http/workflow/pipeline.rs index 7358c90e..fe8e2ab9 100644 --- a/server/src/http/workflow/pipeline.rs +++ b/server/src/http/workflow/pipeline.rs @@ -62,6 +62,8 @@ pub struct PipelineState { pub qa: Vec, pub merge: Vec, pub done: Vec, + /// Abandoned, superseded, and rejected items (story 984). + pub closed: Vec, /// Story IDs that currently have a deterministic merge in progress. pub deterministic_merges_in_flight: Vec, } @@ -101,6 +103,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result { 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 { } } 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 { 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) } diff --git a/server/src/io/watcher/mod.rs b/server/src/io/watcher/mod.rs index b2dc5ee9..fb56d2f3 100644 --- a/server/src/io/watcher/mod.rs +++ b/server/src/io/watcher/mod.rs @@ -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}")), } } diff --git a/server/src/pipeline_state/apply.rs b/server/src/pipeline_state/apply.rs index 4a968b6f..c39cb7da 100644 --- a/server/src/pipeline_state/apply.rs +++ b/server/src/pipeline_state/apply.rs @@ -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, diff --git a/server/src/pipeline_state/subscribers.rs b/server/src/pipeline_state/subscribers.rs index 0367695c..e8c0bad3 100644 --- a/server/src/pipeline_state/subscribers.rs +++ b/server/src/pipeline_state/subscribers.rs @@ -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, diff --git a/server/src/pipeline_state/tests.rs b/server/src/pipeline_state/tests.rs index 067b6e3f..56e35a7d 100644 --- a/server/src/pipeline_state/tests.rs +++ b/server/src/pipeline_state/tests.rs @@ -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] diff --git a/server/src/pipeline_state/transition.rs b/server/src/pipeline_state/transition.rs index a0c62c72..a956147d 100644 --- a/server/src/pipeline_state/transition.rs +++ b/server/src/pipeline_state/transition.rs @@ -249,29 +249,23 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result 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 diff --git a/server/src/pipeline_state/types.rs b/server/src/pipeline_state/types.rs index 2926abbc..a25ab058 100644 --- a/server/src/pipeline_state/types.rs +++ b/server/src/pipeline_state/types.rs @@ -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, 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 }, + + /// 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, + 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, 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::::UNIX_EPOCH, reason: ArchiveReason::Completed, }), + "abandoned" => Some(Stage::Abandoned { + ts: DateTime::::UNIX_EPOCH, + }), + "superseded" => Some(Stage::Superseded { + ts: DateTime::::UNIX_EPOCH, + superseded_by: StoryId(String::new()), + }), + "rejected" => Some(Stage::Rejected { + ts: DateTime::::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", } } diff --git a/server/src/service/notifications/format.rs b/server/src/service/notifications/format.rs index 7e323836..80d647e7 100644 --- a/server/src/service/notifications/format.rs +++ b/server/src/service/notifications/format.rs @@ -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", } } diff --git a/server/src/service/timer/io.rs b/server/src/service/timer/io.rs index 8d24a8da..d82ff5b9 100644 --- a/server/src/service/timer/io.rs +++ b/server/src/service/timer/io.rs @@ -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", diff --git a/server/src/service/ws/message/convert.rs b/server/src/service/ws/message/convert.rs index c68b1a57..070ba58a 100644 --- a/server/src/service/ws/message/convert.rs +++ b/server/src/service/ws/message/convert.rs @@ -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(); diff --git a/server/src/startup/tick_loop.rs b/server/src/startup/tick_loop.rs index 7f0d5158..3410fc28 100644 --- a/server/src/startup/tick_loop.rs +++ b/server/src/startup/tick_loop.rs @@ -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 { diff --git a/server/src/worktree/sweep.rs b/server/src/worktree/sweep.rs index 9947c7d2..7af28312 100644 --- a/server/src/worktree/sweep.rs +++ b/server/src/worktree/sweep.rs @@ -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, } }