huskies: merge 835
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
//! Short label and traffic-light indicator helpers for pipeline status display.
|
||||
|
||||
/// Format a short display label for a work item.
|
||||
///
|
||||
/// Extracts the leading numeric ID and optional type tag from the file stem
|
||||
/// (e.g. `"293"` and `"story"` from `"293_story_register_all_bot_commands"`)
|
||||
/// and combines them with the human-readable name from the front matter when
|
||||
/// available. Known types (`story`, `bug`, `spike`, `refactor`) are shown as
|
||||
/// bracketed labels; unknown or missing types are omitted silently.
|
||||
///
|
||||
/// Examples:
|
||||
/// - `("293_story_foo", Some("Register all bot commands"))` → `"293 [story] — Register all bot commands"`
|
||||
/// - `("375_bug_foo", None)` → `"375 [bug]"`
|
||||
/// - `("293_story_foo", None)` → `"293 [story]"`
|
||||
/// - `("no_number_here", None)` → `"no_number_here"`
|
||||
pub(crate) fn story_short_label(stem: &str, name: Option<&str>) -> String {
|
||||
let mut parts = stem.splitn(3, '_');
|
||||
let first = parts.next().unwrap_or(stem);
|
||||
let (number, type_label) = if !first.is_empty() && first.chars().all(|c| c.is_ascii_digit()) {
|
||||
let t = parts.next().and_then(|t| match t {
|
||||
"story" | "bug" | "spike" | "refactor" => Some(t),
|
||||
_ => None,
|
||||
});
|
||||
(first, t)
|
||||
} else {
|
||||
(stem, None)
|
||||
};
|
||||
let prefix = match type_label {
|
||||
Some(t) => format!("{number} [{t}]"),
|
||||
None => number.to_string(),
|
||||
};
|
||||
match name {
|
||||
Some(n) => format!("{prefix} — {n}"),
|
||||
None => prefix,
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose the traffic-light indicator for a work item.
|
||||
///
|
||||
/// Priority: blocked > throttled > running > idle.
|
||||
/// Uses coloured emoji so indicators render natively in all Matrix clients
|
||||
/// (Element X and others do not support `<font data-mx-color>` HTML tags).
|
||||
///
|
||||
/// - 🔴 hard-blocked (retry limit exceeded)
|
||||
/// - 🟠 throttled (rate-limit warning received)
|
||||
/// - 🟢 running normally (active agent, no throttle)
|
||||
/// - ⚪ idle / no active agent
|
||||
pub(crate) fn traffic_light_dot(blocked: bool, throttled: bool, has_agent: bool) -> &'static str {
|
||||
if blocked {
|
||||
"\u{1F534} " // 🔴 — hard blocked
|
||||
} else if throttled {
|
||||
"\u{1F7E0} " // 🟠 — throttled
|
||||
} else if has_agent {
|
||||
"\u{1F7E2} " // 🟢 — running normally
|
||||
} else {
|
||||
"\u{26AA} " // ⚪ — idle / no agent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Handler for the `status` command and pipeline status helpers.
|
||||
|
||||
mod labels;
|
||||
mod render;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(super) use labels::{story_short_label, traffic_light_dot};
|
||||
pub(super) use render::{build_pipeline_status, unmet_deps_from_items};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) use render::{build_status_from_items, first_non_empty_snippet};
|
||||
|
||||
use super::CommandContext;
|
||||
|
||||
/// Dispatch the `status` bot command.
|
||||
///
|
||||
/// With no arguments, renders the full pipeline status. With arguments,
|
||||
/// delegates to the triage handler for per-story details.
|
||||
pub(super) fn handle_status(ctx: &CommandContext) -> Option<String> {
|
||||
if ctx.args.trim().is_empty() {
|
||||
Some(build_pipeline_status(
|
||||
ctx.effective_root(),
|
||||
&ctx.services.agents,
|
||||
))
|
||||
} else {
|
||||
super::triage::handle_triage(ctx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Pipeline rendering: builds the full status text from pipeline items.
|
||||
|
||||
use crate::agents::{AgentPool, AgentStatus};
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::pipeline_state::{PipelineItem, Stage};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Check which dependency numbers from `item.depends_on` are unmet.
|
||||
///
|
||||
/// A dependency is considered met if the dep is in `Done` or `Archived` stage
|
||||
/// in `all_items`. If the dep is not found in `all_items` at all (e.g. it was
|
||||
/// archived before the CRDT migration and has no row), it is treated as met.
|
||||
pub(crate) fn unmet_deps_from_items(item: &PipelineItem, all_items: &[PipelineItem]) -> Vec<u32> {
|
||||
item.depends_on
|
||||
.iter()
|
||||
.filter_map(|dep_id| {
|
||||
// dep_id.0 is the raw number string (e.g. "999") as projected
|
||||
// from PipelineItemView.depends_on: Vec<u32>.
|
||||
let dep_num: u32 = dep_id.0.parse().ok()?;
|
||||
// Find the dep by matching the numeric prefix of its story_id.
|
||||
let dep = all_items.iter().find(|i| {
|
||||
i.story_id.0 == dep_id.0
|
||||
|| i.story_id.0.split('_').next() == Some(dep_id.0.as_str())
|
||||
});
|
||||
match dep {
|
||||
Some(d) if matches!(d.stage, Stage::Done { .. } | Stage::Archived { .. }) => None,
|
||||
Some(_) => Some(dep_num), // Found but not done = unmet
|
||||
None => None, // Not in CRDT; treat as met
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract the first non-empty line from `text`, truncated to `max_len` chars.
|
||||
pub(crate) fn first_non_empty_snippet(text: &str, max_len: usize) -> String {
|
||||
let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
|
||||
let mut chars = line.chars();
|
||||
let truncated: String = chars.by_ref().take(max_len).collect();
|
||||
if chars.next().is_some() {
|
||||
format!("{truncated}…")
|
||||
} else {
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the full pipeline status text formatted for Matrix (markdown).
|
||||
pub(crate) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String {
|
||||
let items = crate::pipeline_state::read_all_typed();
|
||||
build_status_from_items(project_root, agents, &items)
|
||||
}
|
||||
|
||||
/// Inner implementation that accepts pre-loaded items for testability.
|
||||
pub(crate) fn build_status_from_items(
|
||||
project_root: &std::path::Path,
|
||||
agents: &AgentPool,
|
||||
items: &[PipelineItem],
|
||||
) -> String {
|
||||
// Build a map from story_id → active AgentInfo for quick lookup.
|
||||
let active_agents = agents.list_agents().unwrap_or_default();
|
||||
let active_map: HashMap<String, &crate::agents::AgentInfo> = active_agents
|
||||
.iter()
|
||||
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
|
||||
.map(|a| (a.story_id.clone(), a))
|
||||
.collect();
|
||||
|
||||
// Read token usage once for all stories to avoid repeated file I/O.
|
||||
let cost_by_story: HashMap<String, f64> = crate::agents::token_usage::read_all(project_root)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut map, r| {
|
||||
*map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd;
|
||||
map
|
||||
});
|
||||
|
||||
let config = ProjectConfig::load(project_root).ok();
|
||||
|
||||
// Pre-fetch merge-specific state: deterministic merges in flight and
|
||||
// any merge_failure text persisted to the story's front matter.
|
||||
let running_merges: HashSet<String> = agents
|
||||
.list_running_merges()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let merge_failures: HashMap<String, String> = items
|
||||
.iter()
|
||||
.filter(|i| matches!(i.stage, Stage::Merge { .. }))
|
||||
.filter_map(|i| {
|
||||
let content = crate::db::read_content(&i.story_id.0)?;
|
||||
let meta = crate::io::story_metadata::parse_front_matter(&content).ok()?;
|
||||
let mf = meta.merge_failure?;
|
||||
Some((i.story_id.0.clone(), mf))
|
||||
})
|
||||
.collect();
|
||||
|
||||
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 { .. })),
|
||||
];
|
||||
|
||||
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();
|
||||
out.push_str(&format!("**{label}** ({count})\n"));
|
||||
if stage_items.is_empty() {
|
||||
out.push_str(" *(none)*\n");
|
||||
} else {
|
||||
for item in &stage_items {
|
||||
out.push_str(&render_item_line(
|
||||
item,
|
||||
items,
|
||||
&active_map,
|
||||
&cost_by_story,
|
||||
&config,
|
||||
&running_merges,
|
||||
&merge_failures,
|
||||
));
|
||||
}
|
||||
}
|
||||
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 {
|
||||
let busy_names: HashSet<String> = active_agents
|
||||
.iter()
|
||||
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
|
||||
.map(|a| a.agent_name.clone())
|
||||
.collect();
|
||||
|
||||
let free: Vec<String> = cfg
|
||||
.agent
|
||||
.iter()
|
||||
.filter(|a| !busy_names.contains(&a.name))
|
||||
.map(|a| match &a.model {
|
||||
Some(m) => format!("{} ({})", a.name, m),
|
||||
None => a.name.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
if free.is_empty() {
|
||||
out.push_str(" *(none — all agents busy)*\n");
|
||||
} else {
|
||||
for name in &free {
|
||||
out.push_str(&format!(" • {name}\n"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push_str(" *(no agent config found)*\n");
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Render a single status line for one pipeline item.
|
||||
fn render_item_line(
|
||||
item: &PipelineItem,
|
||||
all_items: &[PipelineItem],
|
||||
active_map: &HashMap<String, &crate::agents::AgentInfo>,
|
||||
cost_by_story: &HashMap<String, f64>,
|
||||
config: &Option<ProjectConfig>,
|
||||
running_merges: &HashSet<String>,
|
||||
merge_failures: &HashMap<String, String>,
|
||||
) -> String {
|
||||
let story_id = &item.story_id.0;
|
||||
let name_opt = if item.name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(item.name.as_str())
|
||||
};
|
||||
let frozen = crate::io::story_metadata::is_story_frozen_in_store(story_id);
|
||||
let base_label = super::story_short_label(story_id, name_opt);
|
||||
let display = if frozen {
|
||||
format!("\u{2744}\u{FE0F} {base_label}") // ❄️ prefix
|
||||
} else {
|
||||
base_label
|
||||
};
|
||||
let cost_suffix = cost_by_story
|
||||
.get(story_id)
|
||||
.filter(|&&c| c > 0.0)
|
||||
.map(|c| format!(" — ${c:.2}"))
|
||||
.unwrap_or_default();
|
||||
let agent = active_map.get(story_id);
|
||||
let unmet = unmet_deps_from_items(item, all_items);
|
||||
let dep_suffix = if unmet.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let nums: Vec<String> = unmet.iter().map(|n| n.to_string()).collect();
|
||||
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 { .. }) {
|
||||
let in_det_merge = running_merges.contains(story_id);
|
||||
let merge_failure = merge_failures.get(story_id);
|
||||
if in_det_merge {
|
||||
// A fresh deterministic merge is in progress — always prefer 🔄,
|
||||
// even when a previous attempt recorded a merge_failure.
|
||||
return format!(
|
||||
" \u{1F504} {display}{cost_suffix}{dep_suffix} — deterministic-merge running\n"
|
||||
);
|
||||
} else if agent.is_some() {
|
||||
return format!(
|
||||
" \u{1F916} {display}{cost_suffix}{dep_suffix} — mergemaster running\n"
|
||||
);
|
||||
} else if let Some(mf) = merge_failure {
|
||||
let snippet = first_non_empty_snippet(mf, 120);
|
||||
return format!(" \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n");
|
||||
} else {
|
||||
return format!(" \u{23F3} {display}{cost_suffix}{dep_suffix}\n");
|
||||
}
|
||||
}
|
||||
|
||||
let blocked = item.stage.is_blocked();
|
||||
let throttled = agent.map(|a| a.throttled).unwrap_or(false);
|
||||
let dot = super::traffic_light_dot(blocked, throttled, agent.is_some());
|
||||
if let Some(agent) = agent {
|
||||
let model_str = config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.find_agent(&agent.agent_name))
|
||||
.and_then(|ac| ac.model.as_deref())
|
||||
.unwrap_or("?");
|
||||
format!(
|
||||
" {dot}{display}{cost_suffix}{dep_suffix} — {} ({model_str})\n",
|
||||
agent.agent_name
|
||||
)
|
||||
} else {
|
||||
format!(" {dot}{display}{cost_suffix}{dep_suffix}\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
//! Tests for the `status` command submodules.
|
||||
|
||||
use super::*;
|
||||
use crate::agents::{AgentPool, AgentStatus};
|
||||
use crate::pipeline_state::{ArchiveReason, PipelineItem, Stage, StoryId};
|
||||
use chrono::Utc;
|
||||
|
||||
/// Build a minimal PipelineItem for tests.
|
||||
fn make_item(id: &str, name: &str, stage: Stage) -> PipelineItem {
|
||||
PipelineItem {
|
||||
story_id: StoryId(id.to_string()),
|
||||
name: name.to_string(),
|
||||
stage,
|
||||
depends_on: Vec::new(),
|
||||
retry_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a PipelineItem with dependencies for tests.
|
||||
fn make_item_with_deps(id: &str, name: &str, stage: Stage, deps: Vec<u32>) -> PipelineItem {
|
||||
PipelineItem {
|
||||
story_id: StoryId(id.to_string()),
|
||||
name: name.to_string(),
|
||||
stage,
|
||||
depends_on: deps.iter().map(|n| StoryId(n.to_string())).collect(),
|
||||
retry_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_matches() {
|
||||
let result =
|
||||
super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status");
|
||||
assert!(result.is_some(), "status command should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_returns_pipeline_text() {
|
||||
let result =
|
||||
super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status");
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Pipeline Status"),
|
||||
"status output should contain pipeline info: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_case_insensitive() {
|
||||
let result =
|
||||
super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy STATUS");
|
||||
assert!(result.is_some(), "STATUS should match case-insensitively");
|
||||
}
|
||||
|
||||
// -- story_short_label --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn short_label_extracts_number_and_name() {
|
||||
let label = story_short_label(
|
||||
"293_story_register_all_bot_commands",
|
||||
Some("Register all bot commands"),
|
||||
);
|
||||
assert_eq!(label, "293 [story] — Register all bot commands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_number_only_when_no_name() {
|
||||
let label = story_short_label("297_story_improve_bot_status_command_formatting", None);
|
||||
assert_eq!(label, "297 [story]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_falls_back_to_stem_when_no_numeric_prefix() {
|
||||
let label = story_short_label("no_number_here", None);
|
||||
assert_eq!(label, "no_number_here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_does_not_include_underscore_slug() {
|
||||
let label = story_short_label(
|
||||
"293_story_register_all_bot_commands_in_the_command_registry",
|
||||
Some("Register all bot commands"),
|
||||
);
|
||||
assert!(
|
||||
!label.contains("story_register"),
|
||||
"label should not contain the slug portion: {label}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_shows_bug_type() {
|
||||
let label = story_short_label(
|
||||
"375_bug_default_project_toml",
|
||||
Some("Default project.toml issue"),
|
||||
);
|
||||
assert_eq!(label, "375 [bug] — Default project.toml issue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_shows_spike_type() {
|
||||
let label = story_short_label(
|
||||
"61_spike_filesystem_watcher_architecture",
|
||||
Some("Filesystem watcher architecture"),
|
||||
);
|
||||
assert_eq!(label, "61 [spike] — Filesystem watcher architecture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_shows_refactor_type() {
|
||||
let label = story_short_label(
|
||||
"260_refactor_upgrade_libsqlite3_sys",
|
||||
Some("Upgrade libsqlite3-sys"),
|
||||
);
|
||||
assert_eq!(label, "260 [refactor] — Upgrade libsqlite3-sys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_omits_unknown_type() {
|
||||
let label = story_short_label("42_task_do_something", Some("Do something"));
|
||||
assert_eq!(label, "42 — Do something");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_no_type_when_only_id() {
|
||||
// Stem with only a numeric ID and no type segment
|
||||
let label = story_short_label("42", Some("Some item"));
|
||||
assert_eq!(label, "42 — Some item");
|
||||
}
|
||||
|
||||
// -- build_status_from_items formatting -----------------------------------
|
||||
|
||||
#[test]
|
||||
fn status_does_not_show_full_filename_stem() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![make_item(
|
||||
"293_story_register_all_bot_commands",
|
||||
"Register all bot commands",
|
||||
Stage::Coding,
|
||||
)];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
assert!(
|
||||
!output.contains("293_story_register_all_bot_commands"),
|
||||
"output must not show full filename stem: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("293 [story] — Register all bot commands"),
|
||||
"output must show number, type, and title: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- token cost in status output ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn status_shows_cost_when_token_usage_exists() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![make_item(
|
||||
"293_story_register_all_bot_commands",
|
||||
"Register all bot commands",
|
||||
Stage::Coding,
|
||||
)];
|
||||
|
||||
// Write token usage for this story.
|
||||
let usage = crate::agents::TokenUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 200,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: 0.29,
|
||||
};
|
||||
let record = crate::agents::token_usage::build_record(
|
||||
"293_story_register_all_bot_commands",
|
||||
"coder-1",
|
||||
None,
|
||||
usage,
|
||||
);
|
||||
crate::agents::token_usage::append_record(tmp.path(), &record).unwrap();
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
assert!(
|
||||
output.contains("293 [story] — Register all bot commands — $0.29"),
|
||||
"output must show cost next to story: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_no_cost_when_no_usage() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![make_item(
|
||||
"293_story_register_all_bot_commands",
|
||||
"Register all bot commands",
|
||||
Stage::Coding,
|
||||
)];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
assert!(
|
||||
!output.contains("$"),
|
||||
"output must not show cost when no usage exists: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_aggregates_multiple_records_per_story() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![make_item(
|
||||
"293_story_register_all_bot_commands",
|
||||
"Register all bot commands",
|
||||
Stage::Coding,
|
||||
)];
|
||||
|
||||
// Write two records for the same story — costs should be summed.
|
||||
for cost in [0.10, 0.19] {
|
||||
let usage = crate::agents::TokenUsage {
|
||||
input_tokens: 50,
|
||||
output_tokens: 100,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: cost,
|
||||
};
|
||||
let record = crate::agents::token_usage::build_record(
|
||||
"293_story_register_all_bot_commands",
|
||||
"coder-1",
|
||||
None,
|
||||
usage,
|
||||
);
|
||||
crate::agents::token_usage::append_record(tmp.path(), &record).unwrap();
|
||||
}
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
assert!(
|
||||
output.contains("293 [story] — Register all bot commands — $0.29"),
|
||||
"output must show aggregated cost: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- dependency display in status output --------------------------------
|
||||
|
||||
#[test]
|
||||
fn status_shows_waiting_on_for_story_with_unmet_deps() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Story 10 depends on story 999, which is NOT in all_items (treated as met)
|
||||
// OR present in backlog (unmet). Let's add dep 999 in Backlog stage (unmet).
|
||||
let items = vec![
|
||||
make_item_with_deps(
|
||||
"10_story_waiting",
|
||||
"Waiting Story",
|
||||
Stage::Coding,
|
||||
vec![999],
|
||||
),
|
||||
make_item("999_story_dep", "Dep Story", Stage::Backlog),
|
||||
];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
assert!(
|
||||
output.contains("waiting on: 999"),
|
||||
"status should show waiting-on info for unmet deps: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_does_not_show_waiting_on_when_dep_is_done() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Dep 999 is in Done stage — met.
|
||||
let items = vec![
|
||||
make_item_with_deps(
|
||||
"10_story_unblocked",
|
||||
"Unblocked Story",
|
||||
Stage::Coding,
|
||||
vec![999],
|
||||
),
|
||||
make_item(
|
||||
"999_story_dep",
|
||||
"Dep Story",
|
||||
Stage::Done {
|
||||
merged_at: Utc::now(),
|
||||
merge_commit: crate::pipeline_state::GitSha("abc123".to_string()),
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
assert!(
|
||||
!output.contains("waiting on"),
|
||||
"status should not show waiting-on when all deps are done: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_shows_no_waiting_info_when_no_deps() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![make_item("42_story_nodeps", "No Deps Story", Stage::Coding)];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
assert!(
|
||||
!output.contains("waiting on"),
|
||||
"status should not show waiting-on for stories without deps: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- traffic_light_dot --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dot_idle_when_no_agent() {
|
||||
assert_eq!(traffic_light_dot(false, false, false), "\u{26AA} "); // ⚪
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_running_when_agent_not_throttled() {
|
||||
assert_eq!(traffic_light_dot(false, false, true), "\u{1F7E2} "); // 🟢
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_throttled_when_agent_throttled() {
|
||||
assert_eq!(traffic_light_dot(false, true, true), "\u{1F7E0} "); // 🟠
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_blocked_takes_priority_over_throttled() {
|
||||
assert_eq!(traffic_light_dot(true, true, true), "\u{1F534} "); // 🔴
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_blocked_when_no_agent_but_blocked_flag() {
|
||||
assert_eq!(traffic_light_dot(true, false, false), "\u{1F534} "); // 🔴
|
||||
}
|
||||
|
||||
// -- Stage::is_blocked() replaces read_story_blocked --------------------
|
||||
|
||||
#[test]
|
||||
fn stage_is_blocked_returns_true_for_archived_blocked() {
|
||||
let stage = Stage::Archived {
|
||||
archived_at: Utc::now(),
|
||||
reason: ArchiveReason::Blocked {
|
||||
reason: "too many retries".to_string(),
|
||||
},
|
||||
};
|
||||
assert!(stage.is_blocked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_is_blocked_returns_false_for_coding() {
|
||||
assert!(!Stage::Coding.is_blocked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_is_blocked_returns_false_for_archived_completed() {
|
||||
let stage = Stage::Archived {
|
||||
archived_at: Utc::now(),
|
||||
reason: ArchiveReason::Completed,
|
||||
};
|
||||
assert!(!stage.is_blocked());
|
||||
}
|
||||
|
||||
// -- status output shows idle dot for items with no active agent --------
|
||||
|
||||
#[test]
|
||||
fn status_shows_idle_dot_for_unassigned_story() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![make_item("42_story_idle", "Idle Story", Stage::Coding)];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
assert!(
|
||||
output.contains("\u{26AA} "), // ⚪
|
||||
"idle story should show white circle emoji: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_shows_blocked_dot_for_blocked_story() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![make_item(
|
||||
"42_story_blocked",
|
||||
"Blocked Story",
|
||||
Stage::Archived {
|
||||
archived_at: Utc::now(),
|
||||
reason: ArchiveReason::Blocked {
|
||||
reason: "retry limit exceeded".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}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Regression: CRDT-only story appears in correct stage ---------------
|
||||
|
||||
#[test]
|
||||
fn status_shows_crdt_done_story_in_done_not_backlog() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Story is Done in the typed API — even if a stale 1_backlog/ shadow existed
|
||||
// on the filesystem, the status must show it in Done, not Backlog.
|
||||
let items = vec![make_item(
|
||||
"503_story_some_feature",
|
||||
"Some Feature",
|
||||
Stage::Done {
|
||||
merged_at: Utc::now(),
|
||||
merge_commit: crate::pipeline_state::GitSha("deadbeef".to_string()),
|
||||
},
|
||||
)];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
// Must appear under Done, not Backlog.
|
||||
let done_pos = output.find("**Done**").expect("Done section must exist");
|
||||
let backlog_pos = output
|
||||
.find("**Backlog**")
|
||||
.expect("Backlog section must exist");
|
||||
let story_pos = output
|
||||
.find("503 [story]")
|
||||
.expect("story must appear in output");
|
||||
|
||||
assert!(
|
||||
story_pos > done_pos,
|
||||
"story should appear after Done header: backlog={backlog_pos} done={done_pos} story={story_pos}\n{output}"
|
||||
);
|
||||
assert!(
|
||||
story_pos > backlog_pos,
|
||||
"story should not be in the Backlog section: {output}"
|
||||
);
|
||||
|
||||
// Verify it's not in Backlog section specifically.
|
||||
let backlog_section = &output[backlog_pos..done_pos];
|
||||
assert!(
|
||||
!backlog_section.contains("503"),
|
||||
"503 must not appear in Backlog section: {backlog_section}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- merge-stage breakdown indicators ------------------------------------
|
||||
|
||||
fn merge_stage() -> Stage {
|
||||
use crate::pipeline_state::BranchName;
|
||||
Stage::Merge {
|
||||
feature_branch: BranchName("feature/test".to_string()),
|
||||
commits_ahead: std::num::NonZeroU32::new(1).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_item_queued_shows_hourglass() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let items = vec![make_item(
|
||||
"901_story_merge_q",
|
||||
"Queued Story",
|
||||
merge_stage(),
|
||||
)];
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
assert!(
|
||||
output.contains("\u{23F3}"), // ⏳
|
||||
"queued merge item should show hourglass: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_item_with_active_agent_shows_robot() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let items = vec![make_item(
|
||||
"902_story_merge_mm",
|
||||
"Mergemaster Story",
|
||||
merge_stage(),
|
||||
)];
|
||||
let agents = AgentPool::new_test(3000);
|
||||
agents.inject_test_agent("902_story_merge_mm", "mergemaster", AgentStatus::Running);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
assert!(
|
||||
output.contains("\u{1F916}"), // 🤖
|
||||
"merge item with running agent should show robot: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("mergemaster running"),
|
||||
"output should contain 'mergemaster running': {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_item_with_failure_shows_stop_sign_and_snippet() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"903_story_merge_fail",
|
||||
"4_merge",
|
||||
"---\nname: Failed Story\nmerge_failure: \"conflicts in src/lib.rs\"\n---\n",
|
||||
);
|
||||
let items = vec![make_item(
|
||||
"903_story_merge_fail",
|
||||
"Failed Story",
|
||||
merge_stage(),
|
||||
)];
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
assert!(
|
||||
output.contains("\u{26D4}"), // ⛔
|
||||
"merge item with failure should show stop sign: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("conflicts in src/lib.rs"),
|
||||
"output should contain failure snippet: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_item_failure_snippet_truncated_at_120_chars() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
let long_reason = "x".repeat(200);
|
||||
let content = format!("---\nname: Long Fail\nmerge_failure: \"{long_reason}\"\n---\n");
|
||||
crate::db::write_item_with_content("904_story_long_fail", "4_merge", &content);
|
||||
let items = vec![make_item("904_story_long_fail", "Long Fail", merge_stage())];
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
assert!(
|
||||
output.contains("…"),
|
||||
"long failure should be truncated with ellipsis: {output}"
|
||||
);
|
||||
// The snippet should not exceed 120 chars plus the ellipsis character.
|
||||
let snippet_start = output.find("\u{26D4}").expect("stop sign must be present");
|
||||
let line = output[snippet_start..].lines().next().unwrap_or("");
|
||||
// Find the last " — " separator (before the snippet) and take what follows.
|
||||
if let Some(sep_pos) = line.rfind(" \u{2014} ") {
|
||||
let snippet = &line[sep_pos + 5..]; // " — " is 5 bytes (space + 3-byte em dash + space)
|
||||
assert!(
|
||||
snippet.chars().count() <= 122, // 120 chars + "…" (1 char) + possible trailing
|
||||
"snippet should be at most ~121 chars: {snippet}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_item_failure_snippet_is_first_non_empty_line() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
// Multi-line failure reason — first non-empty line should be used.
|
||||
let reason = "\nfirst line of error\nsecond line";
|
||||
let content = format!(
|
||||
"---\nname: Multi Line\nmerge_failure: \"{}\" \n---\n",
|
||||
reason.replace('\n', "\\n")
|
||||
);
|
||||
crate::db::write_item_with_content("905_story_multiline", "4_merge", &content);
|
||||
// Write with literal \n as the content (simulating stored text with newlines).
|
||||
let content2 =
|
||||
"---\nname: Multi Line\nmerge_failure: |\n \n first line of error\n second line\n---\n";
|
||||
crate::db::write_item_with_content("905_story_multiline", "4_merge", content2);
|
||||
let items = vec![make_item(
|
||||
"905_story_multiline",
|
||||
"Multi Line",
|
||||
merge_stage(),
|
||||
)];
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
assert!(
|
||||
output.contains("first line of error"),
|
||||
"snippet should use first non-empty line: {output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("second line"),
|
||||
"snippet should not include second line: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_item_det_merge_running_preferred_over_failure() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"906_story_det_over_fail",
|
||||
"4_merge",
|
||||
"---\nname: Det Over Fail\nmerge_failure: \"old failure\"\n---\n",
|
||||
);
|
||||
// Record a running deterministic merge in the CRDT.
|
||||
crate::crdt_state::write_merge_job("906_story_det_over_fail", "running", 0.0, None, None);
|
||||
let items = vec![make_item(
|
||||
"906_story_det_over_fail",
|
||||
"Det Over Fail",
|
||||
merge_stage(),
|
||||
)];
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
assert!(
|
||||
output.contains("\u{1F504}"), // 🔄
|
||||
"deterministic merge running should be preferred over failure: {output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("\u{26D4}"), // ⛔ should not appear
|
||||
"stop sign should not appear when det merge is running: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- first_non_empty_snippet unit tests ---------------------------------
|
||||
|
||||
#[test]
|
||||
fn snippet_empty_text_returns_empty() {
|
||||
assert_eq!(first_non_empty_snippet("", 120), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_short_text_returned_as_is() {
|
||||
assert_eq!(first_non_empty_snippet("short error", 120), "short error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_long_text_truncated_with_ellipsis() {
|
||||
let text = "a".repeat(200);
|
||||
let result = first_non_empty_snippet(&text, 120);
|
||||
assert!(result.ends_with('…'), "should end with ellipsis: {result}");
|
||||
assert_eq!(
|
||||
result.chars().count(),
|
||||
121,
|
||||
"should be 120 + ellipsis: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_skips_leading_empty_lines() {
|
||||
let text = "\n\n \nactual error here\nsecond line";
|
||||
let result = first_non_empty_snippet(text, 120);
|
||||
assert_eq!(result, "actual error here");
|
||||
}
|
||||
Reference in New Issue
Block a user