From d58027026393e69bdae15d73375758e53e0a98b0 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 27 Jul 2026 21:21:36 +0200 Subject: [PATCH] hive-claude: recognize OAuth-refresh give-up as an auth failure --- hive-claude/src/classify.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/hive-claude/src/classify.rs b/hive-claude/src/classify.rs index 80dc836d..ce6c4624 100644 --- a/hive-claude/src/classify.rs +++ b/hive-claude/src/classify.rs @@ -32,13 +32,17 @@ const RATE_LIMIT_MARKERS: [&str; 5] = [ "Request rate limit exceeded", ]; -/// Substrings indicating the API rejected the request as unauthenticated (401) -/// — an expired/revoked OAuth session. Sourced from claude-code's `api_retry` -/// JSON events and its human-readable give-up line. -const AUTH_FAIL_MARKERS: [&str; 3] = [ +/// Substrings indicating the session is unauthenticated — either the API +/// rejected the request (401, an expired/revoked OAuth session) or the CLI +/// gave up refreshing its local OAuth token before ever making a request. +/// Sourced from claude-code's `api_retry` JSON events and its human-readable +/// give-up lines (two distinct give-up messages: a rejected request, and a +/// failed local token refresh). +const AUTH_FAIL_MARKERS: [&str; 4] = [ "\"error\":\"authentication_failed\"", "\"error_status\":401", "Failed to authenticate. API Error: 401", + "Failed to authenticate: OAuth session expired and could not be refreshed", ]; /// Substrings indicating `--resume` could not resolve its target: no session @@ -314,6 +318,19 @@ mod tests { assert!(matches!(s.soft_error(), Some(Error::PromptTooLong))); } + #[test] + fn oauth_refresh_failure_trips_auth_failed() { + // A local OAuth token-refresh failure never reaches an API request — + // no `error_status`/401 anywhere — so it needs its own marker rather + // than riding the request-rejected 401 text. Observed in the wild as + // a turn that failed with a bare non-zero exit and no recognized + // sentinel: the harness never transitioned to needs-login because + // this exact give-up line wasn't a marker yet. + let s = Sentinels::default(); + s.scan_cli_line("Failed to authenticate: OAuth session expired and could not be refreshed"); + assert!(matches!(s.soft_error(), Some(Error::AuthFailed))); + } + #[test] fn rate_limit_still_only_on_error_event() { // A non-error control event mentioning the marker must not trip it.