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:
Michael Ludlow
2026-01-12 22:17:46 -05:00
committed by AndyMik90
parent 1701160b8a
commit e7427321c5
5 changed files with 134 additions and 27 deletions
+1
View File
@@ -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,
+25 -15
View File
@@ -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) {