refactor: introduce AgentTriggerReason type union as SSOT for error reason strings

Replace scattered reason string literals with a centralized type hierarchy:
- AgentTriggerReason: all possible trigger reasons
- ClaudeRotationReason, CodexRotationReason, NoFallbackCooldownReason:
  derived subtypes for specific contexts
- AgentErrorClassification, FallbackTriggerResult, CodexRotationTriggerResult:
  discriminated unions for compile-time narrowing

Remove dead export isUsageExhausted() (superseded by
isPrimaryNoFallbackCooldownActive). Replace NO_FALLBACK_COOLDOWN_REASONS
Set with isNoFallbackCooldownReason() type guard.

Add tests for agent-error-detection and provider-retry (423 total passing).

Known limitation: 6 `as CodexRotationReason` casts in Codex rotation paths
where streamed trigger reason is AgentTriggerReason but runtime value is
always CodexRotationReason-compatible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eyejoker
2026-03-25 17:46:43 +09:00
parent b86fd1ad89
commit 94eeaa5e3e
10 changed files with 290 additions and 53 deletions

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
classifyClaudeAuthError,
isClaudeOrgAccessDeniedMessage,
isNoFallbackCooldownReason,
shouldRotateClaudeToken,
} from './agent-error-detection.js';
describe('agent-error-detection', () => {
it('detects Claude org access denied banners', () => {
expect(
isClaudeOrgAccessDeniedMessage(
'Your organization does not have access to Claude. Please login again or contact your administrator.',
),
).toBe(true);
});
it('classifies org access denied banners as org-access-denied', () => {
expect(
classifyClaudeAuthError(
'Your organization does not have access to Claude. Please login again or contact your administrator.',
),
).toEqual({
category: 'org-access-denied',
reason: 'org-access-denied',
});
});
it('classifies terminated 403 auth failures as org-access-denied', () => {
expect(
classifyClaudeAuthError(
'Failed to authenticate. API Error: 403 terminated',
),
).toEqual({
category: 'org-access-denied',
reason: 'org-access-denied',
});
});
it('marks only Claude quota/auth reasons as Claude rotation reasons', () => {
expect(shouldRotateClaudeToken('429')).toBe(true);
expect(shouldRotateClaudeToken('usage-exhausted')).toBe(true);
expect(shouldRotateClaudeToken('auth-expired')).toBe(true);
expect(shouldRotateClaudeToken('org-access-denied')).toBe(true);
expect(shouldRotateClaudeToken('overloaded')).toBe(false);
expect(shouldRotateClaudeToken('success-null-result')).toBe(false);
});
it('marks only no-fallback cooldown reasons as skip-worthy', () => {
expect(isNoFallbackCooldownReason('usage-exhausted')).toBe(true);
expect(isNoFallbackCooldownReason('auth-expired')).toBe(true);
expect(isNoFallbackCooldownReason('org-access-denied')).toBe(true);
expect(isNoFallbackCooldownReason('429')).toBe(false);
expect(isNoFallbackCooldownReason('success-null-result')).toBe(false);
});
});

View File

