Watch
0
0
Fork
You've already forked hyperhive
0

hive-agent: warn on usage responses with no recognisable windows

addresses argus's review on #4842 (c88740): fetch's success path was
silent when the response parsed but windows() found nothing to record.
should_warn_empty tracks the same once-per-streak shape as the
existing token-skip logging, reset the moment a response has windows
again, and never logs the body.

Also notes in the observability doc that a past resets_at means the
paired percent gauge is stale.
This commit is contained in:
atlas 2026-09-30 20:37:32 +02:00 • committed by mara
commit 5d7042e655
2 changed files with 40 additions and 2 deletions

View file

@ -378,7 +378,8 @@ with the OAuth access token from the agent's own
`~/.claude/.credentials.json`. The harness only reads that token and never `~/.claude/.credentials.json`. The harness only reads that token and never
refreshes it — claude owns refresh-token rotation. An agent with no OAuth refreshes it — claude owns refresh-token rotation. An agent with no OAuth
session (API-key backends, not yet logged in) or an expired token skips the session (API-key backends, not yet logged in) or an expired token skips the
poll, so a gauge keeps its last value until the next successful poll. poll, so a gauge keeps its last value until the next successful poll — a
`resets_at` in the past means the paired `percent` is stale.
Resource attributes (`service.name`, `agent`, `hive`, `swarm`) come from the Resource attributes (`service.name`, `agent`, `hive`, `swarm`) come from the
same container-wide `OTEL_RESOURCE_ATTRIBUTES` as everything else in this same container-wide `OTEL_RESOURCE_ATTRIBUTES` as everything else in this

View file

@ -83,6 +83,11 @@ pub async fn run() {
}; };
let creds = crate::paths::claude_dir().join(CREDENTIALS_FILE); let creds = crate::paths::claude_dir().join(CREDENTIALS_FILE);
let mut skip_logged = false; let mut skip_logged = false;
// Separate streak from `skip_logged`: a token can be usable while the
// response still carries no recognisable window (endpoint change,
// unexpected account state), and that deserves its own once-per-streak
// warning rather than silence.
let mut empty_logged = false;
let mut tick = tokio::time::interval(POLL_INTERVAL); let mut tick = tokio::time::interval(POLL_INTERVAL);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop { loop {
@ -104,7 +109,13 @@ pub async fn run() {
skip_logged = false; skip_logged = false;
match fetch(&client, &token).await { match fetch(&client, &token).await {
Ok(body) => { Ok(body) => {
for w in windows(&body) { let windows = windows(&body);
if should_warn_empty(&mut empty_logged, windows.len()) {
tracing::warn!(
"claude usage watch: usage response had no recognisable windows"
);
}
for w in windows {
crate::otel_turn_metrics::record_claude_usage(&w.name, w.percent, w.resets_at); crate::otel_turn_metrics::record_claude_usage(&w.name, w.percent, w.resets_at);
} }
} }
@ -159,6 +170,21 @@ async fn fetch(client: &reqwest::Client, token: &AccessToken) -> Result<Value, S
.map_err(|e| format!("unreadable response: {e}")) .map_err(|e| format!("unreadable response: {e}"))
} }
/// Streak-deduped decision for the empty-windows warning: updates
/// `empty_logged` and returns whether this tick should actually log. Split
/// from [`run`] so the once-per-streak logic is testable without a timer.
fn should_warn_empty(empty_logged: &mut bool, window_count: usize) -> bool {
if window_count > 0 {
*empty_logged = false;
return false;
}
if *empty_logged {
return false;
}
*empty_logged = true;
true
}
/// Window entries are the top-level objects carrying both `utilization` and /// Window entries are the top-level objects carrying both `utilization` and
/// `resets_at`; other top-level keys (`extra_usage`, `limits`, …) are not /// `resets_at`; other top-level keys (`extra_usage`, `limits`, …) are not
/// windows. A window whose `utilization` is `null` is not reported. /// windows. A window whose `utilization` is `null` is not reported.
@ -242,6 +268,17 @@ mod tests {
assert!(matches!(token_from(f, 1000), Token::Expired)); assert!(matches!(token_from(f, 1000), Token::Expired));
} }
/// Empty responses warn once per streak; a non-empty response in
/// between resets it, so a second empty streak warns again.
#[test]
fn empty_windows_warn_once_per_streak() {
let mut empty_logged = false;
assert!(should_warn_empty(&mut empty_logged, 0));
assert!(!should_warn_empty(&mut empty_logged, 0));
assert!(!should_warn_empty(&mut empty_logged, 2));
assert!(should_warn_empty(&mut empty_logged, 0));
}
#[test] #[test]
fn missing_or_malformed_credentials_are_absent() { fn missing_or_malformed_credentials_are_absent() {
assert!(matches!(token_from(b"{}", 0), Token::Absent)); assert!(matches!(token_from(b"{}", 0), Token::Absent));