huskies: merge 830
This commit is contained in:
@@ -101,6 +101,18 @@ pub(super) fn unmet_deps_from_items(item: &PipelineItem, all_items: &[PipelineIt
|
|||||||
.collect()
|
.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).
|
/// Build the full pipeline status text formatted for Matrix (markdown).
|
||||||
pub(super) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String {
|
pub(super) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String {
|
||||||
let items = crate::pipeline_state::read_all_typed();
|
let items = crate::pipeline_state::read_all_typed();
|
||||||
@@ -132,6 +144,24 @@ fn build_status_from_items(
|
|||||||
|
|
||||||
let config = ProjectConfig::load(project_root).ok();
|
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");
|
let mut out = String::from("**Pipeline Status**\n\n");
|
||||||
|
|
||||||
// Active pipeline stages to display (Archived is handled separately below).
|
// Active pipeline stages to display (Archived is handled separately below).
|
||||||
@@ -160,6 +190,8 @@ fn build_status_from_items(
|
|||||||
&active_map,
|
&active_map,
|
||||||
&cost_by_story,
|
&cost_by_story,
|
||||||
&config,
|
&config,
|
||||||
|
&running_merges,
|
||||||
|
&merge_failures,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,6 +211,8 @@ fn build_status_from_items(
|
|||||||
&active_map,
|
&active_map,
|
||||||
&cost_by_story,
|
&cost_by_story,
|
||||||
&config,
|
&config,
|
||||||
|
&running_merges,
|
||||||
|
&merge_failures,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
@@ -224,6 +258,8 @@ fn render_item_line(
|
|||||||
active_map: &HashMap<String, &crate::agents::AgentInfo>,
|
active_map: &HashMap<String, &crate::agents::AgentInfo>,
|
||||||
cost_by_story: &HashMap<String, f64>,
|
cost_by_story: &HashMap<String, f64>,
|
||||||
config: &Option<ProjectConfig>,
|
config: &Option<ProjectConfig>,
|
||||||
|
running_merges: &HashSet<String>,
|
||||||
|
merge_failures: &HashMap<String, String>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let story_id = &item.story_id.0;
|
let story_id = &item.story_id.0;
|
||||||
let name_opt = if item.name.is_empty() {
|
let name_opt = if item.name.is_empty() {
|
||||||
@@ -243,10 +279,7 @@ fn render_item_line(
|
|||||||
.filter(|&&c| c > 0.0)
|
.filter(|&&c| c > 0.0)
|
||||||
.map(|c| format!(" — ${c:.2}"))
|
.map(|c| format!(" — ${c:.2}"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let blocked = item.stage.is_blocked();
|
|
||||||
let agent = active_map.get(story_id);
|
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 unmet = unmet_deps_from_items(item, all_items);
|
||||||
let dep_suffix = if unmet.is_empty() {
|
let dep_suffix = if unmet.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -254,6 +287,33 @@ fn render_item_line(
|
|||||||
let nums: Vec<String> = unmet.iter().map(|n| n.to_string()).collect();
|
let nums: Vec<String> = unmet.iter().map(|n| n.to_string()).collect();
|
||||||
format!(" *(waiting on: {})*", nums.join(", "))
|
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 {
|
if let Some(agent) = agent {
|
||||||
let model_str = config
|
let model_str = config
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -748,4 +808,200 @@ mod tests {
|
|||||||
"503 must not appear in Backlog section: {backlog_section}"
|
"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