huskies: merge 895

This commit is contained in:
dave
2026-05-13 08:48:36 +00:00
parent 4a8ed4348b
commit 6bd11d41f9
7 changed files with 405 additions and 49 deletions
+62 -41
View File
@@ -2,9 +2,36 @@
use crate::agents::{AgentPool, AgentStatus};
use crate::config::ProjectConfig;
use crate::pipeline_state::{PipelineItem, Stage};
use crate::pipeline_state::{ArchiveReason, PipelineItem, Stage};
use std::collections::{HashMap, HashSet};
/// Map a stage to its display section label, or `None` to skip it entirely.
///
/// This is the single source of truth for the "where does this item appear"
/// decision. It mirrors the bucket routing in `http/workflow/pipeline.rs`
/// so that chat output and the web UI are always consistent.
///
/// `Stage::Frozen { resume_to }` is handled recursively: a frozen story
/// appears in the same section its `resume_to` stage would land in.
pub(crate) fn display_section(s: &Stage) -> 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 &section_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 {
+254
View File
@@ -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}"
);
}
+42 -8
View File
@@ -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<u32>,
/// 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<bool>,
/// True when the item is in `Stage::Frozen` (paused at its current stage).
#[serde(skip_serializing_if = "Option::is_none")]
pub frozen: Option<bool>,
/// Story numbers this story depends on.
#[serde(skip_serializing_if = "Option::is_none")]
pub depends_on: Option<Vec<u32>>,
@@ -63,6 +66,24 @@ pub struct PipelineState {
pub deterministic_merges_in_flight: Vec<String>,
}
/// 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<PipelineState, String> {
} 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<PipelineState, String> {
},
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<PipelineState, String> {
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<Vec<UpcomingStory>, St
} else {
None
},
frozen: if item.stage.is_frozen() {
Some(true)
} else {
None
},
depends_on: if item.depends_on.is_empty() {
None
} else {
+4
View File
@@ -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,
}],
@@ -209,6 +209,7 @@ mod tests {
qa: None,
retry_count: None,
blocked: None,
frozen: None,
depends_on: None,
epic_id: None,
};