diff --git a/server/src/chat/transport/matrix/notifications.rs b/server/src/chat/transport/matrix/notifications.rs index a6f640f3..3ad84581 100644 --- a/server/src/chat/transport/matrix/notifications.rs +++ b/server/src/chat/transport/matrix/notifications.rs @@ -141,30 +141,6 @@ const RATE_LIMIT_DEBOUNCE: Duration = Duration::from_secs(60); /// into a single notification (only the final stage is announced). const STAGE_TRANSITION_DEBOUNCE: Duration = Duration::from_millis(200); -/// Format a rate limit hard block notification message with scheduled resume time. -/// -/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. -pub fn format_rate_limit_hard_block_notification( - item_id: &str, - story_name: Option<&str>, - agent_name: &str, - resume_at: chrono::DateTime, -) -> (String, String) { - let number = extract_story_number(item_id).unwrap_or(item_id); - let name = story_name.unwrap_or(item_id); - let local_time = resume_at.with_timezone(&chrono::Local); - let resume_str = local_time.format("%Y-%m-%d %H:%M").to_string(); - - let plain = format!( - "\u{1f6d1} #{number} {name} \u{2014} {agent_name} hit a hard rate limit; \ - will auto-resume at {resume_str}" - ); - let html = format!( - "\u{1f6d1} #{number} {name} \u{2014} \ - {agent_name} hit a hard rate limit; will auto-resume at {resume_str}" - ); - (plain, html) -} /// Format a rate limit warning notification message. /// @@ -383,39 +359,13 @@ pub fn spawn_notification_listener( ref agent_name, reset_at, }) => { - // Debounce: reuse the same key as RateLimitWarning so both - // types are rate-limited together for the same agent. - let debounce_key = format!("{story_id}:{agent_name}"); - let now = Instant::now(); - if let Some(&last) = rate_limit_last_notified.get(&debounce_key) - && now.duration_since(last) < RATE_LIMIT_DEBOUNCE - { - slog!( - "[bot] Rate-limit hard-block notification debounced for \ - {story_id}:{agent_name}" - ); - continue; - } - rate_limit_last_notified.insert(debounce_key, now); - - let story_name = find_story_name_any_stage(&project_root, story_id); - let (plain, html) = format_rate_limit_hard_block_notification( - story_id, - story_name.as_deref(), - agent_name, - reset_at, + // Log server-side for debugging; do NOT post to Matrix. + // Hard-block auto-resume is normal operation — the status + // command already surfaces rate-limit state via emoji. + slog!( + "[bot] Rate-limit hard block for {story_id}/{agent_name}, \ + auto-resume at {reset_at}" ); - - slog!("[bot] Sending rate-limit hard-block notification: {plain}"); - - for room_id in &get_room_ids() { - if let Err(e) = transport.send_message(room_id, &plain, &html).await { - slog!( - "[bot] Failed to send rate-limit hard-block notification \ - to {room_id}: {e}" - ); - } - } } Ok(WatcherEvent::ConfigChanged) => { // Hot-reload: pick up any changes to rate_limit_notifications. @@ -1062,17 +1012,10 @@ mod tests { assert_eq!(calls.len(), 0, "RateLimitWarning should be suppressed when rate_limit_notifications = false"); } - /// AC3: RateLimitHardBlock is always sent regardless of rate_limit_notifications. + /// RateLimitHardBlock is never posted to Matrix — it is logged server-side only. #[tokio::test] - async fn rate_limit_hard_block_always_sent_when_config_false() { + async fn rate_limit_hard_block_never_sent_to_matrix() { let tmp = tempfile::tempdir().unwrap(); - let sk_dir = tmp.path().join(".huskies"); - std::fs::create_dir_all(&sk_dir).unwrap(); - std::fs::write( - sk_dir.join("project.toml"), - "rate_limit_notifications = false\n", - ) - .unwrap(); let (watcher_tx, watcher_rx) = broadcast::channel::(16); let (transport, calls) = MockTransport::new(); @@ -1094,7 +1037,7 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(100)).await; let calls = calls.lock().unwrap(); - assert_eq!(calls.len(), 1, "RateLimitHardBlock should always be sent"); + assert_eq!(calls.len(), 0, "RateLimitHardBlock must not post to Matrix"); } /// AC3: StoryBlocked is always sent regardless of rate_limit_notifications.