diff --git a/frontend/src/api/client/types.ts b/frontend/src/api/client/types.ts index 71448b20..458b92f3 100644 --- a/frontend/src/api/client/types.ts +++ b/frontend/src/api/client/types.ts @@ -60,6 +60,10 @@ export interface PipelineStageItem { review_hold: boolean | null; qa: string | null; depends_on: number[] | null; + /** True when the item is in Stage::Blocked — awaiting human unblock. */ + blocked?: boolean | null; + /** True when the item is in Stage::Frozen — paused at its current stage. */ + frozen?: boolean | null; } /** Snapshot of all pipeline stages returned via WebSocket or REST. */ diff --git a/frontend/src/components/StagePanel.tsx b/frontend/src/components/StagePanel.tsx index 699fcb81..422ae881 100644 --- a/frontend/src/components/StagePanel.tsx +++ b/frontend/src/components/StagePanel.tsx @@ -396,6 +396,44 @@ export function StagePanel({ {typeLabel} )} + {item.blocked && !item.merge_failure && ( + + ⊘ BLOCKED + + )} + {item.frozen && ( + + ❄ FROZEN + + )} {costs?.has(item.story_id) && ( Option<&'static str> { + match s { + Stage::Upcoming | Stage::Backlog => Some("Backlog"), + Stage::Coding + | Stage::Blocked { .. } + | Stage::Archived { + reason: ArchiveReason::Blocked { .. }, + .. + } => Some("In Progress"), + Stage::Qa | Stage::ReviewHold { .. } => Some("QA"), + Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. } => { + Some("Merge") + } + Stage::Done { .. } => Some("Done"), + Stage::Frozen { resume_to } => display_section(resume_to), + Stage::Archived { .. } => None, // other archived variants are hidden + } +} + /// Check which dependency numbers from `item.depends_on` are unmet. /// /// A dependency is considered met if the dep is in `Done` or `Archived` stage @@ -95,26 +122,24 @@ pub(crate) fn build_status_from_items( let mut out = String::from("**Pipeline Status**\n\n"); - // Active pipeline stages to display (Archived is handled separately below). - type StagePredicate = fn(&Stage) -> bool; - let stage_filters: &[(&str, StagePredicate)] = &[ - ("Backlog", |s| matches!(s, Stage::Backlog)), - ("In Progress", |s| matches!(s, Stage::Coding)), - ("QA", |s| matches!(s, Stage::Qa)), - ("Merge", |s| matches!(s, Stage::Merge { .. })), - ("Done", |s| matches!(s, Stage::Done { .. })), - ]; + // Render each display section in order. Blocked items appear in-place + // 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"]; - for (label, filter) in stage_filters { - let mut stage_items: Vec<&PipelineItem> = - items.iter().filter(|i| filter(&i.stage)).collect(); - stage_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0)); - let count = stage_items.len(); + for label in sections { + let mut section_items: Vec<&PipelineItem> = items + .iter() + .filter(|i| display_section(&i.stage) == Some(label)) + .collect(); + section_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0)); + let count = section_items.len(); out.push_str(&format!("**{label}** ({count})\n")); - if stage_items.is_empty() { + if section_items.is_empty() { out.push_str(" *(none)*\n"); } else { - for item in &stage_items { + for item in §ion_items { out.push_str(&render_item_line( item, items, @@ -129,26 +154,6 @@ pub(crate) fn build_status_from_items( out.push('\n'); } - // Blocked items: Archived { reason: Blocked } shown with 🔴 indicator. - let mut blocked_items: Vec<&PipelineItem> = - items.iter().filter(|i| i.stage.is_blocked()).collect(); - blocked_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0)); - if !blocked_items.is_empty() { - out.push_str(&format!("**Blocked** ({})\n", blocked_items.len())); - for item in &blocked_items { - out.push_str(&render_item_line( - item, - items, - &active_map, - &cost_by_story, - &config, - &running_merges, - &merge_failures, - )); - } - out.push('\n'); - } - // Free agents: configured agents not currently running or pending. out.push_str("**Free Agents**\n"); if let Some(cfg) = &config { @@ -198,7 +203,8 @@ fn render_item_line( } else { Some(item.name.as_str()) }; - let frozen = crate::io::story_metadata::is_story_frozen_in_store(story_id); + // Use the typed CRDT stage as the sole source of truth (story 945). + let frozen = item.stage.is_frozen(); let base_label = super::story_short_label(story_id, name_opt); let display = if frozen { format!("\u{2744}\u{FE0F} {base_label}") // ❄️ prefix @@ -219,9 +225,24 @@ fn render_item_line( format!(" *(waiting on: {})*", nums.join(", ")) }; - // Merge-stage items get a dedicated breakdown indicator instead of the - // generic traffic-light dot. - if matches!(item.stage, Stage::Merge { .. }) { + // 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 + // here alongside normal Merge items. + if matches!( + item.stage, + Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. } + ) { + // MergeFailure and MergeFailureFinal carry their reason directly on + // the stage variant — always show ⛔ with the failure snippet. + match &item.stage { + Stage::MergeFailure { reason, .. } | Stage::MergeFailureFinal { reason } => { + let snippet = first_non_empty_snippet(reason, 120); + return format!(" \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n"); + } + _ => {} + } + let in_det_merge = running_merges.contains(story_id); let merge_failure = merge_failures.get(story_id); if in_det_merge { diff --git a/server/src/chat/commands/status/tests.rs b/server/src/chat/commands/status/tests.rs index c7f83ef7..c8d85be9 100644 --- a/server/src/chat/commands/status/tests.rs +++ b/server/src/chat/commands/status/tests.rs @@ -682,3 +682,257 @@ fn snippet_skips_leading_empty_lines() { let result = first_non_empty_snippet(text, 120); assert_eq!(result, "actual error here"); } + +// -- AC1: blocked items appear in-place, not in a separate "Blocked" section -- + +#[test] +fn blocked_item_appears_in_in_progress_not_separate_section() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + + let items = vec![make_item( + "42_story_blocked", + "Blocked Story", + Stage::Blocked { + reason: "retry limit exceeded".to_string(), + }, + )]; + + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(tmp.path(), &agents, &items); + + // Must NOT appear under a separate "Blocked" section. + assert!( + !output.contains("**Blocked**"), + "output must not have a separate Blocked section: {output}" + ); + + // Must appear under "In Progress". + let in_progress_pos = output + .find("**In Progress**") + .expect("In Progress section must exist"); + let qa_pos = output.find("**QA**").expect("QA section must exist"); + let story_pos = output + .find("42 [story]") + .expect("story must appear in output"); + + assert!( + story_pos > in_progress_pos && story_pos < qa_pos, + "blocked story should be in In Progress section: in_progress={in_progress_pos} story={story_pos} qa={qa_pos}\n{output}" + ); +} + +#[test] +fn blocked_item_shows_red_dot_in_in_progress_section() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + + let items = vec![make_item( + "50_story_blocked_dot", + "Blocked With Dot", + Stage::Blocked { + reason: "too many failures".to_string(), + }, + )]; + + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(tmp.path(), &agents, &items); + + assert!( + output.contains("\u{1F534} "), // 🔴 + "blocked story should show red circle emoji: {output}" + ); +} + +// -- AC2: stage counts include blocked items -- + +#[test] +fn in_progress_count_includes_blocked_items() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + + let items = vec![ + make_item("10_story_coding", "Coding Story", Stage::Coding), + make_item( + "11_story_blocked", + "Blocked Story", + Stage::Blocked { + reason: "failed".to_string(), + }, + ), + ]; + + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(tmp.path(), &agents, &items); + + // "In Progress" header should show count of 2 (one coding + one blocked). + assert!( + output.contains("**In Progress** (2)"), + "In Progress count should include blocked items: {output}" + ); +} + +// -- AC4: frozen items appear in-place, with ❄️ indicator -- + +#[test] +fn frozen_coding_item_appears_in_in_progress_section() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + + let items = vec![make_item( + "60_story_frozen", + "Frozen Coding Story", + Stage::Frozen { + resume_to: Box::new(Stage::Coding), + }, + )]; + + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(tmp.path(), &agents, &items); + + let in_progress_pos = output + .find("**In Progress**") + .expect("In Progress section must exist"); + let qa_pos = output.find("**QA**").expect("QA section must exist"); + let story_pos = output + .find("60 [story]") + .expect("story must appear in output"); + + assert!( + story_pos > in_progress_pos && story_pos < qa_pos, + "frozen-coding story should appear in In Progress: in_progress={in_progress_pos} story={story_pos} qa={qa_pos}\n{output}" + ); +} + +#[test] +fn frozen_qa_item_appears_in_qa_section() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + + let items = vec![make_item( + "70_story_frozen_qa", + "Frozen QA Story", + Stage::Frozen { + resume_to: Box::new(Stage::Qa), + }, + )]; + + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(tmp.path(), &agents, &items); + + let qa_pos = output.find("**QA**").expect("QA section must exist"); + let merge_pos = output.find("**Merge**").expect("Merge section must exist"); + let story_pos = output + .find("70 [story]") + .expect("story must appear in output"); + + assert!( + story_pos > qa_pos && story_pos < merge_pos, + "frozen-QA story should appear in QA section: qa={qa_pos} story={story_pos} merge={merge_pos}\n{output}" + ); +} + +#[test] +fn frozen_item_shows_snowflake_indicator() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + + let items = vec![make_item( + "80_story_frozen_flake", + "Frozen Flake Story", + Stage::Frozen { + resume_to: Box::new(Stage::Coding), + }, + )]; + + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(tmp.path(), &agents, &items); + + assert!( + output.contains("\u{2744}\u{FE0F}"), // ❄️ + "frozen story should show snowflake prefix: {output}" + ); +} + +#[test] +fn frozen_and_blocked_use_distinct_indicators() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + + let items = vec![ + make_item( + "90_story_blocked_ind", + "Blocked Story", + Stage::Blocked { + reason: "failed".to_string(), + }, + ), + make_item( + "91_story_frozen_ind", + "Frozen Story", + Stage::Frozen { + resume_to: Box::new(Stage::Coding), + }, + ), + ]; + + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(tmp.path(), &agents, &items); + + assert!( + output.contains("\u{1F534} "), // 🔴 for blocked + "blocked story should show red dot: {output}" + ); + assert!( + output.contains("\u{2744}\u{FE0F}"), // ❄️ for frozen + "frozen story should show snowflake: {output}" + ); +} + +// -- merge-failure items appear in Merge section -- + +#[test] +fn merge_failure_item_appears_in_merge_section_not_blocked() { + use crate::pipeline_state::BranchName; + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + + let items = vec![make_item( + "100_story_merge_fail", + "Merge Failure Story", + Stage::MergeFailure { + reason: "conflict in lib.rs".to_string(), + feature_branch: BranchName("feature/100".to_string()), + commits_ahead: std::num::NonZeroU32::new(1).unwrap(), + }, + )]; + + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(tmp.path(), &agents, &items); + + // Must not be in a separate "Blocked" section. + assert!( + !output.contains("**Blocked**"), + "output must not have a separate Blocked section: {output}" + ); + + let merge_pos = output.find("**Merge**").expect("Merge section must exist"); + let done_pos = output.find("**Done**").expect("Done section must exist"); + let story_pos = output + .find("100 [story]") + .expect("story must appear in output"); + + assert!( + story_pos > merge_pos && story_pos < done_pos, + "merge-failure story should appear in Merge section: merge={merge_pos} story={story_pos} done={done_pos}\n{output}" + ); + + assert!( + output.contains("\u{26D4}"), // ⛔ + "merge failure should show stop sign: {output}" + ); + assert!( + output.contains("conflict in lib.rs"), + "merge failure reason should be shown: {output}" + ); +} diff --git a/server/src/http/workflow/pipeline.rs b/server/src/http/workflow/pipeline.rs index ff74f4ee..b6abff48 100644 --- a/server/src/http/workflow/pipeline.rs +++ b/server/src/http/workflow/pipeline.rs @@ -33,9 +33,12 @@ pub struct UpcomingStory { /// Number of retries at the current pipeline stage. #[serde(skip_serializing_if = "Option::is_none")] pub retry_count: Option, - /// True when the story has exceeded its retry limit and will not be auto-assigned. + /// True when the item is in `Stage::Blocked` (awaiting human unblock). #[serde(skip_serializing_if = "Option::is_none")] pub blocked: Option, + /// True when the item is in `Stage::Frozen` (paused at its current stage). + #[serde(skip_serializing_if = "Option::is_none")] + pub frozen: Option, /// Story numbers this story depends on. #[serde(skip_serializing_if = "Option::is_none")] pub depends_on: Option>, @@ -63,6 +66,24 @@ pub struct PipelineState { pub deterministic_merges_in_flight: Vec, } +/// Determine which pipeline bucket a frozen item's `resume_to` stage maps to. +/// +/// Mirrors the routing in `load_pipeline_state` for non-frozen items so that +/// a frozen story always appears under the same section it was in before freezing. +fn frozen_resume_bucket(resume_to: &crate::pipeline_state::Stage) -> &'static str { + use crate::pipeline_state::Stage; + match resume_to { + Stage::Upcoming | Stage::Backlog => "backlog", + Stage::Coding | Stage::Blocked { .. } => "current", + Stage::Qa | Stage::ReviewHold { .. } => "qa", + Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. } => { + "merge" + } + Stage::Frozen { resume_to: inner } => frozen_resume_bucket(inner), + _ => "backlog", // Done, Archived → fall back to backlog (should not occur) + } +} + /// Load the full pipeline state (all 5 active stages). /// /// Reads from the CRDT document and enriches with content from the @@ -131,6 +152,11 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result { } else { None }, + frozen: if item.stage.is_frozen() { + Some(true) + } else { + None + }, depends_on: if item.depends_on.is_empty() { None } else { @@ -143,12 +169,6 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result { }, epic_id, }; - // Frozen items (CRDT flag) are routed to the backlog bucket regardless - // of their underlying stage — they're paused, not progressing. - if item.is_frozen() { - state.backlog.push(story); - continue; - } match &item.stage { Stage::Upcoming => state.backlog.push(story), // upcoming shown with backlog Stage::Backlog => state.backlog.push(story), @@ -159,7 +179,16 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result { Stage::MergeFailure { .. } => state.merge.push(story), // show merge failures with merge Stage::MergeFailureFinal { .. } => state.merge.push(story), // mergemaster gave up Stage::ReviewHold { .. } => state.qa.push(story), // review-held shown with QA - Stage::Frozen { .. } => state.backlog.push(story), // paused, route to backlog + Stage::Frozen { resume_to } => { + // Route to the section matching the stage that was active when + // the item was frozen, so it appears in-place. + match frozen_resume_bucket(resume_to) { + "current" => state.current.push(story), + "qa" => state.qa.push(story), + "merge" => state.merge.push(story), + _ => state.backlog.push(story), + } + } Stage::Done { .. } => state.done.push(story), Stage::Archived { .. } => {} // skip archived } @@ -244,6 +273,11 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result, St } else { None }, + frozen: if item.stage.is_frozen() { + Some(true) + } else { + None + }, depends_on: if item.depends_on.is_empty() { None } else { diff --git a/server/src/service/ws/message/convert.rs b/server/src/service/ws/message/convert.rs index b5aafed9..f7be11a0 100644 --- a/server/src/service/ws/message/convert.rs +++ b/server/src/service/ws/message/convert.rs @@ -214,6 +214,7 @@ mod tests { qa: None, retry_count: None, blocked: None, + frozen: None, depends_on: None, epic_id: None, }], @@ -227,6 +228,7 @@ mod tests { qa: None, retry_count: None, blocked: None, + frozen: None, depends_on: None, epic_id: None, }], @@ -242,6 +244,7 @@ mod tests { qa: None, retry_count: None, blocked: None, + frozen: None, depends_on: None, epic_id: None, }], @@ -298,6 +301,7 @@ mod tests { qa: None, retry_count: None, blocked: None, + frozen: None, depends_on: None, epic_id: None, }], diff --git a/server/src/service/ws/message/response.rs b/server/src/service/ws/message/response.rs index 7a15450b..b4983ca0 100644 --- a/server/src/service/ws/message/response.rs +++ b/server/src/service/ws/message/response.rs @@ -209,6 +209,7 @@ mod tests { qa: None, retry_count: None, blocked: None, + frozen: None, depends_on: None, epic_id: None, };