huskies: merge 1188 story Merge tool results return a summary, not the full gate log (35KB per call)
This commit is contained in:
@@ -103,6 +103,9 @@ pub struct MergeJob {
|
|||||||
/// than the current server's boot time. This survives `rebuild_and_restart`
|
/// than the current server's boot time. This survives `rebuild_and_restart`
|
||||||
/// (which re-execs and keeps the same PID).
|
/// (which re-execs and keeps the same PID).
|
||||||
pub server_start_time: f64,
|
pub server_start_time: f64,
|
||||||
|
/// Unix timestamp (seconds) when this merge job started, used to compute
|
||||||
|
/// elapsed time for a still-`Running` job.
|
||||||
|
pub started_at: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of a mergemaster merge operation.
|
/// Result of a mergemaster merge operation.
|
||||||
@@ -113,6 +116,11 @@ pub struct MergeReport {
|
|||||||
pub result: MergeResult,
|
pub result: MergeResult,
|
||||||
pub worktree_cleaned_up: bool,
|
pub worktree_cleaned_up: bool,
|
||||||
pub story_archived: bool,
|
pub story_archived: bool,
|
||||||
|
/// Path (relative to the project root) of the full untruncated report
|
||||||
|
/// written by `service::merge::io::write_merge_report`, if the write
|
||||||
|
/// succeeded.
|
||||||
|
#[serde(default)]
|
||||||
|
pub report_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ impl AgentPool {
|
|||||||
// retry_count=1 so maybe_inject_gate_failure injects gate output
|
// retry_count=1 so maybe_inject_gate_failure injects gate output
|
||||||
// into --append-system-prompt on the fixup spawn.
|
// into --append-system-prompt on the fixup spawn.
|
||||||
// transition_to_merge_failure also writes ContentKey::GateOutput.
|
// transition_to_merge_failure also writes ContentKey::GateOutput.
|
||||||
let display = kind.display_reason();
|
let display = crate::service::merge::summarize_merge_failure_kind(&kind);
|
||||||
let _ =
|
let _ =
|
||||||
crate::agents::lifecycle::transition_to_merge_failure(sid.as_str(), kind);
|
crate::agents::lifecycle::transition_to_merge_failure(sid.as_str(), kind);
|
||||||
match crate::agents::lifecycle::move_story_to_stage(&sid, "current") {
|
match crate::agents::lifecycle::move_story_to_stage(&sid, "current") {
|
||||||
@@ -258,7 +258,7 @@ impl AgentPool {
|
|||||||
// Transition through the state machine (Merge → MergeFailure).
|
// Transition through the state machine (Merge → MergeFailure).
|
||||||
// Only send the notification when the stage actually changed; if the
|
// Only send the notification when the stage actually changed; if the
|
||||||
// story was already in MergeFailure (self-loop), suppress the duplicate.
|
// story was already in MergeFailure (self-loop), suppress the duplicate.
|
||||||
let display = kind.display_reason();
|
let display = crate::service::merge::summarize_merge_failure_kind(&kind);
|
||||||
let should_notify = match crate::agents::lifecycle::transition_to_merge_failure(
|
let should_notify = match crate::agents::lifecycle::transition_to_merge_failure(
|
||||||
sid.as_str(),
|
sid.as_str(),
|
||||||
kind,
|
kind,
|
||||||
@@ -352,14 +352,26 @@ impl AgentPool {
|
|||||||
merge_result,
|
merge_result,
|
||||||
crate::agents::merge::MergeResult::Success { .. }
|
crate::agents::merge::MergeResult::Success { .. }
|
||||||
) {
|
) {
|
||||||
|
let report_path = crate::service::merge::io::write_merge_report(
|
||||||
|
project_root,
|
||||||
|
story_id,
|
||||||
|
merge_result.output(),
|
||||||
|
);
|
||||||
return Ok(crate::agents::merge::MergeReport {
|
return Ok(crate::agents::merge::MergeReport {
|
||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
result: merge_result,
|
result: merge_result,
|
||||||
worktree_cleaned_up: false,
|
worktree_cleaned_up: false,
|
||||||
story_archived: false,
|
story_archived: false,
|
||||||
|
report_path,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let report_path = crate::service::merge::io::write_merge_report(
|
||||||
|
project_root,
|
||||||
|
story_id,
|
||||||
|
merge_result.output(),
|
||||||
|
);
|
||||||
|
|
||||||
let story_archived = crate::agents::lifecycle::move_story_to_done(story_id).is_ok();
|
let story_archived = crate::agents::lifecycle::move_story_to_done(story_id).is_ok();
|
||||||
|
|
||||||
// Story 1178: only delete the feature branch once the state transition
|
// Story 1178: only delete the feature branch once the state transition
|
||||||
@@ -389,6 +401,7 @@ impl AgentPool {
|
|||||||
result: merge_result,
|
result: merge_result,
|
||||||
worktree_cleaned_up,
|
worktree_cleaned_up,
|
||||||
story_archived,
|
story_archived,
|
||||||
|
report_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ impl AgentPool {
|
|||||||
},
|
},
|
||||||
worktree_cleaned_up: false,
|
worktree_cleaned_up: false,
|
||||||
story_archived: false,
|
story_archived: false,
|
||||||
|
report_path: None,
|
||||||
});
|
});
|
||||||
(crate::agents::merge::MergeJobStatus::Completed(report), 0.0)
|
(crate::agents::merge::MergeJobStatus::Completed(report), 0.0)
|
||||||
}
|
}
|
||||||
@@ -41,6 +42,7 @@ impl AgentPool {
|
|||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
status,
|
status,
|
||||||
server_start_time,
|
server_start_time,
|
||||||
|
started_at: view.started_at,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,9 +82,15 @@ pub(super) fn tool_get_merge_status(args: &Value, ctx: &AppContext) -> Result<St
|
|||||||
|
|
||||||
match &job.status {
|
match &job.status {
|
||||||
crate::agents::merge::MergeJobStatus::Running => {
|
crate::agents::merge::MergeJobStatus::Running => {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs_f64();
|
||||||
|
let elapsed_seconds = (now - job.started_at).max(0.0);
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&json!({
|
||||||
"story_id": story_id,
|
"story_id": story_id,
|
||||||
"status": "running",
|
"status": "running",
|
||||||
|
"elapsed_seconds": elapsed_seconds,
|
||||||
"message": "Merge pipeline is still running."
|
"message": "Merge pipeline is still running."
|
||||||
}))
|
}))
|
||||||
.map_err(|e| format!("Serialization error: {e}"))
|
.map_err(|e| format!("Serialization error: {e}"))
|
||||||
@@ -92,24 +98,27 @@ pub(super) fn tool_get_merge_status(args: &Value, ctx: &AppContext) -> Result<St
|
|||||||
crate::agents::merge::MergeJobStatus::Completed(report) => {
|
crate::agents::merge::MergeJobStatus::Completed(report) => {
|
||||||
use crate::agents::merge::MergeResult;
|
use crate::agents::merge::MergeResult;
|
||||||
let status_msg = crate::service::merge::format_merge_status_message(report);
|
let status_msg = crate::service::merge::format_merge_status_message(report);
|
||||||
let (success, had_conflicts, conflicts_resolved, conflict_details, gates_passed, gate_output) =
|
let (success, had_conflicts, conflicts_resolved, conflict_details, gates_passed) =
|
||||||
match &report.result {
|
match &report.result {
|
||||||
MergeResult::Success { conflicts_resolved, conflict_details, gate_output } => {
|
MergeResult::Success { conflicts_resolved, conflict_details, .. } => {
|
||||||
(true, *conflicts_resolved, *conflicts_resolved, conflict_details.clone(), true, gate_output.clone())
|
(true, *conflicts_resolved, *conflicts_resolved, conflict_details.clone(), true)
|
||||||
}
|
}
|
||||||
MergeResult::Conflict { details, output } => {
|
MergeResult::Conflict { details, .. } => {
|
||||||
(false, true, false, details.clone(), false, output.clone())
|
(false, true, false, details.clone(), false)
|
||||||
}
|
}
|
||||||
MergeResult::GateFailure { output, .. } => {
|
MergeResult::GateFailure { .. } => {
|
||||||
(false, false, false, None, false, output.clone())
|
(false, false, false, None, false)
|
||||||
}
|
}
|
||||||
MergeResult::NoCommits { output } => {
|
MergeResult::NoCommits { .. } => {
|
||||||
(false, false, false, None, false, output.clone())
|
(false, false, false, None, false)
|
||||||
}
|
}
|
||||||
MergeResult::Other { output, conflict_details } => {
|
MergeResult::Other { conflict_details, .. } => {
|
||||||
(false, false, false, conflict_details.clone(), false, output.clone())
|
(false, false, false, conflict_details.clone(), false)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// AC1: condensed digest instead of the full (potentially 35KB+)
|
||||||
|
// gate_output — successful merges carry no summary at all.
|
||||||
|
let gate_summary = crate::service::merge::summarize_merge_result(&report.result);
|
||||||
|
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&json!({
|
||||||
"story_id": story_id,
|
"story_id": story_id,
|
||||||
@@ -119,7 +128,8 @@ pub(super) fn tool_get_merge_status(args: &Value, ctx: &AppContext) -> Result<St
|
|||||||
"conflicts_resolved": conflicts_resolved,
|
"conflicts_resolved": conflicts_resolved,
|
||||||
"conflict_details": conflict_details,
|
"conflict_details": conflict_details,
|
||||||
"gates_passed": gates_passed,
|
"gates_passed": gates_passed,
|
||||||
"gate_output": gate_output,
|
"gate_summary": if gate_summary.is_empty() { serde_json::Value::Null } else { json!(gate_summary) },
|
||||||
|
"report_path": report.report_path,
|
||||||
"worktree_cleaned_up": report.worktree_cleaned_up,
|
"worktree_cleaned_up": report.worktree_cleaned_up,
|
||||||
"story_archived": report.story_archived,
|
"story_archived": report.story_archived,
|
||||||
"message": status_msg,
|
"message": status_msg,
|
||||||
@@ -398,6 +408,118 @@ mod tests {
|
|||||||
assert!(result.unwrap_err().contains("No merge job"));
|
assert!(result.unwrap_err().contains("No merge job"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AC3: a still-running merge job returns status and elapsed time only —
|
||||||
|
/// no gate data of any kind belongs on a Running response.
|
||||||
|
#[test]
|
||||||
|
fn tool_get_merge_status_running_includes_elapsed_seconds_only() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
crate::crdt_state::write_merge_job(
|
||||||
|
"50_story_running",
|
||||||
|
"running",
|
||||||
|
0.0,
|
||||||
|
None,
|
||||||
|
Some("{\"server_start\":0.0}"),
|
||||||
|
);
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = test_ctx(tmp.path());
|
||||||
|
let result = tool_get_merge_status(&json!({"story_id": "50_story_running"}), &ctx);
|
||||||
|
assert!(result.is_ok(), "expected Ok, got: {result:?}");
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
|
||||||
|
assert_eq!(v["status"], "running");
|
||||||
|
assert!(
|
||||||
|
v["elapsed_seconds"].as_f64().unwrap() > 0.0,
|
||||||
|
"elapsed_seconds must reflect time since started_at=0.0: {v}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
v.get("gate_output").is_none() && v.get("gate_summary").is_none(),
|
||||||
|
"a running job must carry no gate data: {v}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC1: a successful merge's response carries no gate summary at all.
|
||||||
|
#[test]
|
||||||
|
fn tool_get_merge_status_completed_success_has_no_gate_summary() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
let report = crate::agents::merge::MergeReport {
|
||||||
|
story_id: "51_story_ok".to_string(),
|
||||||
|
result: crate::agents::merge::MergeResult::Success {
|
||||||
|
conflicts_resolved: false,
|
||||||
|
conflict_details: None,
|
||||||
|
gate_output: "all tests passed".to_string(),
|
||||||
|
},
|
||||||
|
worktree_cleaned_up: true,
|
||||||
|
story_archived: true,
|
||||||
|
report_path: Some(".huskies/merge_reports/51_story_ok-1.log".to_string()),
|
||||||
|
};
|
||||||
|
let report_json = serde_json::to_string(&report).unwrap();
|
||||||
|
crate::crdt_state::write_merge_job(
|
||||||
|
"51_story_ok",
|
||||||
|
"completed",
|
||||||
|
1.0,
|
||||||
|
Some(2.0),
|
||||||
|
Some(&report_json),
|
||||||
|
);
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = test_ctx(tmp.path());
|
||||||
|
let result = tool_get_merge_status(&json!({"story_id": "51_story_ok"}), &ctx).unwrap();
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||||
|
assert!(
|
||||||
|
v["gate_summary"].is_null(),
|
||||||
|
"success must carry no gate summary: {v}"
|
||||||
|
);
|
||||||
|
assert_eq!(v["report_path"], ".huskies/merge_reports/51_story_ok-1.log");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC1/AC2: a gate-failure response carries a condensed summary (failing
|
||||||
|
/// gate + parsed test names + tail), plus the full-report path — not the
|
||||||
|
/// raw multi-KB output.
|
||||||
|
#[test]
|
||||||
|
fn tool_get_merge_status_completed_gate_failure_has_condensed_summary() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
let output = "running 1 test\ntest foo::bar ... FAILED\n\n\
|
||||||
|
failures:\n foo::bar\n\ntest result: FAILED. 0 passed; 1 failed\n"
|
||||||
|
.to_string();
|
||||||
|
let report = crate::agents::merge::MergeReport {
|
||||||
|
story_id: "52_story_fail".to_string(),
|
||||||
|
result: crate::agents::merge::MergeResult::GateFailure {
|
||||||
|
output,
|
||||||
|
failure_kind: Some(crate::agents::gates::GateFailureKind::Test),
|
||||||
|
},
|
||||||
|
worktree_cleaned_up: false,
|
||||||
|
story_archived: false,
|
||||||
|
report_path: Some(".huskies/merge_reports/52_story_fail-1.log".to_string()),
|
||||||
|
};
|
||||||
|
let report_json = serde_json::to_string(&report).unwrap();
|
||||||
|
crate::crdt_state::write_merge_job(
|
||||||
|
"52_story_fail",
|
||||||
|
"completed",
|
||||||
|
1.0,
|
||||||
|
Some(2.0),
|
||||||
|
Some(&report_json),
|
||||||
|
);
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = test_ctx(tmp.path());
|
||||||
|
let result = tool_get_merge_status(&json!({"story_id": "52_story_fail"}), &ctx).unwrap();
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||||
|
let summary = v["gate_summary"]
|
||||||
|
.as_str()
|
||||||
|
.expect("gate_summary must be present on failure");
|
||||||
|
assert!(
|
||||||
|
summary.contains("Failing gate: tests"),
|
||||||
|
"summary: {summary}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
summary.contains("Failing tests: foo::bar"),
|
||||||
|
"summary: {summary}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
v["report_path"],
|
||||||
|
".huskies/merge_reports/52_story_fail-1.log"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// tool_get_merge_status_returns_running removed: depends on
|
// tool_get_merge_status_returns_running removed: depends on
|
||||||
// tool_merge_agent_work which now blocks indefinitely in a poll loop.
|
// tool_merge_agent_work which now blocks indefinitely in a poll loop.
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,145 @@
|
|||||||
//! Currently, the bulk of the merge I/O is handled by `crate::agents::merge`
|
//! Currently, the bulk of the merge I/O is handled by `crate::agents::merge`
|
||||||
//! and `crate::io::story_metadata`. This file is the designated home for any
|
//! and `crate::io::story_metadata`. This file is the designated home for any
|
||||||
//! future I/O helpers that are extracted from merge-related MCP handlers.
|
//! future I/O helpers that are extracted from merge-related MCP handlers.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Directory (relative to the project root) where full untruncated merge
|
||||||
|
/// reports are written, one file per merge attempt.
|
||||||
|
const MERGE_REPORTS_DIR: &str = "merge_reports";
|
||||||
|
|
||||||
|
/// Maximum number of report files retained per story; older files beyond
|
||||||
|
/// this count are pruned after each write.
|
||||||
|
const MAX_REPORTS_PER_STORY: usize = 5;
|
||||||
|
|
||||||
|
/// Write the full untruncated merge output to
|
||||||
|
/// `.huskies/merge_reports/{story_id}-{unix_timestamp}.log` and prune older
|
||||||
|
/// reports for the same story beyond [`MAX_REPORTS_PER_STORY`].
|
||||||
|
///
|
||||||
|
/// Returns the path to the written file (relative to `project_root`) for
|
||||||
|
/// inclusion in tool responses, or `None` if the write failed — a report is
|
||||||
|
/// a convenience artifact, so a filesystem hiccup here must not fail the
|
||||||
|
/// merge itself.
|
||||||
|
pub fn write_merge_report(project_root: &Path, story_id: &str, output: &str) -> Option<String> {
|
||||||
|
let dir = project_root.join(".huskies").join(MERGE_REPORTS_DIR);
|
||||||
|
std::fs::create_dir_all(&dir).ok()?;
|
||||||
|
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let file_name = format!("{story_id}-{timestamp}.log");
|
||||||
|
std::fs::write(dir.join(&file_name), output).ok()?;
|
||||||
|
|
||||||
|
prune_old_reports(&dir, story_id);
|
||||||
|
|
||||||
|
Some(format!(".huskies/{MERGE_REPORTS_DIR}/{file_name}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the oldest report files for `story_id` in `dir` beyond
|
||||||
|
/// [`MAX_REPORTS_PER_STORY`]. Filenames embed a unix timestamp, so lexical
|
||||||
|
/// sort order matches chronological order.
|
||||||
|
fn prune_old_reports(dir: &Path, story_id: &str) {
|
||||||
|
let prefix = format!("{story_id}-");
|
||||||
|
let Ok(read_dir) = std::fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut paths: Vec<PathBuf> = read_dir
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".log"))
|
||||||
|
})
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.collect();
|
||||||
|
paths.sort();
|
||||||
|
|
||||||
|
if paths.len() > MAX_REPORTS_PER_STORY {
|
||||||
|
for old in &paths[..paths.len() - MAX_REPORTS_PER_STORY] {
|
||||||
|
let _ = std::fs::remove_file(old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_merge_report_creates_file_with_full_output() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let output = "full untruncated gate output\nline 2\n";
|
||||||
|
let path = write_merge_report(tmp.path(), "42_story_foo", output)
|
||||||
|
.expect("write_merge_report should succeed");
|
||||||
|
assert!(path.starts_with(".huskies/merge_reports/42_story_foo-"));
|
||||||
|
assert!(path.ends_with(".log"));
|
||||||
|
let full_path = tmp.path().join(&path);
|
||||||
|
assert_eq!(std::fs::read_to_string(full_path).unwrap(), output);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_merge_report_prunes_beyond_max_per_story() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let dir = tmp.path().join(".huskies").join(MERGE_REPORTS_DIR);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
// Seed MAX_REPORTS_PER_STORY + 2 files with distinct, ordered names
|
||||||
|
// (avoids relying on real-clock timestamps to establish ordering).
|
||||||
|
for i in 0..(MAX_REPORTS_PER_STORY + 2) {
|
||||||
|
std::fs::write(
|
||||||
|
dir.join(format!("77_story-{i:010}.log")),
|
||||||
|
format!("report {i}"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
prune_old_reports(&dir, "77_story");
|
||||||
|
|
||||||
|
let remaining: Vec<String> = std::fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(remaining.len(), MAX_REPORTS_PER_STORY);
|
||||||
|
// The two oldest (lowest-numbered) files must be gone.
|
||||||
|
assert!(!remaining.contains(&"77_story-0000000000.log".to_string()));
|
||||||
|
assert!(!remaining.contains(&"77_story-0000000001.log".to_string()));
|
||||||
|
// The newest must survive.
|
||||||
|
assert!(remaining.contains(&format!("77_story-{:010}.log", MAX_REPORTS_PER_STORY + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_merge_report_prune_only_affects_matching_story() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let dir = tmp.path().join(".huskies").join(MERGE_REPORTS_DIR);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(dir.join("99_other-0000000000.log"), "keep me").unwrap();
|
||||||
|
|
||||||
|
for i in 0..(MAX_REPORTS_PER_STORY + 2) {
|
||||||
|
std::fs::write(
|
||||||
|
dir.join(format!("77_story-{i:010}.log")),
|
||||||
|
format!("report {i}"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
prune_old_reports(&dir, "77_story");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
dir.join("99_other-0000000000.log").exists(),
|
||||||
|
"pruning one story's reports must not touch another story's files"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_merge_report_returns_none_when_project_root_is_unwritable_file() {
|
||||||
|
// Passing a path that is a regular file (not a directory) makes
|
||||||
|
// create_dir_all fail, exercising the graceful-`None` path.
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let not_a_dir = tmp.path().join("not_a_dir");
|
||||||
|
std::fs::write(¬_a_dir, "i am a file").unwrap();
|
||||||
|
assert!(write_merge_report(¬_a_dir, "1_story", "output").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,13 @@
|
|||||||
pub mod io;
|
pub mod io;
|
||||||
/// Pure merge-status message formatting.
|
/// Pure merge-status message formatting.
|
||||||
pub mod status;
|
pub mod status;
|
||||||
|
/// Pure condensed gate/merge-failure summaries.
|
||||||
|
pub mod summary;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use status::format_merge_status_message;
|
pub use status::format_merge_status_message;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use summary::{summarize_merge_failure_kind, summarize_merge_result};
|
||||||
|
|
||||||
// ── Error type ────────────────────────────────────────────────────────────────
|
// ── Error type ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ mod tests {
|
|||||||
result,
|
result,
|
||||||
worktree_cleaned_up: false,
|
worktree_cleaned_up: false,
|
||||||
story_archived: false,
|
story_archived: false,
|
||||||
|
report_path: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//! Condensed summaries of gate/merge failure output for MCP tool responses
|
||||||
|
//! and chat notifications.
|
||||||
|
//!
|
||||||
|
//! Full gate/test output can run into hundreds of KB; these pure functions
|
||||||
|
//! reduce it to a bounded, human-scannable digest: the failing gate's name,
|
||||||
|
//! parsed failing test names (when the output looks like a `cargo test`
|
||||||
|
//! run), and the last [`SUMMARY_TAIL_LINES`] lines of raw output. No I/O.
|
||||||
|
|
||||||
|
use crate::agents::gates::GateFailureKind;
|
||||||
|
use crate::agents::merge::MergeResult;
|
||||||
|
use crate::pipeline_state::MergeFailureKind;
|
||||||
|
use crate::service::notifications::format::truncate_gate_output;
|
||||||
|
|
||||||
|
/// Number of trailing output lines retained in a gate-failure summary.
|
||||||
|
///
|
||||||
|
/// Kept a few lines under `notifications::format::MERGE_FAILURE_TAIL_LINES`
|
||||||
|
/// (30): chat notifications re-apply that cap on top of this summary's
|
||||||
|
/// `Failing gate:`/`Failing tests:`/`--- output tail ---` header lines, and
|
||||||
|
/// the total must stay at or under 30 lines so that second pass is a no-op
|
||||||
|
/// instead of truncating the header off the front of an already-shaped
|
||||||
|
/// summary.
|
||||||
|
pub const SUMMARY_TAIL_LINES: usize = 25;
|
||||||
|
|
||||||
|
/// Parse `cargo test` output for the list of failing test names.
|
||||||
|
///
|
||||||
|
/// libtest prints one or more `failures:` sections listing the fully
|
||||||
|
/// qualified name of each failing test on its own indented line, terminated
|
||||||
|
/// by a blank line or the `test result:` summary line. Returns names in
|
||||||
|
/// first-seen order with duplicates removed (a name can repeat across the
|
||||||
|
/// `failures:` section and the final summary re-listing).
|
||||||
|
pub fn extract_failing_test_names(output: &str) -> Vec<String> {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
let mut in_failures = false;
|
||||||
|
for line in output.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed == "failures:" {
|
||||||
|
in_failures = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if in_failures {
|
||||||
|
if trimmed.is_empty() || trimmed.starts_with("test result:") {
|
||||||
|
in_failures = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !names.iter().any(|n: &String| n == trimmed) {
|
||||||
|
names.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable label for the gate/step that produced a [`MergeResult`].
|
||||||
|
fn failing_gate_label(result: &MergeResult) -> &'static str {
|
||||||
|
match result {
|
||||||
|
MergeResult::Success { .. } => "none",
|
||||||
|
MergeResult::Conflict { .. } => "git merge",
|
||||||
|
MergeResult::GateFailure { failure_kind, .. } => match failure_kind {
|
||||||
|
Some(GateFailureKind::Fmt) => "fmt",
|
||||||
|
Some(GateFailureKind::Lint) => "clippy",
|
||||||
|
Some(GateFailureKind::Test) => "tests",
|
||||||
|
Some(GateFailureKind::SourceMapCheck) => "source-map-check",
|
||||||
|
Some(GateFailureKind::ContentConflict) => "git merge",
|
||||||
|
Some(GateFailureKind::Build) => "build",
|
||||||
|
Some(GateFailureKind::Other) | None => "unknown",
|
||||||
|
},
|
||||||
|
MergeResult::NoCommits { .. } => "no commits",
|
||||||
|
MergeResult::Other { .. } => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable label for the gate/step that produced a [`MergeFailureKind`].
|
||||||
|
fn failing_gate_label_for_kind(kind: &MergeFailureKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
MergeFailureKind::ConflictDetected(_) => "git merge",
|
||||||
|
MergeFailureKind::GatesFailed(_) => "quality gates",
|
||||||
|
MergeFailureKind::EmptyDiff => "empty diff",
|
||||||
|
MergeFailureKind::NoCommits => "no commits",
|
||||||
|
MergeFailureKind::Other(_) => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a condensed summary: a `Failing gate:` line, an optional
|
||||||
|
/// `Failing tests:` line (when any are parseable), and the last
|
||||||
|
/// [`SUMMARY_TAIL_LINES`] lines of `output`.
|
||||||
|
fn summarize(label: &str, output: &str) -> String {
|
||||||
|
let mut summary = format!("Failing gate: {label}\n");
|
||||||
|
let test_names = extract_failing_test_names(output);
|
||||||
|
if !test_names.is_empty() {
|
||||||
|
summary.push_str(&format!("Failing tests: {}\n", test_names.join(", ")));
|
||||||
|
}
|
||||||
|
summary.push_str("--- output tail ---\n");
|
||||||
|
summary.push_str(&truncate_gate_output(output, SUMMARY_TAIL_LINES));
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summarize a completed [`MergeResult`] for MCP tool responses.
|
||||||
|
///
|
||||||
|
/// Returns an empty string for [`MergeResult::Success`] — successful merges
|
||||||
|
/// carry no gate-failure summary.
|
||||||
|
pub fn summarize_merge_result(result: &MergeResult) -> String {
|
||||||
|
if matches!(result, MergeResult::Success { .. }) {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
summarize(failing_gate_label(result), result.output())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summarize a [`MergeFailureKind`] for chat notifications, so the same
|
||||||
|
/// bounded shaping (label + parsed test names + output tail) reaches the
|
||||||
|
/// room instead of the raw gate/conflict output that `display_reason`
|
||||||
|
/// embeds verbatim.
|
||||||
|
pub fn summarize_merge_failure_kind(kind: &MergeFailureKind) -> String {
|
||||||
|
summarize(failing_gate_label_for_kind(kind), &kind.to_gate_output())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::agents::gates::GateFailureKind;
|
||||||
|
|
||||||
|
// ── extract_failing_test_names ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_failing_test_names_single_failures_section() {
|
||||||
|
let output = "running 3 tests\ntest foo::bar ... FAILED\ntest foo::baz ... ok\n\n\
|
||||||
|
failures:\n foo::bar\n\ntest result: FAILED. 1 passed; 1 failed; 0 ignored\n";
|
||||||
|
assert_eq!(extract_failing_test_names(output), vec!["foo::bar"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_failing_test_names_dedupes_across_sections() {
|
||||||
|
// cargo test prints the failing list once mid-output and again in the
|
||||||
|
// final "failures:" summary block; both must collapse to one entry.
|
||||||
|
let output = "failures:\n foo::bar\n\nfailures:\n foo::bar\n\ntest result: FAILED.\n";
|
||||||
|
assert_eq!(extract_failing_test_names(output), vec!["foo::bar"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_failing_test_names_multiple_names() {
|
||||||
|
let output = "failures:\n a::one\n b::two\n\ntest result: FAILED.\n";
|
||||||
|
assert_eq!(extract_failing_test_names(output), vec!["a::one", "b::two"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_failing_test_names_empty_when_no_failures_section() {
|
||||||
|
let output = "test result: ok. 3 passed; 0 failed\n";
|
||||||
|
assert!(extract_failing_test_names(output).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_failing_test_names_empty_on_build_error_output() {
|
||||||
|
let output = "error[E0063]: missing field `plan` in initializer of `Stage`\n";
|
||||||
|
assert!(extract_failing_test_names(output).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── summarize_merge_result ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summarize_merge_result_success_is_empty() {
|
||||||
|
let result = MergeResult::Success {
|
||||||
|
conflicts_resolved: false,
|
||||||
|
conflict_details: None,
|
||||||
|
gate_output: "all good".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(summarize_merge_result(&result), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summarize_merge_result_build_error_names_the_gate() {
|
||||||
|
let result = MergeResult::GateFailure {
|
||||||
|
output: "error[E0063]: missing field `plan`".to_string(),
|
||||||
|
failure_kind: Some(GateFailureKind::Build),
|
||||||
|
};
|
||||||
|
let summary = summarize_merge_result(&result);
|
||||||
|
assert!(summary.starts_with("Failing gate: build\n"));
|
||||||
|
assert!(summary.contains("error[E0063]"));
|
||||||
|
assert!(!summary.contains("Failing tests:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summarize_merge_result_test_failure_includes_names() {
|
||||||
|
let output = "running 1 test\ntest foo::bar ... FAILED\n\n\
|
||||||
|
failures:\n foo::bar\n\ntest result: FAILED. 0 passed; 1 failed\n";
|
||||||
|
let result = MergeResult::GateFailure {
|
||||||
|
output: output.to_string(),
|
||||||
|
failure_kind: Some(GateFailureKind::Test),
|
||||||
|
};
|
||||||
|
let summary = summarize_merge_result(&result);
|
||||||
|
assert!(summary.starts_with("Failing gate: tests\n"));
|
||||||
|
assert!(summary.contains("Failing tests: foo::bar\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summarize_merge_result_conflict_names_git_merge() {
|
||||||
|
let result = MergeResult::Conflict {
|
||||||
|
details: Some("CONFLICT in src/lib.rs".to_string()),
|
||||||
|
output: "merge failed".to_string(),
|
||||||
|
};
|
||||||
|
let summary = summarize_merge_result(&result);
|
||||||
|
assert!(summary.starts_with("Failing gate: git merge\n"));
|
||||||
|
assert!(summary.contains("merge failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summarize_merge_result_truncates_long_output() {
|
||||||
|
let lines: Vec<String> = (1..=100).map(|i| format!("line{i}")).collect();
|
||||||
|
let output = lines.join("\n");
|
||||||
|
let result = MergeResult::GateFailure {
|
||||||
|
output,
|
||||||
|
failure_kind: Some(GateFailureKind::Test),
|
||||||
|
};
|
||||||
|
let summary = summarize_merge_result(&result);
|
||||||
|
assert!(summary.contains("output truncated"));
|
||||||
|
assert!(summary.contains("line100"));
|
||||||
|
assert!(
|
||||||
|
!summary.contains("line1\n"),
|
||||||
|
"line1 should have been dropped by the tail"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── summarize_merge_failure_kind ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summarize_merge_failure_kind_gates_failed() {
|
||||||
|
let kind = MergeFailureKind::GatesFailed("test result: FAILED. 1 failed".to_string());
|
||||||
|
let summary = summarize_merge_failure_kind(&kind);
|
||||||
|
assert!(summary.starts_with("Failing gate: quality gates\n"));
|
||||||
|
assert!(summary.contains("test result: FAILED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summarize_merge_failure_kind_does_not_include_raw_prefix_sentence() {
|
||||||
|
// display_reason() would prefix "Quality gates failed: "; the summary
|
||||||
|
// must not duplicate that human-sentence framing.
|
||||||
|
let kind = MergeFailureKind::GatesFailed("boom".to_string());
|
||||||
|
let summary = summarize_merge_failure_kind(&kind);
|
||||||
|
assert!(!summary.contains("Quality gates failed:"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user