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,
};