storkit: merge 462_bug_stage_transition_notifications_can_arrive_out_of_order_and_show_wrong_story_name
This commit is contained in:
@@ -136,6 +136,10 @@ pub fn format_blocked_notification(
|
||||
/// Minimum time between rate-limit notifications for the same agent.
|
||||
const RATE_LIMIT_DEBOUNCE: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Window during which rapid stage transitions for the same item are coalesced
|
||||
/// 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`.
|
||||
@@ -202,36 +206,87 @@ pub fn spawn_notification_listener(
|
||||
// "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 {
|
||||
match rx.recv().await {
|
||||
// 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 &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;
|
||||
}
|
||||
|
||||
match recv_result.unwrap() {
|
||||
Ok(WatcherEvent::WorkItem {
|
||||
ref stage,
|
||||
ref item_id,
|
||||
ref from_stage,
|
||||
..
|
||||
}) => {
|
||||
// Only notify on stage transitions, not creations.
|
||||
let Some(from_display) = inferred_from_stage(stage) else {
|
||||
continue;
|
||||
// Determine from_display: prefer the actual from_stage recorded
|
||||
// in the event (AC3); fall back to inference for synthetic events.
|
||||
let from_display = from_stage
|
||||
.as_deref()
|
||||
.map(stage_display_name)
|
||||
.or_else(|| inferred_from_stage(stage));
|
||||
let Some(from_display) = from_display else {
|
||||
continue; // creation or unknown transition — skip
|
||||
};
|
||||
let to_display = stage_display_name(stage);
|
||||
|
||||
let story_name = read_story_name(&project_root, stage, item_id);
|
||||
let (plain, html) = format_stage_notification(
|
||||
item_id,
|
||||
story_name.as_deref(),
|
||||
from_display,
|
||||
to_display,
|
||||
);
|
||||
// Look up the story name in the expected stage directory; fall
|
||||
// back to a full search so stale events still show the name (AC1).
|
||||
let story_name = read_story_name(&project_root, stage, item_id)
|
||||
.or_else(|| find_story_name_any_stage(&project_root, item_id));
|
||||
|
||||
slog!("[bot] Sending stage notification: {plain}");
|
||||
// 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 (AC2).
|
||||
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)
|
||||
});
|
||||
|
||||
for room_id in &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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Start or extend the debounce window.
|
||||
flush_deadline =
|
||||
Some(tokio::time::Instant::now() + STAGE_TRANSITION_DEBOUNCE);
|
||||
}
|
||||
Ok(WatcherEvent::MergeFailure {
|
||||
ref story_id,
|
||||
@@ -362,6 +417,28 @@ pub fn spawn_notification_listener(
|
||||
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 &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;
|
||||
}
|
||||
}
|
||||
@@ -550,9 +627,12 @@ mod tests {
|
||||
item_id: "10_story_foo".to_string(),
|
||||
action: "qa".to_string(),
|
||||
commit_msg: "storkit: qa 10_story_foo".to_string(),
|
||||
from_stage: None,
|
||||
}).unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
// 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");
|
||||
@@ -582,6 +662,7 @@ mod tests {
|
||||
item_id: "10_story_foo".to_string(),
|
||||
action: "qa".to_string(),
|
||||
commit_msg: "storkit: qa 10_story_foo".to_string(),
|
||||
from_stage: None,
|
||||
}).unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
Reference in New Issue
Block a user