/** * Agent Error Detection (SSOT) * * Single source of truth for classifying agent errors from output text * and error strings. Used by both message-agent-executor and task-scheduler. */ // ── Banner / text detection ───────────────────────────────────── export function isClaudeAuthError(text: string): boolean { const lower = text.toLowerCase(); return ( lower.includes('failed to authenticate') && (lower.includes('401') || lower.includes('authentication_error')) ); } export function isClaudeUsageExhaustedMessage(text: string): boolean { const normalized = text .trim() .toLowerCase() .replace(/['\u2018\u2019`]/g, "'") .replace(/\s+/g, ' ') .replace(/^error:\s*/i, ''); const looksLikeBanner = normalized.startsWith("you're out of extra usage") || normalized.startsWith('you are out of extra usage') || normalized.startsWith("you've hit your limit") || normalized.startsWith('you have hit your limit'); const hasResetHint = normalized.includes('resets ') || normalized.includes('reset at ') || normalized.includes('try again'); return looksLikeBanner && hasResetHint && normalized.length <= 160; } export function isClaudeAuthExpiredMessage(text: string): boolean { const normalized = text.trim().toLowerCase().replace(/\s+/g, ' '); const looksLikeAuthFailure = normalized.startsWith('failed to authenticate'); const hasExpiredTokenMarker = normalized.includes('oauth token has expired') || normalized.includes('authentication_error') || normalized.includes('obtain a new token') || normalized.includes('refresh your existing token') || normalized.includes('invalid authentication credentials'); const hasUnauthorizedMarker = normalized.includes('401') || normalized.includes('authentication error'); const hasTerminatedMarker = normalized.includes('terminated'); return ( looksLikeAuthFailure && hasUnauthorizedMarker && (hasExpiredTokenMarker || hasTerminatedMarker) ); } export function detectClaudeProviderFailureMessage( text: string, ): Extract | '' { const normalized = text.trim().toLowerCase().replace(/\s+/g, ' '); const looksLikeProviderError = normalized.startsWith('api error:') || normalized.startsWith('error: api error:') || normalized.startsWith('network error') || normalized.startsWith('fetch failed'); if (!looksLikeProviderError) { return ''; } const classification = classifyAgentError(text); if ( classification.category === 'rate-limit' || classification.category === 'overloaded' || classification.category === 'network-error' ) { return classification.reason; } return ''; } export function isClaudeOrgAccessDeniedMessage(text: string): boolean { const normalized = text.trim().toLowerCase().replace(/\s+/g, ' '); const hasOrgAccessDeniedMarker = normalized.includes( 'does not have access to claude', ); const hasRecoveryHint = normalized.includes('please login again') || normalized.includes('contact your administrator'); return hasOrgAccessDeniedMarker && hasRecoveryHint; } // ── Rotation decision ─────────────────────────────────────────── export type AgentTriggerReason = | '429' | 'usage-exhausted' | 'auth-expired' | 'org-access-denied' | 'session-failure' | 'overloaded' | 'network-error' | 'success-null-result' | 'session-failure'; export type ClaudeRotationReason = Extract< AgentTriggerReason, '429' | 'usage-exhausted' | 'auth-expired' | 'org-access-denied' >; export type CodexRotationReason = Extract< AgentTriggerReason, '429' | 'auth-expired' | 'org-access-denied' | 'overloaded' | 'network-error' >; export function shouldRotateClaudeToken( reason: AgentTriggerReason, ): reason is ClaudeRotationReason { return ( reason === '429' || reason === 'usage-exhausted' || reason === 'auth-expired' || reason === 'org-access-denied' ); } export function isCodexRotationReason( reason: AgentTriggerReason, ): reason is CodexRotationReason { return ( reason === '429' || reason === 'auth-expired' || reason === 'org-access-denied' || reason === 'overloaded' || reason === 'network-error' ); } // ── Rotation trigger classification ───────────────────────────── export type RotationTriggerResult = | { shouldRetry: false; reason: '' } | { shouldRetry: true; reason: AgentTriggerReason; retryAfterMs?: number; }; /** * Classify an error string to determine if Claude token rotation * should be attempted. * * Priority: 429 > auth/org errors > 503/network. */ export function classifyRotationTrigger( error?: string | null, ): RotationTriggerResult { if (!error) return { shouldRetry: false, reason: '' }; const common = classifyAgentError(error); // 429 rate-limit (highest priority) if (common.category === 'rate-limit') { return { shouldRetry: true, reason: common.reason, retryAfterMs: common.retryAfterMs, }; } // Claude-specific auth checks (before 503/network) const auth = classifyClaudeAuthError(error); if (auth.category !== 'none') { return { shouldRetry: true, reason: auth.reason }; } // 503 overloaded, network errors if (common.category !== 'none') { return { shouldRetry: true, reason: common.reason }; } return { shouldRetry: false, reason: '' }; } // ── Unified error classification ──────────────────────────────── export type ErrorCategory = | 'rate-limit' | 'auth-expired' | 'org-access-denied' | 'overloaded' | 'network-error' | 'none'; export type AgentErrorClassification = | { category: 'none'; reason: ''; retryAfterMs?: undefined; } | { category: 'rate-limit'; reason: '429'; retryAfterMs?: number; } | { category: 'auth-expired'; reason: 'auth-expired'; retryAfterMs?: undefined; } | { category: 'org-access-denied'; reason: 'org-access-denied'; retryAfterMs?: undefined; } | { category: 'overloaded'; reason: 'overloaded'; retryAfterMs?: undefined; } | { category: 'network-error'; reason: 'network-error'; retryAfterMs?: undefined; }; const NONE: AgentErrorClassification = { category: 'none', reason: '', }; export function isCodexPoolUnavailableError( error: string | null | undefined, ): boolean { if (!error) return false; return ( /all\s+codex(?:\s+rotation)?\s+accounts(?:\s+are)?\s+unavailable/i.test( error, ) || /codex\s+rotation\s+pool\s+unavailable/i.test(error) ); } export function isTerminalCodexAccountFailure( error: string | null | undefined, ): boolean { if (!error) return false; if (isCodexPoolUnavailableError(error)) return true; if (classifyCodexAuthError(error).category !== 'none') return true; const lower = error.toLowerCase(); if ( classifyAgentError(error).category === 'rate-limit' && (lower.includes('workspace out of credits') || lower.includes('out of credits') || lower.includes('codex')) ) { return true; } return false; } /** * Classify an agent error string into a category. * Handles patterns common to both Claude and Codex: 429, 503, network. * Auth errors are provider-specific — use classifyClaudeAuthError or * classifyCodexAuthError for those. */ export function classifyAgentError( error: string | null | undefined, ): AgentErrorClassification { if (!error) return NONE; const lower = error.toLowerCase(); // 429 / Rate Limit if ( lower.includes('429') || lower.includes('rate limit') || lower.includes('usage limit') || lower.includes('out of credits') || lower.includes('workspace out of credits') || lower.includes('hit your limit') || lower.includes('too many requests') || lower.includes('rate_limit') ) { const retryMatch = error.match(/retry[\s_-]*after[:\s]*(\d+)/i); const retryAfterMs = retryMatch ? parseInt(retryMatch[1], 10) * 1000 : undefined; return { category: 'rate-limit', reason: '429', retryAfterMs }; } const hasApi5xx = /\bapi error:\s*5\d\d\b/i.test(error); // 5xx / Overloaded if ( hasApi5xx || lower.includes('503') || lower.includes('overloaded') || lower.includes('selected model is at capacity') || lower.includes('model is at capacity') || ((lower.includes('502') || lower.includes('bad gateway')) && (lower.includes('cloudflare') || lower.includes('