fix(ACS-181): enable auto-switch on 401 auth errors & OAuth-only profiles (#900)
* fix(ACS-181): enable auto-switch for OAuth-only profiles Add OAuth token check at the start of isProfileAuthenticated() so that profiles with only an oauthToken (no configDir) are recognized as authenticated. This allows the profile scorer to consider OAuth-only profiles as valid alternatives for proactive auto-switching. Previously, isProfileAuthenticated() immediately returned false if configDir was missing, causing OAuth-only profiles to receive a -500 penalty in the scorer and never be selected for auto-switch. Fixes: ACS-181 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Black Circle Sentinel <mludlow000@icloud.com> * fix(ACS-181): detect 'out of extra usage' rate limit messages The previous patterns only matched "Limit reached · resets ..." but Claude Code also shows "You're out of extra usage · resets ..." which wasn't being detected. This prevented auto-switch from triggering. Added new patterns to both output-parser.ts (terminal) and rate-limit-detector.ts (agent processes) to detect: - "out of extra usage · resets ..." - "You're out of extra usage · resets ..." Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ACS-181): add real-time rate limit detection and debug logging - Add real-time rate limit detection in agent-process.ts processLog() so rate limits are detected immediately as output appears, not just when the process exits - Add clear warning message when auto-switch is disabled in settings - Add debug logging to profile-scorer.ts to trace profile evaluation - Add debug logging to rate-limit-detector.ts to trace pattern matching This enables immediate detection and auto-switch when rate limits occur during task execution. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(frontend): enable auto-switch on 401 auth errors - Propagate 401/403 errors from fetchUsageViaAPI to checkUsageAndSwap in UsageMonitor to trigger proactive profile swapping. - Fix usage monitor race condition by ensuring it waits for ClaudeProfileManager initialization. - Fix isProfileAuthenticated to correctly validate OAuth-only profiles. * fix(ACS-181): address PR review feedback - Revert unrelated files (rate-limit-detector, output-parser, agent-process) to upstream state - Gate profile-scorer logging behind DEBUG flag - Fix usage-monitor type safety and correct catch block syntax - Fix misleading indentation in index.ts app updater block * fix(frontend): enforce eslint compliance for logs in profile-scorer - Replace all console.log with console.warn (per linter rules) - Strictly gate all debug logs behind isDebug check to prevent production noise * fix(ACS-181): add swap loop protection for auth failures - Add authFailedProfiles Map to track profiles with recent auth failures - Implement 5-minute cooldown before retrying failed profiles - Exclude failed profiles from swap candidates to prevent infinite loops - Gate TRACE logs behind DEBUG flag to reduce production noise - Change console.log to console.warn for ESLint compliance --------- Signed-off-by: Black Circle Sentinel <mludlow000@icloud.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
AndyMik90
parent
1701160b8a
commit
e7427321c5
@@ -168,3 +168,4 @@ OPUS_ANALYSIS_AND_IDEAS.md
|
||||
|
||||
# Auto Claude generated files
|
||||
.security-key
|
||||
/shared_docs
|
||||
@@ -35,12 +35,19 @@ export function getBestAvailableProfile(
|
||||
// 2. Lower weekly usage (more important than session)
|
||||
// 3. Lower session usage
|
||||
// 4. More recently authenticated
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[ProfileScorer] Evaluating', candidates.length, 'candidate profiles (excluding:', excludeProfileId, ')');
|
||||
}
|
||||
|
||||
const scoredProfiles: ScoredProfile[] = candidates.map(profile => {
|
||||
let score = 100; // Base score
|
||||
if (isDebug) console.warn('[ProfileScorer] Scoring profile:', profile.name, '(', profile.id, ')');
|
||||
|
||||
// Check rate limit status
|
||||
const rateLimitStatus = isProfileRateLimited(profile);
|
||||
if (isDebug) console.warn('[ProfileScorer] Rate limit status:', rateLimitStatus);
|
||||
if (rateLimitStatus.limited) {
|
||||
// Severely penalize rate-limited profiles
|
||||
if (rateLimitStatus.type === 'weekly') {
|
||||
@@ -73,10 +80,14 @@ export function getBestAvailableProfile(
|
||||
}
|
||||
|
||||
// Check if authenticated
|
||||
if (!isProfileAuthenticated(profile)) {
|
||||
const isAuth = isProfileAuthenticated(profile);
|
||||
if (isDebug) console.warn('[ProfileScorer] isProfileAuthenticated:', isAuth, 'hasOAuthToken:', !!profile.oauthToken, 'hasConfigDir:', !!profile.configDir);
|
||||
if (!isAuth) {
|
||||
score -= 500; // Severely penalize unauthenticated profiles
|
||||
if (isDebug) console.warn('[ProfileScorer] Applied -500 penalty for no auth');
|
||||
}
|
||||
|
||||
if (isDebug) console.warn('[ProfileScorer] Final score:', score);
|
||||
return { profile, score };
|
||||
});
|
||||
|
||||
|
||||
@@ -56,9 +56,16 @@ export async function createProfileDirectory(profileName: string): Promise<strin
|
||||
|
||||
/**
|
||||
* Check if a profile has valid authentication
|
||||
* (checks if the config directory has credential files or OAuth account info)
|
||||
* (checks for OAuth token or config directory credential files)
|
||||
*/
|
||||
export function isProfileAuthenticated(profile: ClaudeProfile): boolean {
|
||||
// Check for direct OAuth token first (OAuth-only profiles without configDir)
|
||||
// This enables auto-switch to work with profiles that only have oauthToken set
|
||||
if (hasValidToken(profile)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for configDir-based credentials (legacy or CLI-authenticated profiles)
|
||||
const configDir = profile.configDir;
|
||||
if (!configDir || !existsSync(configDir)) {
|
||||
return false;
|
||||
@@ -134,7 +141,6 @@ export function hasValidToken(profile: ClaudeProfile): boolean {
|
||||
const oneYearAgo = new Date();
|
||||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
||||
if (new Date(profile.tokenCreatedAt) < oneYearAgo) {
|
||||
console.warn('[ProfileUtils] Token expired for profile:', profile.name);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ export class UsageMonitor extends EventEmitter {
|
||||
private currentUsage: ClaudeUsageSnapshot | null = null;
|
||||
private isChecking = false;
|
||||
private useApiMethod = true; // Try API first, fall back to CLI if it fails
|
||||
|
||||
// Swap loop protection: track profiles that recently failed auth
|
||||
private authFailedProfiles: Map<string, number> = new Map(); // profileId -> timestamp
|
||||
private static AUTH_FAILURE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes cooldown
|
||||
|
||||
// Debug flag for verbose logging
|
||||
private readonly isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
@@ -40,7 +47,7 @@ export class UsageMonitor extends EventEmitter {
|
||||
const settings = profileManager.getAutoSwitchSettings();
|
||||
|
||||
if (!settings.enabled || !settings.proactiveSwapEnabled) {
|
||||
console.warn('[UsageMonitor] Proactive monitoring disabled');
|
||||
console.warn('[UsageMonitor] Proactive monitoring disabled. Settings:', JSON.stringify(settings, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -118,6 +125,15 @@ export class UsageMonitor extends EventEmitter {
|
||||
const weeklyExceeded = usage.weeklyPercent >= settings.weeklyThreshold;
|
||||
|
||||
if (sessionExceeded || weeklyExceeded) {
|
||||
if (this.isDebug) {
|
||||
console.warn('[UsageMonitor:TRACE] Threshold exceeded', {
|
||||
sessionPercent: usage.sessionPercent,
|
||||
weekPercent: usage.weeklyPercent,
|
||||
activeProfile: activeProfile.id,
|
||||
hasToken: !!decryptedToken
|
||||
});
|
||||
}
|
||||
|
||||
console.warn('[UsageMonitor] Threshold exceeded:', {
|
||||
sessionPercent: usage.sessionPercent,
|
||||
sessionThreshold: settings.sessionThreshold,
|
||||
@@ -130,8 +146,48 @@ export class UsageMonitor extends EventEmitter {
|
||||
activeProfile.id,
|
||||
sessionExceeded ? 'session' : 'weekly'
|
||||
);
|
||||
} else {
|
||||
if (this.isDebug) {
|
||||
console.warn('[UsageMonitor:TRACE] Usage OK', {
|
||||
sessionPercent: usage.sessionPercent,
|
||||
weekPercent: usage.weeklyPercent
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Check for auth failure (401/403) from fetchUsageViaAPI
|
||||
if ((error as any).statusCode === 401 || (error as any).statusCode === 403) {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
|
||||
if (activeProfile) {
|
||||
// Mark this profile as auth-failed to prevent swap loops
|
||||
this.authFailedProfiles.set(activeProfile.id, Date.now());
|
||||
console.warn('[UsageMonitor] Auth failure detected, marked profile as failed:', activeProfile.id);
|
||||
|
||||
// Clean up expired entries from the failed profiles map
|
||||
const now = Date.now();
|
||||
this.authFailedProfiles.forEach((timestamp, profileId) => {
|
||||
if (now - timestamp > UsageMonitor.AUTH_FAILURE_COOLDOWN_MS) {
|
||||
this.authFailedProfiles.delete(profileId);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const excludeProfiles = Array.from(this.authFailedProfiles.keys());
|
||||
console.warn('[UsageMonitor] Attempting proactive swap (excluding failed profiles):', excludeProfiles);
|
||||
await this.performProactiveSwap(
|
||||
activeProfile.id,
|
||||
'session', // Treat auth failure as session limit for immediate swap
|
||||
excludeProfiles
|
||||
);
|
||||
return;
|
||||
} catch (swapError) {
|
||||
console.error('[UsageMonitor] Failed to perform auth-failure swap:', swapError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error('[UsageMonitor] Check failed:', error);
|
||||
} finally {
|
||||
this.isChecking = false;
|
||||
@@ -190,6 +246,12 @@ export class UsageMonitor extends EventEmitter {
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[UsageMonitor] API error:', response.status, response.statusText);
|
||||
// Throw specific error for auth failures so we can trigger a swap
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const error = new Error(`API Auth Failure: ${response.status}`);
|
||||
(error as any).statusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -220,7 +282,12 @@ export class UsageMonitor extends EventEmitter {
|
||||
? 'weekly'
|
||||
: 'session'
|
||||
};
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// Re-throw auth failures to be handled by checkUsageAndSwap
|
||||
if (error?.statusCode === 401 || error?.statusCode === 403) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error('[UsageMonitor] API fetch failed:', error);
|
||||
return null;
|
||||
}
|
||||
@@ -270,22 +337,34 @@ export class UsageMonitor extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Perform proactive profile swap
|
||||
* @param currentProfileId - The profile to switch from
|
||||
* @param limitType - The type of limit that triggered the swap
|
||||
* @param additionalExclusions - Additional profile IDs to exclude (e.g., auth-failed profiles)
|
||||
*/
|
||||
private async performProactiveSwap(
|
||||
currentProfileId: string,
|
||||
limitType: 'session' | 'weekly'
|
||||
limitType: 'session' | 'weekly',
|
||||
additionalExclusions: string[] = []
|
||||
): Promise<void> {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const bestProfile = profileManager.getBestAvailableProfile(currentProfileId);
|
||||
|
||||
if (!bestProfile) {
|
||||
console.warn('[UsageMonitor] No alternative profile for proactive swap');
|
||||
|
||||
// Get all profiles to swap to, excluding current and any additional exclusions
|
||||
const allProfiles = profileManager.getProfilesSortedByAvailability();
|
||||
const excludeIds = new Set([currentProfileId, ...additionalExclusions]);
|
||||
const eligibleProfiles = allProfiles.filter(p => !excludeIds.has(p.id));
|
||||
|
||||
if (eligibleProfiles.length === 0) {
|
||||
console.warn('[UsageMonitor] No alternative profile for proactive swap (excluded:', Array.from(excludeIds), ')');
|
||||
this.emit('proactive-swap-failed', {
|
||||
reason: 'no_alternative',
|
||||
currentProfile: currentProfileId
|
||||
reason: additionalExclusions.length > 0 ? 'all_alternatives_failed_auth' : 'no_alternative',
|
||||
currentProfile: currentProfileId,
|
||||
excludedProfiles: Array.from(excludeIds)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the best available from eligible profiles
|
||||
const bestProfile = eligibleProfiles[0];
|
||||
|
||||
console.warn('[UsageMonitor] Proactive swap:', {
|
||||
from: currentProfileId,
|
||||
|
||||
@@ -359,24 +359,34 @@ app.whenReady().then(() => {
|
||||
});
|
||||
});
|
||||
|
||||
// Pre-initialize Claude profile manager in background (non-blocking)
|
||||
// This ensures profile data is loaded before user clicks "Start Claude Code"
|
||||
setImmediate(() => {
|
||||
initializeClaudeProfileManager().catch((error) => {
|
||||
console.warn('[main] Failed to pre-initialize profile manager:', error);
|
||||
// Initialize Claude profile manager, then start usage monitor
|
||||
// We do this sequentially to ensure profile data (including auto-switch settings)
|
||||
// is loaded BEFORE the usage monitor attempts to read settings.
|
||||
// This prevents the "UsageMonitor disabled" error due to race condition.
|
||||
initializeClaudeProfileManager()
|
||||
.then(() => {
|
||||
// Only start monitoring if window is still available (app not quitting)
|
||||
if (mainWindow) {
|
||||
// Setup event forwarding from usage monitor to renderer
|
||||
initializeUsageMonitorForwarding(mainWindow);
|
||||
|
||||
// Start the usage monitor
|
||||
const usageMonitor = getUsageMonitor();
|
||||
usageMonitor.start();
|
||||
console.warn('[main] Usage monitor initialized and started (after profile load)');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[main] Failed to initialize profile manager:', error);
|
||||
// Fallback: try starting usage monitor anyway (might use defaults)
|
||||
if (mainWindow) {
|
||||
initializeUsageMonitorForwarding(mainWindow);
|
||||
const usageMonitor = getUsageMonitor();
|
||||
usageMonitor.start();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize usage monitoring after window is created
|
||||
if (mainWindow) {
|
||||
// Setup event forwarding from usage monitor to renderer
|
||||
initializeUsageMonitorForwarding(mainWindow);
|
||||
|
||||
// Start the usage monitor
|
||||
const usageMonitor = getUsageMonitor();
|
||||
usageMonitor.start();
|
||||
console.warn('[main] Usage monitor initialized and started');
|
||||
|
||||
// Log debug mode status
|
||||
const isDebugMode = process.env.DEBUG === 'true';
|
||||
if (isDebugMode) {
|
||||
|
||||
Reference in New Issue
Block a user