surface rate_limited status as red badge on per-agent page and dashboard

- add rate_limited: Arc<AtomicBool> to Bus; set/cleared by emit_status
- write/remove sentinel file hyperhive-rate-limited in state dir so host-side
  dashboard can detect it without a live socket call
- api_state returns status=rate_limited when flag is set (cold-load accurate)
- ALIVE_LABELS gains rate_limited entry (⊘ red chip) on per-agent page
- ContainerView gains rate_limited: bool read from sentinel file
- dashboard container row shows ⊘ rate limited badge (red) ahead of needs_login

Closes #24
This commit is contained in:
iris 2026-05-20 15:16:00 +02:00
parent 808b9cbe1a
commit 804875d670
7 changed files with 69 additions and 10 deletions

View file

@ -531,6 +531,10 @@
if (pending) {
head.append(el('span', { class: 'pending-state' },
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
} else if (c.rate_limited) {
head.append(el('span',
{ class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' },
'⊘ rate limited'));
} else if (c.needs_login) {
head.append(el('a',
{ class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },

View file

@ -115,6 +115,10 @@ a:hover {
color: var(--amber); border-color: var(--amber);
text-shadow: 0 0 6px rgba(250, 179, 135, 0.5);
}
.badge-rate-limited {
color: var(--red); border-color: var(--red);
text-shadow: 0 0 6px rgba(243, 139, 168, 0.5);
}
.badge-muted {
color: var(--muted); border-color: var(--purple-dim);
background: rgba(127, 132, 156, 0.08);

View file

@ -44,6 +44,12 @@ pub struct ContainerView {
/// the "which agent is close to the window?" dashboard glance.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ctx_tokens: Option<u64>,
/// True while the harness is parked after an API rate-limit response.
/// Detected via the sentinel file `{state_dir}/hyperhive-rate-limited`
/// that the harness writes in `Bus::emit_status("rate_limited")` and
/// removes when it resumes. Stale by up to one crash-watch cycle.
#[serde(default)]
pub rate_limited: bool,
}
/// Build the full container list. Wraps `lifecycle::list()` and
@ -83,6 +89,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
.count_pending_reminders_for(reminder_recipient)
.unwrap_or(0);
let ctx_tokens = read_last_ctx_tokens(&logical);
let rate_limited = is_rate_limited(&logical);
out.push(ContainerView {
port: lifecycle::agent_web_port(&logical),
running: lifecycle::is_running(&logical).await,
@ -94,6 +101,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
deployed_sha,
pending_reminders,
ctx_tokens,
rate_limited,
});
}
out
@ -112,6 +120,15 @@ pub fn claude_has_session(dir: &Path) -> bool {
.any(|e| e.file_type().is_ok_and(|t| t.is_file()))
}
/// Returns true if the agent's harness is currently parked after an API
/// rate-limit response. Detected via the sentinel file written by
/// `hive_ag3nt::events::Bus::emit_status("rate_limited")`.
fn is_rate_limited(name: &str) -> bool {
Coordinator::agent_notes_dir(name)
.join("hyperhive-rate-limited")
.exists()
}
/// Read the most recent completed turn's context-window size (prompt
/// tokens) from the agent's turn-stats SQLite. Returns `None` when
/// the file is absent or has no rows. Best-effort — any DB error