huskies: merge 1185 story Bounded auto-retry for GatesFailed merges before requiring human intervention

This commit is contained in:
Huskies Agent
2026-07-17 12:11:27 +00:00
parent 5d672f5bf6
commit 0f4b0c9536
8 changed files with 708 additions and 2 deletions
@@ -28,6 +28,8 @@ pub enum EventAction {
},
/// Post a new-item-created notification.
NewItemCreated,
/// Post a merge-auto-retry notification naming the attempt and budget.
MergeAutoRetry,
/// Log server-side only; do not post to chat (e.g. hard rate-limit blocks).
LogOnly,
/// Reload the project configuration.
@@ -54,6 +56,7 @@ pub fn classify(event: &WatcherEvent) -> EventAction {
EventAction::AgentCompleted { success: *success }
}
WatcherEvent::NewItemCreated { .. } => EventAction::NewItemCreated,
WatcherEvent::MergeAutoRetry { .. } => EventAction::MergeAutoRetry,
_ => EventAction::Skip,
}
}
@@ -191,4 +194,14 @@ mod tests {
};
assert_eq!(classify(&event), EventAction::NewItemCreated);
}
#[test]
fn merge_auto_retry_is_classified_correctly() {
let event = WatcherEvent::MergeAutoRetry {
story_id: "1_story_foo".to_string(),
attempt: 1,
budget: 3,
};
assert_eq!(classify(&event), EventAction::MergeAutoRetry);
}
}
@@ -240,6 +240,38 @@ pub fn format_new_item_notification(
(plain, html)
}
/// Format a merge-auto-retry notification message.
///
/// Sent when a `GatesFailed` merge failure is automatically retried after a
/// delay (story 1185). Returns `(plain_text, html)` suitable for
/// `ChatTransport::send_message`.
pub fn format_merge_auto_retry_notification(
item_id: &str,
story_name: &str,
attempt: u32,
budget: u32,
) -> (String, String) {
let number = extract_item_number(item_id).unwrap_or(item_id);
let effective_name = if story_name.is_empty() {
None
} else {
Some(story_name)
};
let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default();
let name_html = effective_name
.map(|n| format!("<em>{n}</em> "))
.unwrap_or_default();
let plain = format!(
"\u{1f504} #{number} {name_plain}\u{2014} auto-retrying merge (attempt {attempt}/{budget})"
);
let html = format!(
"\u{1f504} <strong>#{number}</strong> {name_html}\u{2014} auto-retrying merge \
(attempt {attempt}/{budget})"
);
(plain, html)
}
/// Maximum number of trailing gate-output lines included in a merge-failure
/// chat notification.
///
@@ -595,6 +627,36 @@ mod tests {
assert_eq!(plain, "\u{1F916} #42 \u{2014} coder-1 started");
}
// ── format_merge_auto_retry_notification ──────────────────────────────────
#[test]
fn format_merge_auto_retry_notification_with_story_name() {
let (plain, html) =
format_merge_auto_retry_notification("42_story_foo", "My Feature", 1, 3);
assert_eq!(
plain,
"\u{1f504} #42 My Feature \u{2014} auto-retrying merge (attempt 1/3)"
);
assert_eq!(
html,
"\u{1f504} <strong>#42</strong> <em>My Feature</em> \u{2014} auto-retrying merge \
(attempt 1/3)"
);
}
#[test]
fn format_merge_auto_retry_notification_falls_back_to_number() {
let (plain, html) = format_merge_auto_retry_notification("42_story_foo", "", 2, 3);
assert_eq!(
plain,
"\u{1f504} #42 \u{2014} auto-retrying merge (attempt 2/3)"
);
assert_eq!(
html,
"\u{1f504} <strong>#42</strong> \u{2014} auto-retrying merge (attempt 2/3)"
);
}
// ── truncate_gate_output ──────────────────────────────────────────────────
#[test]
@@ -16,8 +16,9 @@ use super::super::filter::{AGENT_EVENT_DEBOUNCE, should_send_rate_limit};
use super::super::format::{
MERGE_FAILURE_TAIL_LINES, format_agent_completed_notification,
format_agent_started_notification, format_blocked_notification, format_error_notification,
format_new_item_notification, format_oauth_account_swapped, format_oauth_accounts_exhausted,
format_rate_limit_notification, truncate_gate_output,
format_merge_auto_retry_notification, format_new_item_notification,
format_oauth_account_swapped, format_oauth_accounts_exhausted, format_rate_limit_notification,
truncate_gate_output,
};
use super::super::route::rooms_for_notification;
use super::{find_story_name_any_stage, read_story_name};
@@ -295,6 +296,35 @@ pub fn spawn_notification_listener(
}
}
}
EventAction::MergeAutoRetry => {
if !config.status_push_enabled {
continue;
}
let WatcherEvent::MergeAutoRetry {
ref story_id,
attempt,
budget,
} = event
else {
continue;
};
let story_name = find_story_name_any_stage(&project_root, story_id);
let (plain, html) = format_merge_auto_retry_notification(
story_id,
&story_name,
attempt,
budget,
);
slog!("[bot] Sending merge-auto-retry notification: {plain}");
for room_id in &rooms_for_notification(&get_room_ids) {
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
slog!(
"[bot] Failed to send merge-auto-retry notification \
to {room_id}: {e}"
);
}
}
}
EventAction::LogOnly => {
// Hard-block: log server-side for debugging; do NOT post to chat.
// Hard-block auto-resume is normal operation — the status command
+2
View File
@@ -39,6 +39,8 @@ pub fn watcher_event_to_response(e: WatcherEvent) -> Option<WsResponse> {
WatcherEvent::AgentCompleted { .. } => None,
// Creation notifications are forwarded to chat transports only; no WebSocket message.
WatcherEvent::NewItemCreated { .. } => None,
// Merge-auto-retry notifications are forwarded to chat transports only; no WebSocket message.
WatcherEvent::MergeAutoRetry { .. } => None,
}
}