diff --git a/server/src/chat/commands/status.rs b/server/src/chat/commands/status.rs index b169e8af..9edfe82a 100644 --- a/server/src/chat/commands/status.rs +++ b/server/src/chat/commands/status.rs @@ -101,6 +101,18 @@ pub(super) fn unmet_deps_from_items(item: &PipelineItem, all_items: &[PipelineIt .collect() } +/// Extract the first non-empty line from `text`, truncated to `max_len` chars. +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(super) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String { let items = crate::pipeline_state::read_all_typed(); @@ -132,6 +144,24 @@ fn build_status_from_items( 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 = agents + .list_running_merges() + .unwrap_or_default() + .into_iter() + .collect(); + let merge_failures: HashMap = 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). @@ -160,6 +190,8 @@ fn build_status_from_items( &active_map, &cost_by_story, &config, + &running_merges, + &merge_failures, )); } } @@ -179,6 +211,8 @@ fn build_status_from_items( &active_map, &cost_by_story, &config, + &running_merges, + &merge_failures, )); } out.push('\n'); @@ -224,6 +258,8 @@ fn render_item_line( active_map: &HashMap, cost_by_story: &HashMap, config: &Option, + running_merges: &HashSet, + merge_failures: &HashMap, ) -> String { let story_id = &item.story_id.0; let name_opt = if item.name.is_empty() { @@ -243,10 +279,7 @@ fn render_item_line( .filter(|&&c| c > 0.0) .map(|c| format!(" — ${c:.2}")) .unwrap_or_default(); - let blocked = item.stage.is_blocked(); let agent = active_map.get(story_id); - let throttled = agent.map(|a| a.throttled).unwrap_or(false); - let dot = traffic_light_dot(blocked, throttled, agent.is_some()); let unmet = unmet_deps_from_items(item, all_items); let dep_suffix = if unmet.is_empty() { String::new() @@ -254,6 +287,33 @@ fn render_item_line( let nums: Vec = 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 = traffic_light_dot(blocked, throttled, agent.is_some()); if let Some(agent) = agent { let model_str = config .as_ref() @@ -748,4 +808,200 @@ mod tests { "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"); + } }