huskies: merge 1085

This commit is contained in:
dave
2026-05-15 01:32:34 +00:00
parent 56179d712e
commit b053f14d58
11 changed files with 440 additions and 132 deletions
+58 -58
View File
@@ -2,37 +2,30 @@
use crate::agents::{AgentPool, AgentStatus};
use crate::config::ProjectConfig;
use crate::pipeline_state::{ArchiveReason, PipelineItem, Stage};
use crate::pipeline_state::{ArchiveReason, Pipeline, PipelineItem, Stage, Status};
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.
/// This routes through [`Stage::pipeline`] so chat output and the web UI use
/// the same column derivation. Frozen stories appear in their underlying
/// `resume_to` column (handled inside `Stage::pipeline`) and items in
/// `Stage::Archived` (with non-Blocked reasons) stay hidden.
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::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } => {
Some("Closed")
}
Stage::Archived { .. } => None, // Completed/MergeFailed/ReviewHeld stay hidden
// Archived items with non-Blocked reasons are hidden from chat output.
if matches!(s, Stage::Archived { reason, .. } if !matches!(reason, ArchiveReason::Blocked { .. }))
{
return None;
}
Some(match s.pipeline() {
Pipeline::Backlog => "Backlog",
Pipeline::Coding => "In Progress",
Pipeline::Qa => "QA",
Pipeline::Merge => "Merge",
Pipeline::Done => "Done",
Pipeline::Closed => "Closed",
Pipeline::Archived => return None,
})
}
/// Check which dependency numbers from `item.depends_on` are unmet.
@@ -114,10 +107,10 @@ pub(crate) fn build_status_from_items(
let config = ProjectConfig::load(project_root).ok();
// Pre-fetch working tree state for all Coding-stage items whose worktrees exist.
// Pre-fetch working tree state for all Coding-column items whose worktrees exist.
let dirty_files_by_story: HashMap<String, crate::service::git_ops::DirtyFiles> = items
.iter()
.filter(|i| matches!(i.stage, Stage::Coding { .. }))
.filter(|i| i.stage.pipeline() == Pipeline::Coding && i.stage.status() == Status::Active)
.filter_map(|i| {
let wt = crate::worktree::worktree_path(project_root, &i.story_id.0);
if wt.is_dir() {
@@ -137,10 +130,13 @@ pub(crate) fn build_status_from_items(
.into_iter()
.collect();
// Merge-failure detail now lives on the typed MergeJob CRDT entry
// (story 929 — CRDT is the sole source of metadata).
// (story 929 — CRDT is the sole source of metadata). Only items in the
// Merge column with an Active status (i.e. `Stage::Merge { .. }`) need a
// pre-fetched failure snippet; MergeFailure(Final) items render their
// own snippet from the typed kind.
let merge_failures: HashMap<String, String> = items
.iter()
.filter(|i| matches!(i.stage, Stage::Merge { .. }))
.filter(|i| i.stage.pipeline() == Pipeline::Merge && i.stage.status() == Status::Active)
.filter_map(|i| {
let job = crate::crdt_state::read_merge_job(&i.story_id.0)?;
let err = job.error?;
@@ -260,8 +256,10 @@ fn render_item_line(
} else {
Some(item.name.as_str())
};
// Use the typed CRDT stage as the sole source of truth (story 945).
let frozen = matches!(item.stage, Stage::Frozen { .. });
// Use the new Pipeline + Status helpers (story 1085).
let pipeline = item.stage.pipeline();
let status = item.stage.status();
let frozen = status == Status::Frozen;
let base_label = super::story_short_label(story_id, name_opt);
let display = if frozen {
format!("\u{2744}\u{FE0F} {base_label}") // ❄️ prefix
@@ -282,41 +280,52 @@ fn render_item_line(
format!(" *(waiting on: {})*", nums.join(", "))
};
// Closed-stage items (abandoned / superseded / rejected) each get a
// Closed-pipeline items (abandoned / superseded / rejected) each get a
// distinct indicator and optionally display their metadata.
match &item.stage {
Stage::Abandoned { .. } => {
match status {
Status::Abandoned => {
return format!(" \u{1F5D1}\u{FE0F} {display}{cost_suffix}\n"); // 🗑️
}
Stage::Superseded { superseded_by, .. } => {
Status::Superseded => {
let superseded_by = match &item.stage {
Stage::Superseded { superseded_by, .. } => superseded_by.0.as_str(),
_ => "",
};
return format!(
" \u{1F500} {display}{cost_suffix} — superseded by {}\n", // 🔀
superseded_by.0
" \u{1F500} {display}{cost_suffix} — superseded by {superseded_by}\n", // 🔀
);
}
Stage::Rejected { reason, .. } => {
Status::Rejected => {
let reason = match &item.stage {
Stage::Rejected { reason, .. } => reason.as_str(),
_ => "",
};
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
// Merge-column 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 { .. }
) {
match &item.stage {
// appear in the Merge column (in-place) and are handled by the same arm.
if pipeline == Pipeline::Merge {
match status {
// MergeFailureFinal: mergemaster already tried and gave up — always ⛔.
Stage::MergeFailureFinal { kind } => {
Status::MergeFailureFinal => {
let kind = match &item.stage {
Stage::MergeFailureFinal { kind } => kind,
_ => unreachable!(),
};
let snippet = first_non_empty_snippet(&kind.display_reason(), 120);
return format!(" \u{26D4} {display}{cost_suffix}{dep_suffix}{snippet}\n");
}
// MergeFailure: a recovery agent may be running or queued.
Stage::MergeFailure { kind, .. } => {
Status::MergeFailure => {
let kind = match &item.stage {
Stage::MergeFailure { kind, .. } => kind,
_ => unreachable!(),
};
return match agent.map(|a| &a.status) {
Some(AgentStatus::Running) => format!(
" \u{1F916} {display}{cost_suffix}{dep_suffix} — mergemaster running\n"
@@ -353,16 +362,7 @@ fn render_item_line(
}
}
let blocked = matches!(
item.stage,
Stage::Blocked { .. }
| Stage::MergeFailure { .. }
| Stage::MergeFailureFinal { .. }
| Stage::Archived {
reason: ArchiveReason::Blocked { .. },
..
}
);
let blocked = status == Status::Blocked;
// Blocked items with a recovery agent get differentiated indicators.
if blocked {
return match agent.map(|a| &a.status) {