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 {