diff --git a/server/src/agents/pool/auto_assign/auto_assign.rs b/server/src/agents/pool/auto_assign/auto_assign.rs index 11ff2ecd..3107061c 100644 --- a/server/src/agents/pool/auto_assign/auto_assign.rs +++ b/server/src/agents/pool/auto_assign/auto_assign.rs @@ -76,12 +76,17 @@ impl AgentPool { .join(".storkit/work") .join(stage_dir) .join(format!("{story_id}.md")); + let empty_diff_reason = "Feature branch has no code changes — the coder agent \ + did not produce any commits."; let _ = crate::io::story_metadata::write_merge_failure( &story_path, - "Feature branch has no code changes — the coder agent \ - did not produce any commits.", + empty_diff_reason, ); let _ = crate::io::story_metadata::write_blocked(&story_path); + let _ = self.watcher_tx.send(crate::io::watcher::WatcherEvent::StoryBlocked { + story_id: story_id.to_string(), + reason: empty_diff_reason.to_string(), + }); continue; } diff --git a/server/src/agents/pool/pipeline.rs b/server/src/agents/pool/pipeline.rs index 7bd671a3..0d6f7334 100644 --- a/server/src/agents/pool/pipeline.rs +++ b/server/src/agents/pool/pipeline.rs @@ -122,8 +122,12 @@ impl AgentPool { let story_path = project_root .join(".storkit/work/2_current") .join(format!("{story_id}.md")); - if should_block_story(&story_path, config.max_retries, story_id, "coder") { + if let Some(reason) = should_block_story(&story_path, config.max_retries, story_id, "coder") { // Story has exceeded retry limit — do not restart. + let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked { + story_id: story_id.to_string(), + reason, + }); } else { slog!( "[pipeline] Coder '{agent_name}' failed gates for '{story_id}'. Restarting." @@ -221,8 +225,12 @@ impl AgentPool { let story_path = project_root .join(".storkit/work/3_qa") .join(format!("{story_id}.md")); - if should_block_story(&story_path, config.max_retries, story_id, "qa-coverage") { + if let Some(reason) = should_block_story(&story_path, config.max_retries, story_id, "qa-coverage") { // Story has exceeded retry limit — do not restart. + let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked { + story_id: story_id.to_string(), + reason, + }); } else { slog!( "[pipeline] QA coverage gate failed for '{story_id}'. Restarting QA." @@ -245,8 +253,12 @@ impl AgentPool { let story_path = project_root .join(".storkit/work/3_qa") .join(format!("{story_id}.md")); - if should_block_story(&story_path, config.max_retries, story_id, "qa") { + if let Some(reason) = should_block_story(&story_path, config.max_retries, story_id, "qa") { // Story has exceeded retry limit — do not restart. + let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked { + story_id: story_id.to_string(), + reason, + }); } else { slog!("[pipeline] QA failed gates for '{story_id}'. Restarting."); let context = format!( @@ -321,8 +333,12 @@ impl AgentPool { let story_path = project_root .join(".storkit/work/4_merge") .join(format!("{story_id}.md")); - if should_block_story(&story_path, config.max_retries, story_id, "mergemaster") { + if let Some(reason) = should_block_story(&story_path, config.max_retries, story_id, "mergemaster") { // Story has exceeded retry limit — do not restart. + let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked { + story_id: story_id.to_string(), + reason, + }); } else { slog!( "[pipeline] Post-merge tests failed for '{story_id}'. Restarting mergemaster." @@ -830,15 +846,15 @@ fn spawn_pipeline_advance( /// Increment retry_count and block the story if it exceeds `max_retries`. /// -/// Returns `true` if the story is now blocked (caller should NOT restart the agent). -/// Returns `false` if the story may be retried. +/// Returns `Some(reason)` if the story is now blocked (caller should NOT restart the agent). +/// Returns `None` if the story may be retried. /// When `max_retries` is 0, retry limits are disabled. -fn should_block_story(story_path: &Path, max_retries: u32, story_id: &str, stage_label: &str) -> bool { +fn should_block_story(story_path: &Path, max_retries: u32, story_id: &str, stage_label: &str) -> Option { use crate::io::story_metadata::{increment_retry_count, write_blocked}; if max_retries == 0 { // Retry limits disabled. - return false; + return None; } match increment_retry_count(story_path) { @@ -851,17 +867,19 @@ fn should_block_story(story_path: &Path, max_retries: u32, story_id: &str, stage if let Err(e) = write_blocked(story_path) { slog_error!("[pipeline] Failed to write blocked flag for '{story_id}': {e}"); } - true + Some(format!( + "Retry limit exceeded ({new_count}/{max_retries}) at {stage_label} stage" + )) } else { slog!( "[pipeline] Story '{story_id}' retry {new_count}/{max_retries} at {stage_label} stage." ); - false + None } } Err(e) => { slog_error!("[pipeline] Failed to increment retry_count for '{story_id}': {e}"); - false // Don't block on error — allow retry. + None // Don't block on error — allow retry. } } } diff --git a/server/src/chat/transport/matrix/notifications.rs b/server/src/chat/transport/matrix/notifications.rs index 567ab28b..a58ca345 100644 --- a/server/src/chat/transport/matrix/notifications.rs +++ b/server/src/chat/transport/matrix/notifications.rs @@ -115,6 +115,24 @@ fn find_story_name_any_stage(project_root: &Path, item_id: &str) -> Option, + reason: &str, +) -> (String, String) { + let number = extract_story_number(item_id).unwrap_or(item_id); + let name = story_name.unwrap_or(item_id); + + let plain = format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}"); + let html = format!( + "\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}" + ); + (plain, html) +} + /// Minimum time between rate-limit notifications for the same agent. const RATE_LIMIT_DEBOUNCE: Duration = Duration::from_secs(60); @@ -249,6 +267,27 @@ pub fn spawn_notification_listener( } } } + Ok(WatcherEvent::StoryBlocked { + ref story_id, + ref reason, + }) => { + let story_name = find_story_name_any_stage(&project_root, story_id); + let (plain, html) = format_blocked_notification( + story_id, + story_name.as_deref(), + reason, + ); + + slog!("[bot] Sending blocked 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 blocked notification to {room_id}: {e}" + ); + } + } + } Ok(_) => {} // Ignore non-work-item events Err(broadcast::error::RecvError::Lagged(n)) => { slog!( @@ -622,6 +661,104 @@ mod tests { ); } + // ── format_blocked_notification ───────────────────────────────────────── + + #[test] + fn format_blocked_notification_with_story_name() { + let (plain, html) = format_blocked_notification( + "425_story_blocking_reason", + Some("Blocking Reason Story"), + "Retry limit exceeded (3/3) at coder stage", + ); + assert_eq!( + plain, + "\u{1f6ab} #425 Blocking Reason Story \u{2014} BLOCKED: Retry limit exceeded (3/3) at coder stage" + ); + assert_eq!( + html, + "\u{1f6ab} #425 Blocking Reason Story \u{2014} BLOCKED: Retry limit exceeded (3/3) at coder stage" + ); + } + + #[test] + fn format_blocked_notification_falls_back_to_item_id() { + let (plain, _html) = + format_blocked_notification("42_story_thing", None, "empty diff"); + assert_eq!( + plain, + "\u{1f6ab} #42 42_story_thing \u{2014} BLOCKED: empty diff" + ); + } + + // ── spawn_notification_listener: StoryBlocked ─────────────────────────── + + /// AC1: when a StoryBlocked event arrives, send_message is called with a + /// notification that includes the story number, name, and reason. + #[tokio::test] + async fn story_blocked_sends_notification_with_reason() { + let tmp = tempfile::tempdir().unwrap(); + let stage_dir = tmp.path().join(".storkit").join("work").join("2_current"); + std::fs::create_dir_all(&stage_dir).unwrap(); + std::fs::write( + stage_dir.join("425_story_blocking_test.md"), + "---\nname: Blocking Test Story\n---\n", + ) + .unwrap(); + + let (watcher_tx, watcher_rx) = broadcast::channel::(16); + let (transport, calls) = MockTransport::new(); + + spawn_notification_listener( + transport, + || vec!["!room123:example.org".to_string()], + watcher_rx, + tmp.path().to_path_buf(), + ); + + watcher_tx.send(WatcherEvent::StoryBlocked { + story_id: "425_story_blocking_test".to_string(), + reason: "Retry limit exceeded (3/3) at coder stage".to_string(), + }).unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 1, "Expected exactly one notification"); + let (room_id, plain, html) = &calls[0]; + assert_eq!(room_id, "!room123:example.org"); + assert!(plain.contains("425"), "plain should contain story number"); + assert!(plain.contains("Blocking Test Story"), "plain should contain story name"); + assert!(plain.contains("BLOCKED"), "plain should contain BLOCKED label"); + assert!(plain.contains("Retry limit exceeded"), "plain should contain the reason"); + assert!(html.contains("BLOCKED"), "html should contain BLOCKED label"); + } + + /// StoryBlocked with no room registered should not panic. + #[tokio::test] + async fn story_blocked_with_no_rooms_is_silent() { + let tmp = tempfile::tempdir().unwrap(); + + let (watcher_tx, watcher_rx) = broadcast::channel::(16); + let (transport, calls) = MockTransport::new(); + + spawn_notification_listener( + transport, + Vec::new, + watcher_rx, + tmp.path().to_path_buf(), + ); + + watcher_tx.send(WatcherEvent::StoryBlocked { + story_id: "42_story_no_rooms".to_string(), + reason: "empty diff".to_string(), + }).unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 0, "No rooms means no notifications"); + } + // ── format_rate_limit_notification ───────────────────────────────────── #[test] diff --git a/server/src/http/ws.rs b/server/src/http/ws.rs index 82d9bb45..b0427197 100644 --- a/server/src/http/ws.rs +++ b/server/src/http/ws.rs @@ -158,10 +158,11 @@ impl From for Option { }), WatcherEvent::ConfigChanged => Some(WsResponse::AgentConfigChanged), WatcherEvent::AgentStateChanged => Some(WsResponse::AgentStateChanged), - // MergeFailure and RateLimitWarning are handled by the chat notification - // listener only; no WebSocket message is needed for the frontend. + // MergeFailure, RateLimitWarning, and StoryBlocked are handled by the + // chat notification listener only; no WebSocket message is needed for the frontend. WatcherEvent::MergeFailure { .. } => None, WatcherEvent::RateLimitWarning { .. } => None, + WatcherEvent::StoryBlocked { .. } => None, } } } diff --git a/server/src/io/watcher.rs b/server/src/io/watcher.rs index 1598f9b0..59a16394 100644 --- a/server/src/io/watcher.rs +++ b/server/src/io/watcher.rs @@ -67,6 +67,14 @@ pub enum WatcherEvent { /// Name of the agent that hit the rate limit. agent_name: String, }, + /// A story has been blocked (e.g. retry limit exceeded, empty diff). + /// Triggers a warning notification to configured chat rooms. + StoryBlocked { + /// Work item ID (e.g. `"42_story_my_feature"`). + story_id: String, + /// Human-readable reason the story was blocked. + reason: String, + }, } /// Return `true` if `path` is the root-level `.storkit/project.toml`, i.e.