feat(424): rate-limit traffic-light dots and hard-block alerts

- Add HardBlock variant to WatcherEvent (story_id, agent_name, reset_time)
- In pty.rs, distinguish allowed_warning (throttle) from hard blocks;
  emit RateLimitWarning for throttles, HardBlock for actual 429s
- Add `throttled: bool` field to StoryAgent / AgentInfo
- Pool spawns a background listener that sets throttled=true on
  RateLimitWarning or HardBlock events and fires AgentStateChanged
- Status command shows traffic-light dots: ○ idle, ● running, ◑ throttled, ✗ blocked
- Read blocked flag from story front matter for the ✗ dot
- Notifications: RateLimitWarning silenced (too noisy); HardBlock sends
  urgent chat notification with optional reset time
- Tests added for traffic_light_dot, read_story_blocked, status output,
  and all notification paths
This commit is contained in:
dave
2026-03-28 09:21:03 +00:00
parent d83f2ae4c1
commit ebdcf18134
5 changed files with 190 additions and 5 deletions
+36 -2
View File
@@ -41,13 +41,47 @@ pub struct AgentPool {
impl AgentPool {
pub fn new(port: u16, watcher_tx: broadcast::Sender<WatcherEvent>) -> Self {
Self {
let pool = Self {
agents: Arc::new(Mutex::new(HashMap::new())),
port,
child_killers: Arc::new(Mutex::new(HashMap::new())),
watcher_tx,
watcher_tx: watcher_tx.clone(),
merge_jobs: Arc::new(Mutex::new(HashMap::new())),
};
// Spawn a background task (only when inside a tokio runtime) that
// listens for RateLimitWarning and HardBlock events and updates the
// throttled flag on the relevant agent so status dots stay current.
if tokio::runtime::Handle::try_current().is_ok() {
let agents_clone = Arc::clone(&pool.agents);
let watcher_tx_clone = watcher_tx.clone();
let mut rx = watcher_tx.subscribe();
tokio::spawn(async move {
loop {
let event = match rx.recv().await {
Ok(e) => e,
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
};
let (story_id, agent_name) = match &event {
WatcherEvent::RateLimitWarning { story_id, agent_name }
| WatcherEvent::HardBlock { story_id, agent_name, .. } => {
(story_id.clone(), agent_name.clone())
}
_ => continue,
};
let key = composite_key(&story_id, &agent_name);
if let Ok(mut agents) = agents_clone.lock() {
if let Some(agent) = agents.get_mut(&key) {
agent.throttled = true;
}
}
let _ = watcher_tx_clone.send(WatcherEvent::AgentStateChanged);
}
});
}
pool
}
pub fn port(&self) -> u16 {