@@ -68,7 +68,35 @@ export function isClaudeOrgAccessDeniedMessage(text: string): boolean {
// ── Rotation decision ───────────────────────────────────────────
export function shouldRotateClaudeToken(reason: string): boolean {
export type AgentTriggerReason =
| '429'
| 'usage-exhausted'
| 'auth-expired'
| 'org-access-denied'
| 'overloaded'
| 'network-error'
| 'success-null-result';
export type FallbackTriggerReason = Exclude<
AgentTriggerReason,
'usage-exhausted' | 'success-null-result'
>;
export type ClaudeRotationReason = Extract<
AgentTriggerReason,
'429' | 'usage-exhausted' | 'auth-expired' | 'org-access-denied'
>;
export type CodexRotationReason = FallbackTriggerReason;
export type NoFallbackCooldownReason = Extract<
AgentTriggerReason,
'usage-exhausted' | 'auth-expired' | 'org-access-denied'
>;
export function shouldRotateClaudeToken(
reason: AgentTriggerReason,
): reason is ClaudeRotationReason {
return (
reason === '429' ||
reason === 'usage-exhausted' ||
@@ -77,6 +105,16 @@ export function shouldRotateClaudeToken(reason: string): boolean {
);
}
export function isNoFallbackCooldownReason(
reason: AgentTriggerReason,
): reason is NoFallbackCooldownReason {
return (
reason === 'usage-exhausted' ||
reason === 'auth-expired' ||
reason === 'org-access-denied'
);
}
// ── Unified error classification ────────────────────────────────
export type ErrorCategory =
@@ -87,11 +125,37 @@ export type ErrorCategory =
| 'network-error'
| 'none';
export interface AgentErrorClassification {
category: ErrorCategory;
reason: string; // '429' | 'auth-expired' | 'org-access-denied' | 'overloaded' | 'network-error' | ''
retryAfterMs?: number;
}
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',

View File

@@ -17,6 +17,7 @@ import path from 'path';
import {
classifyAgentError,
classifyCodexAuthError,
type CodexRotationReason,
} from './agent-error-detection.js';
import { DATA_DIR } from './config.js';
import { logger } from './logger.js';
@@ -42,10 +43,15 @@ interface CodexAccount {
resetD7At?: string;
}
export interface CodexRotationTriggerResult {
shouldRotate: boolean;
reason: string;
}
export type CodexRotationTriggerResult =
| {
shouldRotate: false;
reason: '';
}
| {
shouldRotate: true;
reason: CodexRotationReason;
};
function parseJwtAuth(idToken: string): {
planType: string;

View File

@@ -57,7 +57,6 @@ vi.mock('./provider-fallback.js', () => ({
hasGroupProviderOverride: vi.fn(() => false),
isFallbackEnabled: vi.fn(() => true),
isPrimaryNoFallbackCooldownActive: vi.fn(() => false),
isUsageExhausted: vi.fn(() => false),
markPrimaryCooldown: vi.fn(),
}));

View File

@@ -35,6 +35,7 @@ import {
getCodexAccountCount,
markCodexTokenHealthy,
} from './codex-token-rotation.js';
import type { AgentTriggerReason, CodexRotationReason } from './agent-error-detection.js';
import { getTokenCount } from './token-rotation.js';
import type { RegisteredGroup } from './types.js';
@@ -124,7 +125,7 @@ export async function runAgentForGroup(
sawOutput: boolean;
sawSuccessNullResultWithoutOutput: boolean;
streamedTriggerReason?: {
reason: string;
reason: AgentTriggerReason;
retryAfterMs?: number;
};
}> => {
@@ -293,7 +294,7 @@ export async function runAgentForGroup(
};
const runFallbackAttempt = async (
reason: string,
reason: AgentTriggerReason,
retryAfterMs?: number,
): Promise<'success' | 'error'> => {
const fallbackName = getFallbackProviderName();
@@ -347,7 +348,7 @@ export async function runAgentForGroup(
};
const retryCodexWithRotation = async (
initialTrigger: { reason: string },
initialTrigger: { reason: CodexRotationReason },
rotationMessage?: string,
): Promise<'success' | 'error'> => {
let trigger = initialTrigger;
@@ -407,7 +408,9 @@ export async function runAgentForGroup(
retryAttempt.streamedTriggerReason &&
retryOutput.status !== 'error'
) {
trigger = { reason: retryAttempt.streamedTriggerReason.reason };
trigger = {
reason: retryAttempt.streamedTriggerReason.reason as CodexRotationReason,
};
lastRotationMessage =
typeof retryOutput.result === 'string'
? retryOutput.result
@@ -419,7 +422,8 @@ export async function runAgentForGroup(
const retryTrigger = retryAttempt.streamedTriggerReason
? {
shouldRotate: true,
reason: retryAttempt.streamedTriggerReason.reason,
reason:
retryAttempt.streamedTriggerReason.reason as CodexRotationReason,
}
: detectCodexRotationTrigger(retryOutput.error);
@@ -451,7 +455,7 @@ export async function runAgentForGroup(
const retryClaudeWithRotation = async (
initialTrigger: {
reason: string;
reason: AgentTriggerReason;
retryAfterMs?: number;
},
rotationMessage?: string,
@@ -667,7 +671,9 @@ export async function runAgentForGroup(
getCodexAccountCount() > 1
) {
return retryCodexWithRotation(
{ reason: primaryAttempt.streamedTriggerReason.reason },
{
reason: primaryAttempt.streamedTriggerReason.reason as CodexRotationReason,
},
output.error ?? output.result ?? undefined,
);
}

View File

@@ -17,6 +17,9 @@ import fs from 'fs';
import {
classifyAgentError,
classifyClaudeAuthError,
isNoFallbackCooldownReason,
type AgentTriggerReason,
type FallbackTriggerReason,
} from './agent-error-detection.js';
import { fetchClaudeUsage, type ClaudeUsageData } from './claude-usage.js';
import { getEnv } from './env.js';
@@ -27,16 +30,22 @@ import { rotateToken, getTokenCount } from './token-rotation.js';
export type ProviderName = 'claude' | string; // fallback name is configurable
export interface FallbackTriggerResult {
shouldFallback: boolean;
reason: string;
retryAfterMs?: number;
}
export type FallbackTriggerResult =
| {
shouldFallback: false;
reason: '';
retryAfterMs?: undefined;
}
| {
shouldFallback: true;
reason: FallbackTriggerReason;
retryAfterMs?: number;
};
interface CooldownState {
startedAt: number;
expiresAt: number;
reason: string;
reason: AgentTriggerReason;
}
interface FallbackConfig {
@@ -59,11 +68,6 @@ let lastUsageAvailabilityCheck: {
let usageAvailabilityCheckPromise: Promise<
'available' | 'exhausted' | 'unknown'
> | null = null;
const NO_FALLBACK_COOLDOWN_REASONS = new Set([
'usage-exhausted',
'auth-expired',
'org-access-denied',
]);
const USAGE_RECOVERY_RECHECK_MS = 30_000;
@@ -290,7 +294,7 @@ export async function getActiveProvider(): Promise<string> {
* the fallback provider until the cooldown expires.
*/
export function markPrimaryCooldown(
reason: string,
reason: AgentTriggerReason,
retryAfterMs?: number,
): void {
const config = loadConfig();
@@ -331,14 +335,9 @@ export function clearPrimaryCooldown(): void {
}
}
/** Check if Claude is currently in usage-exhausted cooldown. */
export function isUsageExhausted(): boolean {
return cooldown?.reason === 'usage-exhausted';
}
/** Check whether the active primary cooldown should suppress fallback entirely. */
export function isPrimaryNoFallbackCooldownActive(): boolean {
return cooldown ? NO_FALLBACK_COOLDOWN_REASONS.has(cooldown.reason) : false;
return cooldown ? isNoFallbackCooldownReason(cooldown.reason) : false;
}
/** Get current cooldown info (for diagnostics / status dashboard). */

View File

@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('./provider-fallback.js', () => ({
detectFallbackTrigger: vi.fn(() => ({ shouldFallback: false, reason: '' })),
markPrimaryCooldown: vi.fn(),
}));
vi.mock('./token-rotation.js', () => ({
rotateToken: vi.fn(() => false),
getTokenCount: vi.fn(() => 1),
markTokenHealthy: vi.fn(),
}));
import { markPrimaryCooldown } from './provider-fallback.js';
import { runClaudeRotationLoop } from './provider-retry.js';
import {
getTokenCount,
markTokenHealthy,
rotateToken,
} from './token-rotation.js';
describe('runClaudeRotationLoop', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getTokenCount).mockReturnValue(1);
vi.mocked(rotateToken).mockReturnValue(false);
});
it('rotates and succeeds after an org-access-denied trigger', async () => {
vi.mocked(getTokenCount).mockReturnValue(2);
vi.mocked(rotateToken).mockReturnValueOnce(true);
const outcome = await runClaudeRotationLoop(
{ reason: 'org-access-denied' },
async () => ({
output: { status: 'success', result: 'ok' },
sawOutput: true,
}),
{ runId: 'rotate-org-access' },
);
expect(outcome).toEqual({ type: 'success' });
expect(rotateToken).toHaveBeenCalledTimes(1);
expect(markTokenHealthy).toHaveBeenCalledTimes(1);
expect(markPrimaryCooldown).not.toHaveBeenCalled();
});
it('marks no-fallback cooldown when all Claude tokens are org-access-denied', async () => {
vi.mocked(getTokenCount).mockReturnValue(2);
const outcome = await runClaudeRotationLoop(
{ reason: 'org-access-denied' },
async () => ({
output: { status: 'success', result: 'should not run' },
sawOutput: true,
}),
{ runId: 'no-fallback-org-access' },
);
expect(outcome).toEqual({
type: 'no-fallback',
trigger: { reason: 'org-access-denied' },
});
expect(markPrimaryCooldown).toHaveBeenCalledWith(
'org-access-denied',
undefined,
);
});
it('returns success-null-result as a fallback trigger after rotation', async () => {
vi.mocked(getTokenCount).mockReturnValue(2);
vi.mocked(rotateToken).mockReturnValueOnce(true);
const outcome = await runClaudeRotationLoop(
{ reason: '429' },
async () => ({
output: { status: 'success', result: null },
sawOutput: false,
sawSuccessNullResult: true,
}),
{ runId: 'success-null-result' },
);
expect(outcome).toEqual({
type: 'needs-fallback',
trigger: { reason: 'success-null-result' },
});
expect(markPrimaryCooldown).not.toHaveBeenCalled();
});
});

View File

@@ -5,7 +5,11 @@
* to eliminate the ~255-line structural duplication.
*/
import { shouldRotateClaudeToken } from './agent-error-detection.js';
import {
isNoFallbackCooldownReason,
shouldRotateClaudeToken,
type AgentTriggerReason,
} from './agent-error-detection.js';
import { logger } from './logger.js';
import { getErrorMessage } from './utils.js';
import {
@@ -21,7 +25,7 @@ import {
// ── Types ────────────────────────────────────────────────────────
export interface TriggerInfo {
reason: string;
reason: AgentTriggerReason;
retryAfterMs?: number;
}
@@ -163,11 +167,7 @@ export async function runClaudeRotationLoop(
// ── All tokens exhausted ──
// Usage/auth/org access failures: don't fall back to Kimi
if (
trigger.reason === 'usage-exhausted' ||
trigger.reason === 'auth-expired' ||
trigger.reason === 'org-access-denied'
) {
if (isNoFallbackCooldownReason(trigger.reason)) {
markPrimaryCooldown(trigger.reason, trigger.retryAfterMs);
logger.info(
{ ...logContext, reason: trigger.reason },

View File

@@ -3,13 +3,14 @@ import {
isClaudeAuthExpiredMessage,
isClaudeOrgAccessDeniedMessage,
isClaudeUsageExhaustedMessage,
type AgentTriggerReason,
} from './agent-error-detection.js';
import type { AgentOutput } from './agent-runner.js';
import { detectCodexRotationTrigger } from './codex-token-rotation.js';
import { detectFallbackTrigger } from './provider-fallback.js';
export interface StreamedTriggerReason {
reason: string;
reason: AgentTriggerReason;
retryAfterMs?: number;
}
@@ -51,7 +52,8 @@ export function evaluateStreamedOutput(
!state.sawOutput &&
typeof output.result === 'string'
) {
const triggerReason = isClaudeUsageExhaustedMessage(output.result)
const triggerReason: AgentTriggerReason | undefined =
isClaudeUsageExhaustedMessage(output.result)
? 'usage-exhausted'
: isClaudeOrgAccessDeniedMessage(output.result)
? 'org-access-denied'

View File

@@ -49,6 +49,7 @@ import {
getCodexAccountCount,
markCodexTokenHealthy,
} from './codex-token-rotation.js';
import type { AgentTriggerReason, CodexRotationReason } from './agent-error-detection.js';
import {
getTokenCount,
markTokenHealthy,
@@ -290,7 +291,7 @@ async function runTask(
output: AgentOutput;
sawOutput: boolean;
streamedTriggerReason?: {
reason: string;
reason: AgentTriggerReason;
retryAfterMs?: number;
};
attemptResult: string | null;
@@ -410,7 +411,7 @@ async function runTask(
};
const runFallbackTaskAttempt = async (
reason: string,
reason: AgentTriggerReason,
retryAfterMs?: number,
): Promise<void> => {
if (!canFallback) {
@@ -443,7 +444,7 @@ async function runTask(
const retryClaudeTaskWithRotation = async (
initialTrigger: {
reason: string;
reason: AgentTriggerReason;
retryAfterMs?: number;
},
rotationMessage?: string,
@@ -489,7 +490,7 @@ async function runTask(
};
const retryCodexTaskWithRotation = async (
initialTrigger: { reason: string },
initialTrigger: { reason: CodexRotationReason },
rotationMessage?: string,
): Promise<void> => {
let trigger = initialTrigger;
@@ -518,7 +519,9 @@ async function runTask(
retryAttempt.streamedTriggerReason &&
retryAttempt.output.status !== 'error'
) {
trigger = { reason: retryAttempt.streamedTriggerReason.reason };
trigger = {
reason: retryAttempt.streamedTriggerReason.reason as CodexRotationReason,
};
lastRotationMessage =
typeof retryAttempt.output.result === 'string'
? retryAttempt.output.result
@@ -530,7 +533,8 @@ async function runTask(
const retryTrigger = retryAttempt.streamedTriggerReason
? {
shouldRotate: true,
reason: retryAttempt.streamedTriggerReason.reason,
reason:
retryAttempt.streamedTriggerReason.reason as CodexRotationReason,
}
: detectCodexRotationTrigger(
retryAttempt.attemptError || retryAttempt.output.error,
@@ -589,7 +593,9 @@ async function runTask(
!attempt.sawOutput
) {
await retryCodexTaskWithRotation(
{ reason: attempt.streamedTriggerReason.reason },
{
reason: attempt.streamedTriggerReason.reason as CodexRotationReason,
},
typeof attempt.output.error === 'string'
? attempt.output.error
: undefined,
@@ -612,7 +618,7 @@ async function runTask(
const trigger = attempt.streamedTriggerReason
? {
shouldRotate: true,
reason: attempt.streamedTriggerReason.reason,
reason: attempt.streamedTriggerReason.reason as CodexRotationReason,
}
: detectCodexRotationTrigger(error);
if (trigger.shouldRotate) {