From 8cacff28f67d54781aae209d8b59dfa67d67b78d Mon Sep 17 00:00:00 2001 From: Michael Ludlow Date: Mon, 12 Jan 2026 22:17:46 -0500 Subject: [PATCH] fix(ACS-181): enable auto-switch on 401 auth errors & OAuth-only profiles (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Signed-off-by: Black Circle Sentinel * 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 * 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 * 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 Co-authored-by: Claude Opus 4.5 --- .gitignore | 1 + .../src/main/claude-profile/profile-scorer.ts | 13 ++- .../src/main/claude-profile/profile-utils.ts | 10 +- .../src/main/claude-profile/usage-monitor.ts | 97 +++++++++++++++++-- apps/frontend/src/main/index.ts | 40 +++++--- 5 files changed, 134 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 6d2e4585..e0acc0dc 100644 --- a/.gitignore +++ b/.gitignore @@ -168,3 +168,4 @@ OPUS_ANALYSIS_AND_IDEAS.md # Auto Claude generated files .security-key +/shared_docs \ No newline at end of file diff --git a/apps/frontend/src/main/claude-profile/profile-scorer.ts b/apps/frontend/src/main/claude-profile/profile-scorer.ts index fc0f8ecc..25b58816 100644 --- a/apps/frontend/src/main/claude-profile/profile-scorer.ts +++ b/apps/frontend/src/main/claude-profile/profile-scorer.ts @@ -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 }; }); diff --git a/apps/frontend/src/main/claude-profile/profile-utils.ts b/apps/frontend/src/main/claude-profile/profile-utils.ts index 80a3c048..e6d8ceea 100644 --- a/apps/frontend/src/main/claude-profile/profile-utils.ts +++ b/apps/frontend/src/main/claude-profile/profile-utils.ts @@ -56,9 +56,16 @@ export async function createProfileDirectory(profileName: string): Promise = 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 { 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, diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index 8ee2eaf7..612e26a2 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -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) {