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
+1
View File
@@ -271,6 +271,7 @@ impl AgentPool {
project_root: Some(project_root.to_path_buf()),
log_session_id: Some(log_session_id.clone()),
merge_failure_reported: false,
throttled: false,
},
);
}
+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 {
+3
View File
@@ -80,6 +80,8 @@ pub(super) struct StoryAgent {
/// worktree (which compiles fine) and returns `gates_passed=true` even
/// though the code was never squash-merged onto master.
pub(super) merge_failure_reported: bool,
/// Set to `true` when a rate-limit throttle warning was received for this agent.
pub(super) throttled: bool,
}
/// Build an `AgentInfo` snapshot from a `StoryAgent` map entry.
@@ -99,5 +101,6 @@ pub(super) fn agent_info_from_entry(story_id: &str, agent: &StoryAgent) -> Agent
.map(|wt| wt.base_branch.clone()),
completion: agent.completion.clone(),
log_session_id: agent.log_session_id.clone(),
throttled: agent.throttled,
}
}