huskies: merge 641_story_unified_status_update_delivery_across_chat_web_ui_and_top_level_agent_context
This commit is contained in:
@@ -631,6 +631,7 @@ fn build_agent_app_context(
|
|||||||
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
||||||
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||||
});
|
});
|
||||||
crate::http::context::AppContext {
|
crate::http::context::AppContext {
|
||||||
state: Arc::new(state),
|
state: Arc::new(state),
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ mod tests {
|
|||||||
perm_rx: Arc::new(TokioMutex::new(perm_rx)),
|
perm_rx: Arc::new(TokioMutex::new(perm_rx)),
|
||||||
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -491,6 +491,7 @@ mod tests {
|
|||||||
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
||||||
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(Default::default())),
|
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(Default::default())),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||||
});
|
});
|
||||||
Arc::new(WhatsAppWebhookContext {
|
Arc::new(WhatsAppWebhookContext {
|
||||||
services,
|
services,
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ impl AppContext {
|
|||||||
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
||||||
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||||
});
|
});
|
||||||
Self {
|
Self {
|
||||||
state: Arc::new(state),
|
state: Arc::new(state),
|
||||||
|
|||||||
@@ -623,6 +623,7 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| c.permission_timeout_secs)
|
.map(|c| c.permission_timeout_secs)
|
||||||
.unwrap_or(120),
|
.unwrap_or(120),
|
||||||
|
status: Arc::new(service::status::StatusBroadcaster::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build WhatsApp webhook context if bot.toml configures transport = "whatsapp".
|
// Build WhatsApp webhook context if bot.toml configures transport = "whatsapp".
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ pub(super) fn call_sync(
|
|||||||
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
||||||
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
let dispatch = CommandDispatch {
|
let dispatch = CommandDispatch {
|
||||||
|
|||||||
@@ -406,6 +406,7 @@ pub fn spawn_gateway_bot(
|
|||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
)),
|
)),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
status: std::sync::Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
crate::chat::transport::matrix::spawn_bot(
|
crate::chat::transport::matrix::spawn_bot(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub mod project;
|
|||||||
pub mod qa;
|
pub mod qa;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
pub mod status;
|
||||||
pub mod story;
|
pub mod story;
|
||||||
pub mod timer;
|
pub mod timer;
|
||||||
pub mod wizard;
|
pub mod wizard;
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
//! Pure status-event message formatter.
|
||||||
|
//!
|
||||||
|
//! A single [`format_status_event`] function converts any [`StatusEvent`] into
|
||||||
|
//! a human-readable string. Adding a new event type means adding one match arm
|
||||||
|
//! here — no per-transport duplication anywhere in the codebase.
|
||||||
|
|
||||||
|
use crate::service::common::item_id::extract_item_number;
|
||||||
|
use crate::service::notifications::format::stage_display_name;
|
||||||
|
use crate::service::status::StatusEvent;
|
||||||
|
|
||||||
|
/// Render a [`StatusEvent`] into a human-readable plain-text string.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
///
|
||||||
|
/// This is the single formatter for all status event types. Every transport
|
||||||
|
/// (chat, Web UI, agent context) calls this function rather than duplicating
|
||||||
|
/// formatting logic.
|
||||||
|
pub fn format_status_event(event: &StatusEvent) -> String {
|
||||||
|
match event {
|
||||||
|
StatusEvent::StageTransition {
|
||||||
|
story_id,
|
||||||
|
story_name,
|
||||||
|
from_stage,
|
||||||
|
to_stage,
|
||||||
|
} => {
|
||||||
|
let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
|
||||||
|
let name = story_name.as_deref().unwrap_or(story_id.as_str());
|
||||||
|
let from = stage_display_name(from_stage);
|
||||||
|
let to = stage_display_name(to_stage);
|
||||||
|
let prefix = if to == "Done" { "\u{1f389} " } else { "" };
|
||||||
|
format!("{prefix}#{number} {name} \u{2014} {from} \u{2192} {to}")
|
||||||
|
}
|
||||||
|
StatusEvent::MergeFailure {
|
||||||
|
story_id,
|
||||||
|
story_name,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
|
||||||
|
let name = story_name.as_deref().unwrap_or(story_id.as_str());
|
||||||
|
format!("\u{274c} #{number} {name} \u{2014} {reason}")
|
||||||
|
}
|
||||||
|
StatusEvent::StoryBlocked {
|
||||||
|
story_id,
|
||||||
|
story_name,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
|
||||||
|
let name = story_name.as_deref().unwrap_or(story_id.as_str());
|
||||||
|
format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}")
|
||||||
|
}
|
||||||
|
StatusEvent::RateLimitWarning {
|
||||||
|
story_id,
|
||||||
|
story_name,
|
||||||
|
agent_name,
|
||||||
|
} => {
|
||||||
|
let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
|
||||||
|
let name = story_name.as_deref().unwrap_or(story_id.as_str());
|
||||||
|
format!("\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit")
|
||||||
|
}
|
||||||
|
StatusEvent::RateLimitHardBlock {
|
||||||
|
story_id,
|
||||||
|
story_name,
|
||||||
|
agent_name,
|
||||||
|
reset_at,
|
||||||
|
} => {
|
||||||
|
let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
|
||||||
|
let name = story_name.as_deref().unwrap_or(story_id.as_str());
|
||||||
|
let reset = reset_at.format("%H:%M UTC").to_string();
|
||||||
|
format!(
|
||||||
|
"\u{26d4} #{number} {name} \u{2014} {agent_name} hard rate-limited until {reset}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::TimeZone;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_stage_transition_to_done_with_emoji() {
|
||||||
|
let event = StatusEvent::StageTransition {
|
||||||
|
story_id: "42_story_foo".to_string(),
|
||||||
|
story_name: Some("Foo Story".to_string()),
|
||||||
|
from_stage: "4_merge".to_string(),
|
||||||
|
to_stage: "5_done".to_string(),
|
||||||
|
};
|
||||||
|
let s = format_status_event(&event);
|
||||||
|
assert!(
|
||||||
|
s.contains("\u{1f389}"),
|
||||||
|
"done transition should include party emoji"
|
||||||
|
);
|
||||||
|
assert!(s.contains("#42"));
|
||||||
|
assert!(s.contains("Foo Story"));
|
||||||
|
assert!(s.contains("Merge \u{2192} Done"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_stage_transition_no_emoji_for_non_done() {
|
||||||
|
let event = StatusEvent::StageTransition {
|
||||||
|
story_id: "10_story_bar".to_string(),
|
||||||
|
story_name: Some("Bar".to_string()),
|
||||||
|
from_stage: "1_backlog".to_string(),
|
||||||
|
to_stage: "2_current".to_string(),
|
||||||
|
};
|
||||||
|
let s = format_status_event(&event);
|
||||||
|
assert!(!s.contains("\u{1f389}"));
|
||||||
|
assert!(s.contains("Backlog \u{2192} Current"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_stage_transition_falls_back_to_story_id_when_no_name() {
|
||||||
|
let event = StatusEvent::StageTransition {
|
||||||
|
story_id: "5_story_x".to_string(),
|
||||||
|
story_name: None,
|
||||||
|
from_stage: "2_current".to_string(),
|
||||||
|
to_stage: "3_qa".to_string(),
|
||||||
|
};
|
||||||
|
let s = format_status_event(&event);
|
||||||
|
assert!(s.contains("5_story_x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_merge_failure() {
|
||||||
|
let event = StatusEvent::MergeFailure {
|
||||||
|
story_id: "7_story_fail".to_string(),
|
||||||
|
story_name: Some("Failing Story".to_string()),
|
||||||
|
reason: "conflicts detected".to_string(),
|
||||||
|
};
|
||||||
|
let s = format_status_event(&event);
|
||||||
|
assert!(s.contains("\u{274c}"));
|
||||||
|
assert!(s.contains("#7"));
|
||||||
|
assert!(s.contains("conflicts detected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_story_blocked() {
|
||||||
|
let event = StatusEvent::StoryBlocked {
|
||||||
|
story_id: "8_story_blk".to_string(),
|
||||||
|
story_name: Some("Blocked Story".to_string()),
|
||||||
|
reason: "retry limit exceeded".to_string(),
|
||||||
|
};
|
||||||
|
let s = format_status_event(&event);
|
||||||
|
assert!(s.contains("\u{1f6ab}"));
|
||||||
|
assert!(s.contains("BLOCKED: retry limit exceeded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_rate_limit_warning() {
|
||||||
|
let event = StatusEvent::RateLimitWarning {
|
||||||
|
story_id: "9_story_rl".to_string(),
|
||||||
|
story_name: Some("RL Story".to_string()),
|
||||||
|
agent_name: "coder-1".to_string(),
|
||||||
|
};
|
||||||
|
let s = format_status_event(&event);
|
||||||
|
assert!(s.contains("coder-1 hit an API rate limit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_rate_limit_hard_block() {
|
||||||
|
let reset = chrono::Utc
|
||||||
|
.with_ymd_and_hms(2026, 4, 26, 15, 30, 0)
|
||||||
|
.unwrap();
|
||||||
|
let event = StatusEvent::RateLimitHardBlock {
|
||||||
|
story_id: "3_story_hb".to_string(),
|
||||||
|
story_name: Some("HB Story".to_string()),
|
||||||
|
agent_name: "coder-2".to_string(),
|
||||||
|
reset_at: reset,
|
||||||
|
};
|
||||||
|
let s = format_status_event(&event);
|
||||||
|
assert!(s.contains("\u{26d4}"));
|
||||||
|
assert!(s.contains("coder-2"));
|
||||||
|
assert!(s.contains("hard rate-limited"));
|
||||||
|
assert!(s.contains("15:30 UTC"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_formatter_no_per_transport_duplication() {
|
||||||
|
// Each event type produces output through a single call — no per-transport variants.
|
||||||
|
let events: Vec<StatusEvent> = vec![
|
||||||
|
StatusEvent::StageTransition {
|
||||||
|
story_id: "1_story_a".to_string(),
|
||||||
|
story_name: None,
|
||||||
|
from_stage: "1_backlog".to_string(),
|
||||||
|
to_stage: "2_current".to_string(),
|
||||||
|
},
|
||||||
|
StatusEvent::MergeFailure {
|
||||||
|
story_id: "2_story_b".to_string(),
|
||||||
|
story_name: None,
|
||||||
|
reason: "test".to_string(),
|
||||||
|
},
|
||||||
|
StatusEvent::StoryBlocked {
|
||||||
|
story_id: "3_story_c".to_string(),
|
||||||
|
story_name: None,
|
||||||
|
reason: "limit".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for e in &events {
|
||||||
|
let s = format_status_event(e);
|
||||||
|
assert!(!s.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
//! Status broadcaster — unified pipeline-event fan-out for all consumers.
|
||||||
|
//!
|
||||||
|
//! [`StatusBroadcaster`] lives on the [`crate::services::Services`] bundle and
|
||||||
|
//! exposes two entry points:
|
||||||
|
//!
|
||||||
|
//! - [`StatusBroadcaster::publish`] — emit an event to every active subscriber.
|
||||||
|
//! - [`StatusBroadcaster::subscribe`] — obtain a [`Subscription`] handle that
|
||||||
|
//! receives events and can be independently enabled or disabled.
|
||||||
|
//!
|
||||||
|
//! # Multi-project isolation
|
||||||
|
//!
|
||||||
|
//! Each project has its own `Services` bundle and therefore its own
|
||||||
|
//! `StatusBroadcaster` instance. Events published to project A's broadcaster
|
||||||
|
//! are delivered only to subscribers of that same broadcaster; project B's
|
||||||
|
//! subscribers see nothing.
|
||||||
|
//!
|
||||||
|
//! # Plug-in consumer design
|
||||||
|
//!
|
||||||
|
//! Future stories add their transport-specific logic without touching this
|
||||||
|
//! module:
|
||||||
|
//!
|
||||||
|
//! - Story 642 (top-level agent context): calls `subscribe()` once at startup.
|
||||||
|
//! - Story 643 (Web UI): calls `subscribe()` once at startup.
|
||||||
|
//! - Story 644 (chat transports): calls `subscribe()` once per transport.
|
||||||
|
|
||||||
|
pub mod format;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
/// The capacity of the underlying broadcast channel.
|
||||||
|
///
|
||||||
|
/// Lagging subscribers lose events — callers should consume promptly.
|
||||||
|
const CHANNEL_CAPACITY: usize = 256;
|
||||||
|
|
||||||
|
// ── StatusEvent ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A pipeline lifecycle event that the [`StatusBroadcaster`] fans out to all
|
||||||
|
/// configured consumers.
|
||||||
|
///
|
||||||
|
/// Each variant carries enough context for [`format_status_event`] to render a
|
||||||
|
/// human-readable message without additional lookups.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum StatusEvent {
|
||||||
|
/// A work item moved between pipeline stages.
|
||||||
|
StageTransition {
|
||||||
|
/// Work item ID (e.g. `"42_story_my_feature"`).
|
||||||
|
story_id: String,
|
||||||
|
/// Human-readable story name, if available.
|
||||||
|
story_name: Option<String>,
|
||||||
|
/// Pipeline stage directory the item moved FROM (e.g. `"2_current"`).
|
||||||
|
from_stage: String,
|
||||||
|
/// Pipeline stage directory the item moved TO (e.g. `"3_qa"`).
|
||||||
|
to_stage: String,
|
||||||
|
},
|
||||||
|
/// A merge operation failed for a story.
|
||||||
|
MergeFailure {
|
||||||
|
/// Work item ID (e.g. `"42_story_my_feature"`).
|
||||||
|
story_id: String,
|
||||||
|
/// Human-readable story name, if available.
|
||||||
|
story_name: Option<String>,
|
||||||
|
/// Human-readable description of the failure.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// A story was blocked (e.g. retry limit exceeded, empty diff).
|
||||||
|
StoryBlocked {
|
||||||
|
/// Work item ID (e.g. `"42_story_my_feature"`).
|
||||||
|
story_id: String,
|
||||||
|
/// Human-readable story name, if available.
|
||||||
|
story_name: Option<String>,
|
||||||
|
/// Human-readable reason the story was blocked.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// An agent hit a soft API rate limit.
|
||||||
|
RateLimitWarning {
|
||||||
|
/// Work item ID the agent was working on.
|
||||||
|
story_id: String,
|
||||||
|
/// Human-readable story name, if available.
|
||||||
|
story_name: Option<String>,
|
||||||
|
/// Name of the agent that hit the limit.
|
||||||
|
agent_name: String,
|
||||||
|
},
|
||||||
|
/// An agent hit a hard API rate limit and will be blocked until `reset_at`.
|
||||||
|
RateLimitHardBlock {
|
||||||
|
/// Work item ID the agent was working on.
|
||||||
|
story_id: String,
|
||||||
|
/// Human-readable story name, if available.
|
||||||
|
story_name: Option<String>,
|
||||||
|
/// Name of the agent that hit the hard limit.
|
||||||
|
agent_name: String,
|
||||||
|
/// UTC instant at which the rate limit resets.
|
||||||
|
reset_at: DateTime<Utc>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subscription ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A per-consumer handle returned by [`StatusBroadcaster::subscribe`].
|
||||||
|
///
|
||||||
|
/// Each subscription holds its own [`broadcast::Receiver`] and an independent
|
||||||
|
/// `enabled` flag. When disabled, [`Subscription::recv`] silently discards
|
||||||
|
/// incoming events until the subscription is re-enabled.
|
||||||
|
///
|
||||||
|
/// Stories 642/643/644 own the lifecycle of their `Subscription` handles,
|
||||||
|
/// including when to call [`enable`](Subscription::enable) and
|
||||||
|
/// [`disable`](Subscription::disable).
|
||||||
|
pub struct Subscription {
|
||||||
|
receiver: broadcast::Receiver<StatusEvent>,
|
||||||
|
enabled: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Subscription {
|
||||||
|
/// Enable this subscription — future events will be delivered to the caller.
|
||||||
|
pub fn enable(&self) {
|
||||||
|
self.enabled.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disable this subscription — incoming events are silently discarded until
|
||||||
|
/// [`enable`](Subscription::enable) is called again.
|
||||||
|
pub fn disable(&self) {
|
||||||
|
self.enabled.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if this subscription is currently enabled.
|
||||||
|
pub fn is_enabled(&self) -> bool {
|
||||||
|
self.enabled.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive the next event, skipping any that arrive while the subscription
|
||||||
|
/// is disabled.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the broadcaster has been dropped (channel closed).
|
||||||
|
/// If the receiver is lagging (events were dropped), the lag is cleared and
|
||||||
|
/// reception continues.
|
||||||
|
pub async fn recv(&mut self) -> Option<StatusEvent> {
|
||||||
|
loop {
|
||||||
|
match self.receiver.recv().await {
|
||||||
|
Ok(event) => {
|
||||||
|
if self.enabled.load(Ordering::Relaxed) {
|
||||||
|
return Some(event);
|
||||||
|
}
|
||||||
|
// Subscription disabled — discard and wait for next.
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||||
|
// Dropped some events; continue from current position.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a clone of the `enabled` flag so the owner can share it with
|
||||||
|
/// another task (e.g. an HTTP handler that toggles the flag).
|
||||||
|
pub fn enabled_flag(&self) -> Arc<AtomicBool> {
|
||||||
|
Arc::clone(&self.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── StatusBroadcaster ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A project-scoped pub-sub hub for pipeline status events.
|
||||||
|
///
|
||||||
|
/// Constructed once per project in `main.rs` and stored on
|
||||||
|
/// [`crate::services::Services`]. Downstream consumers (chat transports, Web
|
||||||
|
/// UI, agent context) call [`subscribe`](StatusBroadcaster::subscribe) to
|
||||||
|
/// obtain a [`Subscription`] handle; the broadcaster itself only needs to be
|
||||||
|
/// called via [`publish`](StatusBroadcaster::publish).
|
||||||
|
pub struct StatusBroadcaster {
|
||||||
|
tx: broadcast::Sender<StatusEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatusBroadcaster {
|
||||||
|
/// Create a new broadcaster with a channel capacity of [`CHANNEL_CAPACITY`].
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
|
||||||
|
Self { tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish an event to all currently active, non-lagging subscribers.
|
||||||
|
///
|
||||||
|
/// If there are no subscribers the send is silently dropped.
|
||||||
|
pub fn publish(&self, event: StatusEvent) {
|
||||||
|
let _ = self.tx.send(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new [`Subscription`] for a downstream consumer.
|
||||||
|
///
|
||||||
|
/// The subscription starts **enabled** by default. Pass the returned
|
||||||
|
/// handle to the consuming task; call [`Subscription::disable`] /
|
||||||
|
/// [`Subscription::enable`] to gate delivery independently per consumer.
|
||||||
|
pub fn subscribe(&self) -> Subscription {
|
||||||
|
Subscription {
|
||||||
|
receiver: self.tx.subscribe(),
|
||||||
|
enabled: Arc::new(AtomicBool::new(true)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new [`Subscription`] with an explicit initial enabled state.
|
||||||
|
///
|
||||||
|
/// Useful when a consumer is provisioned before its transport is ready.
|
||||||
|
pub fn subscribe_with_state(&self, initial_enabled: bool) -> Subscription {
|
||||||
|
Subscription {
|
||||||
|
receiver: self.tx.subscribe(),
|
||||||
|
enabled: Arc::new(AtomicBool::new(initial_enabled)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StatusBroadcaster {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── publish / subscribe basics ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn publish_delivers_to_subscriber() {
|
||||||
|
let broadcaster = StatusBroadcaster::new();
|
||||||
|
let mut sub = broadcaster.subscribe();
|
||||||
|
|
||||||
|
broadcaster.publish(StatusEvent::MergeFailure {
|
||||||
|
story_id: "1_story_a".to_string(),
|
||||||
|
story_name: None,
|
||||||
|
reason: "conflict".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let received = sub.recv().await.expect("should receive event");
|
||||||
|
assert!(matches!(received, StatusEvent::MergeFailure { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn publish_fans_out_to_multiple_subscribers() {
|
||||||
|
let broadcaster = StatusBroadcaster::new();
|
||||||
|
let mut sub1 = broadcaster.subscribe();
|
||||||
|
let mut sub2 = broadcaster.subscribe();
|
||||||
|
|
||||||
|
broadcaster.publish(StatusEvent::StoryBlocked {
|
||||||
|
story_id: "2_story_b".to_string(),
|
||||||
|
story_name: None,
|
||||||
|
reason: "retry limit".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let e1 = sub1.recv().await.expect("sub1 should receive");
|
||||||
|
let e2 = sub2.recv().await.expect("sub2 should receive");
|
||||||
|
assert!(matches!(e1, StatusEvent::StoryBlocked { .. }));
|
||||||
|
assert!(matches!(e2, StatusEvent::StoryBlocked { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn disabled_subscription_does_not_deliver_until_enabled() {
|
||||||
|
use tokio::time::{Duration, timeout};
|
||||||
|
|
||||||
|
let broadcaster = StatusBroadcaster::new();
|
||||||
|
let mut sub = broadcaster.subscribe_with_state(false);
|
||||||
|
|
||||||
|
// Publish an event while disabled.
|
||||||
|
broadcaster.publish(StatusEvent::StoryBlocked {
|
||||||
|
story_id: "3_story_c".to_string(),
|
||||||
|
story_name: None,
|
||||||
|
reason: "empty diff".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// recv() internally discards the buffered StoryBlocked event (disabled),
|
||||||
|
// then blocks waiting for the next one. The timeout proves it does not
|
||||||
|
// return the discarded event.
|
||||||
|
let blocked = timeout(Duration::from_millis(20), sub.recv()).await;
|
||||||
|
assert!(
|
||||||
|
blocked.is_err(),
|
||||||
|
"disabled subscription must not deliver events"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable and publish another event — should be delivered.
|
||||||
|
sub.enable();
|
||||||
|
broadcaster.publish(StatusEvent::MergeFailure {
|
||||||
|
story_id: "4_story_d".to_string(),
|
||||||
|
story_name: None,
|
||||||
|
reason: "gate failed".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let received = timeout(Duration::from_millis(100), sub.recv())
|
||||||
|
.await
|
||||||
|
.expect("should not time out after enabling")
|
||||||
|
.expect("channel should be open");
|
||||||
|
assert!(matches!(received, StatusEvent::MergeFailure { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn subscribe_with_state_false_starts_disabled() {
|
||||||
|
let broadcaster = StatusBroadcaster::new();
|
||||||
|
let sub = broadcaster.subscribe_with_state(false);
|
||||||
|
assert!(!sub.is_enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn subscribe_starts_enabled_by_default() {
|
||||||
|
let broadcaster = StatusBroadcaster::new();
|
||||||
|
let sub = broadcaster.subscribe();
|
||||||
|
assert!(sub.is_enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-project isolation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Events from project A's broadcaster must not reach project B's subscribers.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multi_project_isolation_events_do_not_cross_broadcasters() {
|
||||||
|
let broadcaster_a = StatusBroadcaster::new();
|
||||||
|
let broadcaster_b = StatusBroadcaster::new();
|
||||||
|
|
||||||
|
let mut sub_a = broadcaster_a.subscribe();
|
||||||
|
let mut sub_b = broadcaster_b.subscribe();
|
||||||
|
|
||||||
|
// Publish an event only to project A.
|
||||||
|
broadcaster_a.publish(StatusEvent::StageTransition {
|
||||||
|
story_id: "10_story_project_a".to_string(),
|
||||||
|
story_name: Some("Project A Story".to_string()),
|
||||||
|
from_stage: "1_backlog".to_string(),
|
||||||
|
to_stage: "2_current".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish a different event only to project B.
|
||||||
|
broadcaster_b.publish(StatusEvent::MergeFailure {
|
||||||
|
story_id: "20_story_project_b".to_string(),
|
||||||
|
story_name: Some("Project B Story".to_string()),
|
||||||
|
reason: "b conflict".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// sub_a should receive project A's event only.
|
||||||
|
let from_a = sub_a.recv().await.expect("sub_a should receive event");
|
||||||
|
match &from_a {
|
||||||
|
StatusEvent::StageTransition { story_id, .. } => {
|
||||||
|
assert_eq!(story_id, "10_story_project_a");
|
||||||
|
}
|
||||||
|
other => panic!("sub_a got wrong event: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// sub_b should receive project B's event only.
|
||||||
|
let from_b = sub_b.recv().await.expect("sub_b should receive event");
|
||||||
|
match &from_b {
|
||||||
|
StatusEvent::MergeFailure { story_id, .. } => {
|
||||||
|
assert_eq!(story_id, "20_story_project_b");
|
||||||
|
}
|
||||||
|
other => panic!("sub_b got wrong event: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no cross-contamination: sub_a should have no further events.
|
||||||
|
use tokio::time::{Duration, timeout};
|
||||||
|
let cross = timeout(Duration::from_millis(10), sub_a.recv()).await;
|
||||||
|
assert!(cross.is_err(), "sub_a should not receive project B's event");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two projects firing events independently must not affect each other.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multi_project_integration_two_projects_independent() {
|
||||||
|
let ba = StatusBroadcaster::new();
|
||||||
|
let bb = StatusBroadcaster::new();
|
||||||
|
|
||||||
|
let mut sub_a1 = ba.subscribe();
|
||||||
|
let mut sub_a2 = ba.subscribe();
|
||||||
|
let mut sub_b1 = bb.subscribe();
|
||||||
|
|
||||||
|
// Project A fires two events.
|
||||||
|
ba.publish(StatusEvent::StoryBlocked {
|
||||||
|
story_id: "1_story_a_blocked".to_string(),
|
||||||
|
story_name: None,
|
||||||
|
reason: "retry".to_string(),
|
||||||
|
});
|
||||||
|
ba.publish(StatusEvent::MergeFailure {
|
||||||
|
story_id: "2_story_a_fail".to_string(),
|
||||||
|
story_name: None,
|
||||||
|
reason: "conflict".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Project B fires one event.
|
||||||
|
bb.publish(StatusEvent::StageTransition {
|
||||||
|
story_id: "3_story_b_move".to_string(),
|
||||||
|
story_name: None,
|
||||||
|
from_stage: "1_backlog".to_string(),
|
||||||
|
to_stage: "2_current".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// sub_a1 sees both of project A's events.
|
||||||
|
let e1 = sub_a1.recv().await.unwrap();
|
||||||
|
let e2 = sub_a1.recv().await.unwrap();
|
||||||
|
assert!(matches!(e1, StatusEvent::StoryBlocked { .. }));
|
||||||
|
assert!(matches!(e2, StatusEvent::MergeFailure { .. }));
|
||||||
|
|
||||||
|
// sub_a2 also sees both of project A's events.
|
||||||
|
let e3 = sub_a2.recv().await.unwrap();
|
||||||
|
let e4 = sub_a2.recv().await.unwrap();
|
||||||
|
assert!(matches!(e3, StatusEvent::StoryBlocked { .. }));
|
||||||
|
assert!(matches!(e4, StatusEvent::MergeFailure { .. }));
|
||||||
|
|
||||||
|
// sub_b1 sees only project B's event.
|
||||||
|
let e5 = sub_b1.recv().await.unwrap();
|
||||||
|
assert!(matches!(e5, StatusEvent::StageTransition { .. }));
|
||||||
|
|
||||||
|
// No cross-contamination from A to B.
|
||||||
|
use tokio::time::{Duration, timeout};
|
||||||
|
assert!(
|
||||||
|
timeout(Duration::from_millis(10), sub_b1.recv())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"sub_b1 should not see project A events"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── enable/disable flag sharing ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn enabled_flag_shared_between_tasks() {
|
||||||
|
let broadcaster = StatusBroadcaster::new();
|
||||||
|
let sub = broadcaster.subscribe();
|
||||||
|
let flag = sub.enabled_flag();
|
||||||
|
|
||||||
|
// Disable via the shared flag.
|
||||||
|
flag.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
assert!(!sub.is_enabled());
|
||||||
|
|
||||||
|
// Re-enable via the subscription directly.
|
||||||
|
sub.enable();
|
||||||
|
assert!(sub.is_enabled());
|
||||||
|
assert!(flag.load(std::sync::atomic::Ordering::Relaxed));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── no-op when no subscribers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn publish_with_no_subscribers_does_not_panic() {
|
||||||
|
let broadcaster = StatusBroadcaster::new();
|
||||||
|
// No subscriber — send is silently dropped.
|
||||||
|
broadcaster.publish(StatusEvent::MergeFailure {
|
||||||
|
story_id: "99_story_nobody".to_string(),
|
||||||
|
story_name: None,
|
||||||
|
reason: "no one listening".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||||
|
use crate::service::status::StatusBroadcaster;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -35,6 +36,14 @@ pub struct Services {
|
|||||||
/// Seconds to wait for a user to respond to a permission prompt before
|
/// Seconds to wait for a user to respond to a permission prompt before
|
||||||
/// auto-denying (fail-closed).
|
/// auto-denying (fail-closed).
|
||||||
pub permission_timeout_secs: u64,
|
pub permission_timeout_secs: u64,
|
||||||
|
/// Project-scoped status broadcaster.
|
||||||
|
///
|
||||||
|
/// Consumers (chat transports, Web UI, agent context) call
|
||||||
|
/// [`StatusBroadcaster::subscribe`] to receive pipeline status events.
|
||||||
|
/// The broadcaster is project-scoped: events published here are delivered
|
||||||
|
/// only to subscribers of this instance, providing natural multi-project
|
||||||
|
/// isolation.
|
||||||
|
pub status: Arc<StatusBroadcaster>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -52,6 +61,7 @@ impl Services {
|
|||||||
perm_rx: std::sync::Arc::new(TokioMutex::new(perm_rx)),
|
perm_rx: std::sync::Arc::new(TokioMutex::new(perm_rx)),
|
||||||
pending_perm_replies: std::sync::Arc::new(TokioMutex::new(HashMap::new())),
|
pending_perm_replies: std::sync::Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
status: std::sync::Arc::new(StatusBroadcaster::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user