fix: address Greptile P1 + CodeRabbit follow-ups (cycle 9)

- waitForSlot now accepts an optional AbortSignal. When the signal
  fires (e.g. session.abortController.abort() during shutdown or
  cancel), the queued waiter is removed from slotWaiters and the
  promise rejects immediately, instead of hanging until a slot
  naturally opens. Restores the cancellation guarantee that the
  removed 60s timeout used to provide. ClaudeProvider.startSession
  now passes session.abortController.signal at the call site.
- EnvManager: a bare ANTHROPIC_BASE_URL now also short-circuits the
  OAuth lookup. Tokenless gateways (allowed by the new install flow)
  were otherwise being authenticated against api.anthropic.com via the
  injected OS-keychain OAuth token.
- install.ts: resolveClaudeAuthMethod now reads the raw stored
  CLAUDE_MEM_CLAUDE_AUTH_METHOD value via a direct settings.json read
  (readRawStoredAuthMethod), bypassing SettingsDefaultsManager's
  default backfill. Without this, getSetting() always returned
  'subscription' for unmigrated installs and the env-based fallback
  never ran — so the previous fix only addressed the optics, not
  the actual misclassification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-05-04 21:07:42 -07:00
parent 5edf1557c4
commit a122d34ebf
4 changed files with 46 additions and 14 deletions
+15 -8
View File
@@ -588,15 +588,22 @@ type ProviderId = 'claude' | 'gemini' | 'openrouter';
type ClaudeAccessMode = 'subscription' | 'api-key';
type ClaudeApiMode = 'direct' | 'gateway';
function resolveClaudeAuthMethod(): 'subscription' | 'api-key' | 'gateway' {
const stored = getSetting('CLAUDE_MEM_CLAUDE_AUTH_METHOD') as
| 'subscription'
| 'api-key'
| 'gateway'
| undefined;
if (stored === 'subscription' || stored === 'api-key' || stored === 'gateway') {
return stored;
function readRawStoredAuthMethod(): 'subscription' | 'api-key' | 'gateway' | undefined {
try {
if (!existsSync(USER_SETTINGS_PATH)) return undefined;
const raw = JSON.parse(readFileSync(USER_SETTINGS_PATH, 'utf-8')) as Record<string, unknown>;
const flat = (raw.env && typeof raw.env === 'object' ? raw.env : raw) as Record<string, unknown>;
const value = flat.CLAUDE_MEM_CLAUDE_AUTH_METHOD;
if (value === 'subscription' || value === 'api-key' || value === 'gateway') return value;
return undefined;
} catch {
return undefined;
}
}
function resolveClaudeAuthMethod(): 'subscription' | 'api-key' | 'gateway' {
const stored = readRawStoredAuthMethod();
if (stored) return stored;
const env = loadClaudeMemEnv();
if (env.ANTHROPIC_BASE_URL?.trim()) return 'gateway';
if (env.ANTHROPIC_API_KEY?.trim()) return 'api-key';
+1 -1
View File
@@ -150,7 +150,7 @@ export class ClaudeProvider {
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const maxConcurrent = parseInt(settings.CLAUDE_MEM_MAX_CONCURRENT_AGENTS, 10) || 2;
await waitForSlot(maxConcurrent);
await waitForSlot(maxConcurrent, session.abortController.signal);
const isolatedEnv = sanitizeEnv(await buildIsolatedEnvWithFreshOAuth());
const authMethod = getAuthMethodDescription();
+8 -2
View File
@@ -231,8 +231,14 @@ export async function buildIsolatedEnvWithFreshOAuth(
if (!includeCredentials) return isolatedEnv;
// If the user already configured explicit Anthropic/gateway credentials in
// ~/.claude-mem/.env, honor those and skip OAuth lookup entirely.
if (isolatedEnv.ANTHROPIC_API_KEY || isolatedEnv.ANTHROPIC_AUTH_TOKEN) {
// ~/.claude-mem/.env, honor those and skip OAuth lookup entirely. A bare
// ANTHROPIC_BASE_URL counts because gateways may be tokenless, and falling
// back to OAuth would silently route requests to api.anthropic.com.
if (
isolatedEnv.ANTHROPIC_API_KEY ||
isolatedEnv.ANTHROPIC_BASE_URL ||
isolatedEnv.ANTHROPIC_AUTH_TOKEN
) {
clearStaleMarker();
return isolatedEnv;
}
+22 -3
View File
@@ -448,7 +448,7 @@ function notifySlotAvailable(): void {
if (waiter) waiter();
}
export async function waitForSlot(maxConcurrent: number): Promise<void> {
export async function waitForSlot(maxConcurrent: number, signal?: AbortSignal): Promise<void> {
getProcessRegistry().pruneDeadEntries();
const activeCount = getActiveSdkCount();
if (activeCount >= TOTAL_PROCESS_HARD_CAP) {
@@ -457,26 +457,45 @@ export async function waitForSlot(maxConcurrent: number): Promise<void> {
if (activeCount < maxConcurrent) return;
if (signal?.aborted) {
throw new Error('waitForSlot aborted before queuing');
}
logger.info('PROCESS', `Pool limit reached (${activeCount}/${maxConcurrent}), waiting for slot...`);
return new Promise<void>((resolve, reject) => {
let recheckTimer: ReturnType<typeof setInterval> | null = null;
let abortHandler: (() => void) | null = null;
const cleanup = () => {
if (recheckTimer) clearInterval(recheckTimer);
if (abortHandler && signal) signal.removeEventListener('abort', abortHandler);
const idx = slotWaiters.indexOf(onSlot);
if (idx >= 0) slotWaiters.splice(idx, 1);
};
const onSlot = () => {
const count = getActiveSdkCount();
if (count >= TOTAL_PROCESS_HARD_CAP) {
if (recheckTimer) clearInterval(recheckTimer);
cleanup();
reject(new Error(`Hard cap exceeded: ${count} processes in registry (cap=${TOTAL_PROCESS_HARD_CAP}). Refusing to spawn more.`));
return;
}
if (count < maxConcurrent) {
if (recheckTimer) clearInterval(recheckTimer);
cleanup();
resolve();
} else {
slotWaiters.push(onSlot);
}
};
if (signal) {
abortHandler = () => {
cleanup();
reject(new Error('waitForSlot aborted'));
};
signal.addEventListener('abort', abortHandler, { once: true });
}
slotWaiters.push(onSlot);
recheckTimer = setInterval(() => {
const removed = getProcessRegistry().pruneDeadEntries();