huskies: merge 616_story_extract_notifications_service
This commit is contained in:
@@ -0,0 +1,923 @@
|
||||
//! I/O side of the notifications service.
|
||||
//!
|
||||
//! This is the **only** file inside `service/notifications/` that may perform
|
||||
//! side effects: reading from the CRDT content store, loading configuration,
|
||||
//! and spawning the background listener task.
|
||||
|
||||
use crate::chat::ChatTransport;
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use crate::slog;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::events::classify;
|
||||
use super::filter::{STAGE_TRANSITION_DEBOUNCE, should_send_rate_limit};
|
||||
use super::format::{
|
||||
format_blocked_notification, format_error_notification, format_rate_limit_notification,
|
||||
format_stage_notification, stage_display_name,
|
||||
};
|
||||
use super::route::rooms_for_notification;
|
||||
|
||||
/// Read the story name from the CRDT content store's YAML front matter.
|
||||
///
|
||||
/// Returns `None` if the item is not in the content store or has no parseable name.
|
||||
pub fn read_story_name(_project_root: &Path, _stage: &str, item_id: &str) -> Option<String> {
|
||||
let contents = crate::db::read_content(item_id)?;
|
||||
let meta = parse_front_matter(&contents).ok()?;
|
||||
meta.name
|
||||
}
|
||||
|
||||
/// Look up a story name from the CRDT content store regardless of stage.
|
||||
///
|
||||
/// Used for events (like rate-limit warnings) that arrive without a known stage.
|
||||
fn find_story_name_any_stage(project_root: &Path, item_id: &str) -> Option<String> {
|
||||
read_story_name(project_root, "", item_id)
|
||||
}
|
||||
|
||||
/// Spawn a background task that listens for watcher events and posts
|
||||
/// stage-transition notifications to all configured rooms via the
|
||||
/// [`ChatTransport`] abstraction.
|
||||
///
|
||||
/// `get_room_ids` is called on each notification to obtain the current list of
|
||||
/// destination room IDs. Pass a closure that returns a static list for Matrix
|
||||
/// and Slack, or one that reads from a runtime `Arc<Mutex<HashSet<String>>>`
|
||||
/// for WhatsApp ambient senders.
|
||||
pub fn spawn_notification_listener(
|
||||
transport: Arc<dyn ChatTransport>,
|
||||
get_room_ids: impl Fn() -> Vec<String> + Send + 'static,
|
||||
watcher_rx: broadcast::Receiver<WatcherEvent>,
|
||||
project_root: PathBuf,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut rx = watcher_rx;
|
||||
// Load initial config; re-loaded on ConfigChanged events.
|
||||
let mut config = ProjectConfig::load(&project_root).unwrap_or_default();
|
||||
// Tracks when a rate-limit notification was last sent for each
|
||||
// "story_id:agent_name" key, to debounce repeated warnings.
|
||||
let mut rate_limit_last_notified: HashMap<String, Instant> = HashMap::new();
|
||||
|
||||
// Pending stage-transition notifications, keyed by item_id.
|
||||
// Value: (from_display, to_stage_key, story_name).
|
||||
// Rapid successive transitions for the same item are coalesced: the
|
||||
// original from_display is kept while to_stage_key is updated to the
|
||||
// latest destination, so only one notification fires for the final stage.
|
||||
let mut pending_transitions: HashMap<String, (String, String, Option<String>)> =
|
||||
HashMap::new();
|
||||
let mut flush_deadline: Option<tokio::time::Instant> = None;
|
||||
|
||||
loop {
|
||||
// Wait for the next event, or flush pending transitions when the
|
||||
// debounce window expires.
|
||||
let recv_result = if let Some(deadline) = flush_deadline {
|
||||
tokio::time::timeout_at(deadline, rx.recv()).await.ok()
|
||||
} else {
|
||||
Some(rx.recv().await)
|
||||
};
|
||||
|
||||
if recv_result.is_none() {
|
||||
// Flush all coalesced stage-transition notifications.
|
||||
for (item_id, (from_display, to_stage_key, story_name)) in
|
||||
pending_transitions.drain()
|
||||
{
|
||||
let to_display = stage_display_name(&to_stage_key);
|
||||
let (plain, html) = format_stage_notification(
|
||||
&item_id,
|
||||
story_name.as_deref(),
|
||||
&from_display,
|
||||
to_display,
|
||||
);
|
||||
slog!("[bot] Sending stage 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 notification to {room_id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
flush_deadline = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
let event = match recv_result.unwrap() {
|
||||
Ok(ev) => ev,
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
slog!("[bot] Notification listener lagged, skipped {n} events");
|
||||
continue;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
slog!("[bot] Watcher channel closed, stopping notification listener");
|
||||
// Flush any coalesced transitions that haven't fired yet.
|
||||
for (item_id, (from_display, to_stage_key, story_name)) in
|
||||
pending_transitions.drain()
|
||||
{
|
||||
let to_display = stage_display_name(&to_stage_key);
|
||||
let (plain, html) = format_stage_notification(
|
||||
&item_id,
|
||||
story_name.as_deref(),
|
||||
&from_display,
|
||||
to_display,
|
||||
);
|
||||
slog!("[bot] Sending stage 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 notification to {room_id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
use super::events::EventAction;
|
||||
match classify(&event) {
|
||||
EventAction::StageTransition => {
|
||||
// WorkItem with a known from_stage — extract the fields.
|
||||
let WatcherEvent::WorkItem {
|
||||
ref stage,
|
||||
ref item_id,
|
||||
ref from_stage,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let from_display = stage_display_name(from_stage.as_deref().unwrap_or(""));
|
||||
|
||||
// Look up the story name in the expected stage directory; fall
|
||||
// back to a full search so stale events still show the name.
|
||||
let story_name = read_story_name(&project_root, stage, item_id)
|
||||
.or_else(|| find_story_name_any_stage(&project_root, item_id));
|
||||
|
||||
// Buffer the transition. If this item_id is already pending (rapid
|
||||
// succession), update to_stage_key to the latest destination while
|
||||
// preserving the original from_display.
|
||||
pending_transitions
|
||||
.entry(item_id.clone())
|
||||
.and_modify(|e| {
|
||||
e.1 = stage.clone();
|
||||
if story_name.is_some() {
|
||||
e.2 = story_name.clone();
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| (from_display.to_string(), stage.clone(), story_name));
|
||||
|
||||
// Start or extend the debounce window.
|
||||
flush_deadline = Some(tokio::time::Instant::now() + STAGE_TRANSITION_DEBOUNCE);
|
||||
}
|
||||
EventAction::MergeFailure => {
|
||||
let WatcherEvent::MergeFailure {
|
||||
ref story_id,
|
||||
ref reason,
|
||||
} = event
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let story_name = read_story_name(&project_root, "4_merge", story_id);
|
||||
let (plain, html) =
|
||||
format_error_notification(story_id, story_name.as_deref(), reason);
|
||||
slog!("[bot] Sending error 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 error notification to {room_id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
EventAction::RateLimitWarning => {
|
||||
let WatcherEvent::RateLimitWarning {
|
||||
ref story_id,
|
||||
ref agent_name,
|
||||
} = event
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !config.rate_limit_notifications {
|
||||
slog!(
|
||||
"[bot] RateLimitWarning suppressed by config for \
|
||||
{story_id}:{agent_name}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let debounce_key = format!("{story_id}:{agent_name}");
|
||||
let now = Instant::now();
|
||||
if !should_send_rate_limit(
|
||||
rate_limit_last_notified.get(&debounce_key).copied(),
|
||||
now,
|
||||
) {
|
||||
slog!(
|
||||
"[bot] Rate-limit 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_notification(story_id, story_name.as_deref(), agent_name);
|
||||
slog!("[bot] Sending rate-limit 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 rate-limit notification \
|
||||
to {room_id}: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
EventAction::StoryBlocked => {
|
||||
let WatcherEvent::StoryBlocked {
|
||||
ref story_id,
|
||||
ref reason,
|
||||
} = event
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
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 &rooms_for_notification(&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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
EventAction::LogOnly => {
|
||||
// Hard-block: log server-side for debugging; do NOT post to chat.
|
||||
// Hard-block auto-resume is normal operation — the status command
|
||||
// already surfaces rate-limit state via emoji.
|
||||
if let WatcherEvent::RateLimitHardBlock {
|
||||
ref story_id,
|
||||
ref agent_name,
|
||||
reset_at,
|
||||
} = event
|
||||
{
|
||||
slog!(
|
||||
"[bot] Rate-limit hard block for {story_id}/{agent_name}, \
|
||||
auto-resume at {reset_at}"
|
||||
);
|
||||
}
|
||||
}
|
||||
EventAction::ReloadConfig => {
|
||||
if let Ok(new_cfg) = ProjectConfig::load(&project_root) {
|
||||
config = new_cfg;
|
||||
}
|
||||
}
|
||||
EventAction::Skip => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chat::MessageId;
|
||||
use async_trait::async_trait;
|
||||
|
||||
// ── MockTransport ───────────────────────────────────────────────────────
|
||||
|
||||
type CallLog = Arc<std::sync::Mutex<Vec<(String, String, String)>>>;
|
||||
|
||||
/// Records every `send_message` call for inspection in tests.
|
||||
struct MockTransport {
|
||||
calls: CallLog,
|
||||
}
|
||||
|
||||
impl MockTransport {
|
||||
fn new() -> (Arc<Self>, CallLog) {
|
||||
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
(
|
||||
Arc::new(Self {
|
||||
calls: Arc::clone(&calls),
|
||||
}),
|
||||
calls,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl crate::chat::ChatTransport for MockTransport {
|
||||
async fn send_message(
|
||||
&self,
|
||||
room_id: &str,
|
||||
plain: &str,
|
||||
html: &str,
|
||||
) -> Result<MessageId, String> {
|
||||
self.calls.lock().unwrap().push((
|
||||
room_id.to_string(),
|
||||
plain.to_string(),
|
||||
html.to_string(),
|
||||
));
|
||||
Ok("mock-msg-id".to_string())
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
_room_id: &str,
|
||||
_id: &str,
|
||||
_plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── spawn_notification_listener: RateLimitWarning ───────────────────────
|
||||
|
||||
/// AC2 + AC3: when a RateLimitWarning event arrives, send_message is called
|
||||
/// with a notification that names the agent and story.
|
||||
#[tokio::test]
|
||||
async fn rate_limit_warning_sends_notification_with_agent_and_story() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Seed story via CRDT (the only source of truth).
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"365_story_rate_limit",
|
||||
"2_current",
|
||||
"---\nname: Rate Limit Test Story\n---\n",
|
||||
);
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(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::RateLimitWarning {
|
||||
story_id: "365_story_rate_limit".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Give the spawned task time to process the event.
|
||||
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("365"), "plain should contain story number");
|
||||
assert!(
|
||||
plain.contains("Rate Limit Test Story"),
|
||||
"plain should contain story name"
|
||||
);
|
||||
assert!(plain.contains("coder-1"), "plain should contain agent name");
|
||||
assert!(
|
||||
plain.contains("rate limit"),
|
||||
"plain should mention rate limit"
|
||||
);
|
||||
}
|
||||
|
||||
/// AC4: a second RateLimitWarning for the same agent within the debounce
|
||||
/// window must NOT trigger a second notification.
|
||||
#[tokio::test]
|
||||
async fn rate_limit_warning_is_debounced() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
|| vec!["!room1:example.org".to_string()],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
// Send the same warning twice in rapid succession.
|
||||
for _ in 0..2 {
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_debounce".to_string(),
|
||||
agent_name: "coder-2".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
1,
|
||||
"Debounce should suppress the second notification"
|
||||
);
|
||||
}
|
||||
|
||||
/// AC4 (corollary): warnings for different agents are NOT debounced against
|
||||
/// each other — both should produce notifications.
|
||||
#[tokio::test]
|
||||
async fn rate_limit_warnings_for_different_agents_both_notify() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
|| vec!["!room1:example.org".to_string()],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-2".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
2,
|
||||
"Different agents should each trigger a notification"
|
||||
);
|
||||
}
|
||||
|
||||
// ── dynamic room IDs (WhatsApp ambient_rooms pattern) ───────────────────
|
||||
|
||||
/// Notifications are sent to the rooms returned by the closure at
|
||||
/// notification time, not at listener-spawn time. This verifies that a
|
||||
/// closure backed by a runtime set (e.g. WhatsApp ambient_rooms) delivers
|
||||
/// messages to the rooms present when the event fires.
|
||||
#[tokio::test]
|
||||
async fn stage_notification_uses_dynamic_room_ids() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Seed story via CRDT (the only source of truth).
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content("10_story_foo", "3_qa", "---\nname: Foo Story\n---\n");
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
let rooms: Arc<std::sync::Mutex<std::collections::HashSet<String>>> =
|
||||
Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
|
||||
let rooms_for_closure = Arc::clone(&rooms);
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
move || rooms_for_closure.lock().unwrap().iter().cloned().collect(),
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
// Add a room after the listener is spawned (simulates a user messaging first).
|
||||
rooms
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("phone:+15551234567".to_string());
|
||||
|
||||
watcher_tx
|
||||
.send(WatcherEvent::WorkItem {
|
||||
stage: "3_qa".to_string(),
|
||||
item_id: "10_story_foo".to_string(),
|
||||
action: "qa".to_string(),
|
||||
commit_msg: "huskies: qa 10_story_foo".to_string(),
|
||||
from_stage: Some("2_current".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Wait longer than STAGE_TRANSITION_DEBOUNCE (200ms) so the coalesced
|
||||
// notification flushes.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
1,
|
||||
"Should deliver to the dynamically added room"
|
||||
);
|
||||
assert_eq!(calls[0].0, "phone:+15551234567");
|
||||
assert!(
|
||||
calls[0].1.contains("10"),
|
||||
"plain should contain story number"
|
||||
);
|
||||
assert!(
|
||||
calls[0].1.contains("Foo Story"),
|
||||
"plain should contain story name"
|
||||
);
|
||||
}
|
||||
|
||||
/// When no rooms are registered (e.g. no WhatsApp users have messaged yet),
|
||||
/// no notifications are sent and the listener does not panic.
|
||||
#[tokio::test]
|
||||
async fn stage_notification_with_no_rooms_is_silent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(transport, Vec::new, watcher_rx, tmp.path().to_path_buf());
|
||||
|
||||
watcher_tx
|
||||
.send(WatcherEvent::WorkItem {
|
||||
stage: "3_qa".to_string(),
|
||||
item_id: "10_story_foo".to_string(),
|
||||
action: "qa".to_string(),
|
||||
commit_msg: "huskies: qa 10_story_foo".to_string(),
|
||||
from_stage: Some("2_current".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");
|
||||
}
|
||||
|
||||
// ── read_story_name ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn read_story_name_reads_from_front_matter() {
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9942_story_my_feature",
|
||||
"2_current",
|
||||
"---\nname: My Cool Feature\n---\n# Story\n",
|
||||
);
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let name = read_story_name(tmp.path(), "2_current", "9942_story_my_feature");
|
||||
assert_eq!(name.as_deref(), Some("My Cool Feature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_story_name_returns_none_for_missing_file() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let name = read_story_name(tmp.path(), "2_current", "99_story_missing_notif_test");
|
||||
assert_eq!(name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_story_name_returns_none_for_missing_name_field() {
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9943_story_no_name",
|
||||
"2_current",
|
||||
"---\ncoverage_baseline: 50%\n---\n# Story\n",
|
||||
);
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let name = read_story_name(tmp.path(), "2_current", "9943_story_no_name");
|
||||
assert_eq!(name, None);
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
// Seed story via CRDT (the only source of truth).
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"425_story_blocking_test",
|
||||
"2_current",
|
||||
"---\nname: Blocking Test Story\n---\n",
|
||||
);
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(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::<WatcherEvent>(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");
|
||||
}
|
||||
|
||||
// ── rate_limit_notifications config flag ─────────────────────────────────
|
||||
|
||||
/// AC1+AC2: when rate_limit_notifications = false in project.toml,
|
||||
/// RateLimitWarning events are suppressed (no send_message call).
|
||||
#[tokio::test]
|
||||
async fn rate_limit_warning_suppressed_when_config_false() {
|
||||
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::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
|| vec!["!room1:example.org".to_string()],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_suppress".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
0,
|
||||
"RateLimitWarning should be suppressed when rate_limit_notifications = false"
|
||||
);
|
||||
}
|
||||
|
||||
/// RateLimitHardBlock is never posted to Matrix — it is logged server-side only.
|
||||
#[tokio::test]
|
||||
async fn rate_limit_hard_block_never_sent_to_matrix() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
|| vec!["!room1:example.org".to_string()],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
let reset_at = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitHardBlock {
|
||||
story_id: "42_story_hard_block".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
reset_at,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 0, "RateLimitHardBlock must not post to Matrix");
|
||||
}
|
||||
|
||||
/// AC3: StoryBlocked is always sent regardless of rate_limit_notifications.
|
||||
#[tokio::test]
|
||||
async fn story_blocked_always_sent_when_config_false() {
|
||||
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::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
|| vec!["!room1:example.org".to_string()],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
watcher_tx
|
||||
.send(WatcherEvent::StoryBlocked {
|
||||
story_id: "42_story_blocked".to_string(),
|
||||
reason: "retry limit exceeded".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1, "StoryBlocked should always be sent");
|
||||
}
|
||||
|
||||
/// AC5: Config is hot-reloaded — disabling rate_limit_notifications after
|
||||
/// startup suppresses subsequent RateLimitWarning events.
|
||||
#[tokio::test]
|
||||
async fn rate_limit_warning_suppressed_after_hot_reload() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk_dir = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&sk_dir).unwrap();
|
||||
// Start with notifications enabled.
|
||||
std::fs::write(
|
||||
sk_dir.join("project.toml"),
|
||||
"rate_limit_notifications = true\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
|| vec!["!room1:example.org".to_string()],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
// First warning is sent.
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_reload".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
// Disable notifications and trigger hot-reload.
|
||||
std::fs::write(
|
||||
sk_dir.join("project.toml"),
|
||||
"rate_limit_notifications = false\n",
|
||||
)
|
||||
.unwrap();
|
||||
watcher_tx.send(WatcherEvent::ConfigChanged).unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
// Second warning (different agent to bypass debounce) should be suppressed.
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_reload".to_string(),
|
||||
agent_name: "coder-2".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
1,
|
||||
"Only the first warning should be sent; second should be suppressed after hot-reload"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bug 549: synthetic events with from_stage=None must not notify ──────
|
||||
|
||||
/// Synthetic events (reassign, creation) have from_stage=None and must
|
||||
/// not produce stage-transition notifications. Before the fix, the
|
||||
/// inferred_from_stage fallback would emit e.g. "QA → Merge" for a
|
||||
/// reassign event within the merge stage.
|
||||
#[tokio::test]
|
||||
async fn synthetic_event_without_from_stage_does_not_notify() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
|| vec!["!room1:example.org".to_string()],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
// Synthetic reassign event within 4_merge — no actual stage change.
|
||||
watcher_tx
|
||||
.send(WatcherEvent::WorkItem {
|
||||
stage: "4_merge".to_string(),
|
||||
item_id: "549_story_skip_qa".to_string(),
|
||||
action: "reassign".to_string(),
|
||||
commit_msg: String::new(),
|
||||
from_stage: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
0,
|
||||
"Synthetic events with from_stage=None must not generate notifications"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stories that skip QA (qa: server) move directly from Current to Merge.
|
||||
/// The notification must say "Current → Merge", not "QA → Merge".
|
||||
#[tokio::test]
|
||||
async fn skip_qa_shows_current_to_merge_not_qa_to_merge() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let stage_dir = tmp.path().join(".huskies").join("work").join("4_merge");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
std::fs::write(
|
||||
stage_dir.join("549_story_skip_qa.md"),
|
||||
"---\nname: Skip QA Story\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
|| vec!["!room1:example.org".to_string()],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
// Story skips QA: from_stage is 2_current, not 3_qa.
|
||||
watcher_tx
|
||||
.send(WatcherEvent::WorkItem {
|
||||
stage: "4_merge".to_string(),
|
||||
item_id: "549_story_skip_qa".to_string(),
|
||||
action: "merge".to_string(),
|
||||
commit_msg: "huskies: merge 549_story_skip_qa".to_string(),
|
||||
from_stage: Some("2_current".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1, "Should send exactly one notification");
|
||||
assert!(
|
||||
calls[0].1.contains("Current \u{2192} Merge"),
|
||||
"Notification should say 'Current → Merge', got: {}",
|
||||
calls[0].1
|
||||
);
|
||||
assert!(
|
||||
!calls[0].1.contains("QA \u{2192} Merge"),
|
||||
"Must NOT say 'QA → Merge' when QA was skipped"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user