huskies: merge 1085
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -51,6 +51,8 @@ pub(crate) fn tool_get_pipeline_status(ctx: &AppContext) -> Result<String, Strin
|
||||
"story_id": s.story_id,
|
||||
"name": slim_name(&s.name),
|
||||
"stage": stage,
|
||||
"pipeline": s.pipeline.as_str(),
|
||||
"status": s.status.as_str(),
|
||||
"agent": s.agent.as_ref().map(|a| json!({
|
||||
"agent_name": a.agent_name,
|
||||
"model": a.model,
|
||||
@@ -83,7 +85,15 @@ pub(crate) fn tool_get_pipeline_status(ctx: &AppContext) -> Result<String, Strin
|
||||
let archived: Vec<Value> = state
|
||||
.archived
|
||||
.iter()
|
||||
.map(|s| json!({ "story_id": s.story_id, "name": slim_name(&s.name), "stage": "archived" }))
|
||||
.map(|s| {
|
||||
json!({
|
||||
"story_id": s.story_id,
|
||||
"name": slim_name(&s.name),
|
||||
"stage": "archived",
|
||||
"pipeline": s.pipeline.as_str(),
|
||||
"status": s.status.as_str(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
|
||||
@@ -24,6 +24,10 @@ pub struct UpcomingStory {
|
||||
pub merge_failure: Option<String>,
|
||||
/// Active agent working on this item, if any.
|
||||
pub agent: Option<AgentAssignment>,
|
||||
/// Display column (story 1085) — derived from `Stage::pipeline()`.
|
||||
pub pipeline: crate::pipeline_state::Pipeline,
|
||||
/// Display badge/indicator (story 1085) — derived from `Stage::status()`.
|
||||
pub status: crate::pipeline_state::Status,
|
||||
/// True when the item is held in QA for human review.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub review_hold: Option<bool>,
|
||||
@@ -142,6 +146,8 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
||||
error: None,
|
||||
merge_failure,
|
||||
agent,
|
||||
pipeline: item.stage.pipeline(),
|
||||
status: item.stage.status(),
|
||||
review_hold,
|
||||
qa,
|
||||
retry_count: if item.retry_count() > 0 {
|
||||
@@ -278,6 +284,8 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result<Vec<UpcomingStory>, St
|
||||
error: None,
|
||||
merge_failure: None,
|
||||
agent: None,
|
||||
pipeline: item.stage.pipeline(),
|
||||
status: item.stage.status(),
|
||||
review_hold: None,
|
||||
qa: None,
|
||||
retry_count: if item_retry_count > 0 {
|
||||
|
||||
@@ -41,8 +41,8 @@ mod tests;
|
||||
#[allow(unused_imports)]
|
||||
pub use types::{
|
||||
AgentClaim, AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind,
|
||||
NodePubkey, PipelineItem, PlanState, Stage, StoryId, TransitionError, stage_dir_name,
|
||||
stage_label,
|
||||
NodePubkey, Pipeline, PipelineItem, PlanState, Stage, Status, StoryId, TransitionError,
|
||||
stage_dir_name, stage_label,
|
||||
};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
|
||||
@@ -429,6 +429,144 @@ impl Stage {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Display split (story 1085): Pipeline column + Status badge ─────────────
|
||||
|
||||
/// Column placement for a work item in the UI/chat status display.
|
||||
///
|
||||
/// Derived from [`Stage`] via [`Stage::pipeline`]. Display callers route items
|
||||
/// to columns by this enum instead of pattern-matching `Stage` variants, so
|
||||
/// new badges (e.g. `Frozen`, `Blocked`) do not produce new columns.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Pipeline {
|
||||
/// Items in `Upcoming` or `Backlog` stages.
|
||||
Backlog,
|
||||
/// Items being coded (or blocked while in the coding lane).
|
||||
Coding,
|
||||
/// Items in QA or `ReviewHold`.
|
||||
Qa,
|
||||
/// Items in `Merge`, `MergeFailure`, or `MergeFailureFinal`.
|
||||
Merge,
|
||||
/// Items in `Done`.
|
||||
Done,
|
||||
/// Abandoned, superseded, or rejected items.
|
||||
Closed,
|
||||
/// Items swept into `Archived`.
|
||||
Archived,
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
/// Stable wire-format identifier (kebab-case).
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Pipeline::Backlog => "backlog",
|
||||
Pipeline::Coding => "coding",
|
||||
Pipeline::Qa => "qa",
|
||||
Pipeline::Merge => "merge",
|
||||
Pipeline::Done => "done",
|
||||
Pipeline::Closed => "closed",
|
||||
Pipeline::Archived => "archived",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Badge/indicator for a work item, orthogonal to its [`Pipeline`] column.
|
||||
///
|
||||
/// Derived from [`Stage`] via [`Stage::status`]. A `Frozen` story stays in
|
||||
/// its underlying `Pipeline` column (e.g. `Coding`) and is decorated with
|
||||
/// `Status::Frozen` for the display. `Status::Done` is reserved for items in
|
||||
/// the `Done` column and is never produced for items still in flight, so a
|
||||
/// done item never carries a `MergeFailure*` badge (story 1052).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", tag = "kind")]
|
||||
pub enum Status {
|
||||
/// No special badge — normal in-progress item.
|
||||
Active,
|
||||
/// Item is paused (`Stage::Frozen`).
|
||||
Frozen,
|
||||
/// Item is held for human review (`Stage::ReviewHold`).
|
||||
ReviewHold,
|
||||
/// Item is blocked (`Stage::Blocked` or legacy `Archived(Blocked)`).
|
||||
Blocked,
|
||||
/// Merge failed; mergemaster may still be recovering.
|
||||
MergeFailure,
|
||||
/// Merge failed beyond automatic recovery.
|
||||
MergeFailureFinal,
|
||||
/// User abandoned the item.
|
||||
Abandoned,
|
||||
/// Item was superseded by another work item.
|
||||
Superseded,
|
||||
/// Item was permanently rejected.
|
||||
Rejected,
|
||||
/// Item completed successfully.
|
||||
Done,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// Stable wire-format identifier (kebab-case).
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Status::Active => "active",
|
||||
Status::Frozen => "frozen",
|
||||
Status::ReviewHold => "review-hold",
|
||||
Status::Blocked => "blocked",
|
||||
Status::MergeFailure => "merge-failure",
|
||||
Status::MergeFailureFinal => "merge-failure-final",
|
||||
Status::Abandoned => "abandoned",
|
||||
Status::Superseded => "superseded",
|
||||
Status::Rejected => "rejected",
|
||||
Status::Done => "done",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Stage {
|
||||
/// Display column for this stage. `Frozen { resume_to }` recurses so a
|
||||
/// paused story keeps its underlying column.
|
||||
pub fn pipeline(&self) -> Pipeline {
|
||||
match self {
|
||||
Stage::Upcoming | Stage::Backlog => Pipeline::Backlog,
|
||||
Stage::Coding { .. } | Stage::Blocked { .. } => Pipeline::Coding,
|
||||
Stage::Qa | Stage::ReviewHold { .. } => Pipeline::Qa,
|
||||
Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. } => {
|
||||
Pipeline::Merge
|
||||
}
|
||||
Stage::Frozen { resume_to } => resume_to.pipeline(),
|
||||
Stage::Done { .. } => Pipeline::Done,
|
||||
Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } => {
|
||||
Pipeline::Closed
|
||||
}
|
||||
Stage::Archived {
|
||||
reason: ArchiveReason::Blocked { .. },
|
||||
..
|
||||
} => Pipeline::Coding,
|
||||
Stage::Archived { .. } => Pipeline::Archived,
|
||||
}
|
||||
}
|
||||
|
||||
/// Display badge for this stage. `Frozen { resume_to }` returns
|
||||
/// `Status::Frozen` regardless of the inner stage; callers wanting the
|
||||
/// underlying badge inspect `resume_to` directly.
|
||||
pub fn status(&self) -> Status {
|
||||
match self {
|
||||
Stage::Frozen { .. } => Status::Frozen,
|
||||
Stage::ReviewHold { .. } => Status::ReviewHold,
|
||||
Stage::Blocked { .. }
|
||||
| Stage::Archived {
|
||||
reason: ArchiveReason::Blocked { .. },
|
||||
..
|
||||
} => Status::Blocked,
|
||||
Stage::MergeFailure { .. } => Status::MergeFailure,
|
||||
Stage::MergeFailureFinal { .. } => Status::MergeFailureFinal,
|
||||
Stage::Abandoned { .. } => Status::Abandoned,
|
||||
Stage::Superseded { .. } => Status::Superseded,
|
||||
Stage::Rejected { .. } => Status::Rejected,
|
||||
Stage::Done { .. } => Status::Done,
|
||||
_ => Status::Active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-node execution state ────────────────────────────────────────────────
|
||||
|
||||
/// Per-node execution tracking, stored in the CRDT under each node's pubkey.
|
||||
|
||||
@@ -212,6 +212,8 @@ mod tests {
|
||||
error: None,
|
||||
merge_failure: None,
|
||||
agent: None,
|
||||
pipeline: crate::pipeline_state::Pipeline::Backlog,
|
||||
status: crate::pipeline_state::Status::Active,
|
||||
review_hold: None,
|
||||
qa: None,
|
||||
retry_count: None,
|
||||
@@ -226,6 +228,8 @@ mod tests {
|
||||
error: None,
|
||||
merge_failure: None,
|
||||
agent: None,
|
||||
pipeline: crate::pipeline_state::Pipeline::Coding,
|
||||
status: crate::pipeline_state::Status::Active,
|
||||
review_hold: None,
|
||||
qa: None,
|
||||
retry_count: None,
|
||||
@@ -242,6 +246,8 @@ mod tests {
|
||||
error: None,
|
||||
merge_failure: None,
|
||||
agent: None,
|
||||
pipeline: crate::pipeline_state::Pipeline::Done,
|
||||
status: crate::pipeline_state::Status::Done,
|
||||
review_hold: None,
|
||||
qa: None,
|
||||
retry_count: None,
|
||||
@@ -303,6 +309,8 @@ mod tests {
|
||||
model: Some(crate::agents::AgentModel::Sonnet),
|
||||
status: crate::agents::AgentStatus::Running,
|
||||
}),
|
||||
pipeline: crate::pipeline_state::Pipeline::Coding,
|
||||
status: crate::pipeline_state::Status::Active,
|
||||
review_hold: None,
|
||||
qa: None,
|
||||
retry_count: None,
|
||||
|
||||
@@ -205,6 +205,8 @@ mod tests {
|
||||
error: None,
|
||||
merge_failure: None,
|
||||
agent: None,
|
||||
pipeline: crate::pipeline_state::Pipeline::Backlog,
|
||||
status: crate::pipeline_state::Status::Active,
|
||||
review_hold: None,
|
||||
qa: None,
|
||||
retry_count: None,
|
||||
|
||||
Reference in New Issue
Block a user