Merge pull request #2302 from thedotmack/codex/remove-agent-pool-timeout

[codex] Remove agent pool timeout data loss
This commit is contained in:
Alex Newman
2026-05-05 14:46:28 -07:00
committed by GitHub
10 changed files with 541 additions and 266 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+221 -17
View File
@@ -7,6 +7,7 @@ import { homedir } from 'os';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { SettingsDefaultsManager, type SettingsDefaults } from '../../shared/SettingsDefaultsManager.js'; import { SettingsDefaultsManager, type SettingsDefaults } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js'; import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import { loadClaudeMemEnv, saveClaudeMemEnv } from '../../shared/EnvManager.js';
import { ensureWorkerStarted, type WorkerStartResult } from '../../services/worker-spawner.js'; import { ensureWorkerStarted, type WorkerStartResult } from '../../services/worker-spawner.js';
import { import {
ensureBun, ensureBun,
@@ -588,13 +589,143 @@ function mergeSettings(updates: Record<string, string>): boolean {
} }
type ProviderId = 'claude' | 'gemini' | 'openrouter'; type ProviderId = 'claude' | 'gemini' | 'openrouter';
type ClaudeAccessMode = 'subscription' | 'api-key';
type ClaudeApiMode = 'direct' | 'gateway';
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';
return 'subscription';
}
async function promptProvider(options: InstallOptions): Promise<ProviderId> { async function promptProvider(options: InstallOptions): Promise<ProviderId> {
const initialProvider = (getSetting('CLAUDE_MEM_PROVIDER') as ProviderId) || 'claude'; const initialProvider = (getSetting('CLAUDE_MEM_PROVIDER') as ProviderId) || 'claude';
const persistClaudeProvider = () => { const persistClaudeProvider = (authMethod?: 'subscription' | 'api-key' | 'gateway') => {
const wrote = mergeSettings({ CLAUDE_MEM_PROVIDER: 'claude' }); const resolvedAuthMethod = authMethod ?? resolveClaudeAuthMethod();
if (wrote) log.info('Saved provider=claude to ~/.claude-mem/settings.json'); const wrote = mergeSettings({
CLAUDE_MEM_PROVIDER: 'claude',
CLAUDE_MEM_CLAUDE_AUTH_METHOD: resolvedAuthMethod,
});
if (wrote) log.info('Saved Claude Agent SDK configuration to ~/.claude-mem/settings.json');
};
const useSubscriptionAuth = () => {
persistClaudeProvider('subscription');
saveClaudeMemEnv({
ANTHROPIC_API_KEY: '',
ANTHROPIC_BASE_URL: '',
ANTHROPIC_AUTH_TOKEN: '',
});
log.info('Configured claude-mem to use your logged-in Claude SDK account.');
};
const configureDirectApiKey = async (): Promise<void> => {
const existing = loadClaudeMemEnv().ANTHROPIC_API_KEY || '';
if (existing.trim().length > 0) {
const choice = await p.select<'keep' | 'replace'>({
message: 'An Anthropic API key is already configured. Keep it or enter a new one?',
options: [
{ value: 'keep', label: 'Keep existing key' },
{ value: 'replace', label: 'Enter a new key (rotate)' },
],
initialValue: 'keep',
});
if (p.isCancel(choice)) {
log.warn('API key prompt cancelled — leaving existing configuration untouched.');
return;
}
if (choice === 'keep') {
saveClaudeMemEnv({
ANTHROPIC_API_KEY: existing.trim(),
ANTHROPIC_BASE_URL: '',
ANTHROPIC_AUTH_TOKEN: '',
});
persistClaudeProvider('api-key');
return;
}
}
const apiKeyResult = await p.password({
message: 'Paste your Anthropic API key:',
mask: '*',
validate: (v?: string) => (!v || v.trim().length === 0) ? 'API key required' : undefined,
});
if (p.isCancel(apiKeyResult)) {
log.warn('API key prompt cancelled — leaving existing configuration untouched.');
return;
}
saveClaudeMemEnv({
ANTHROPIC_API_KEY: String(apiKeyResult).trim(),
ANTHROPIC_BASE_URL: '',
ANTHROPIC_AUTH_TOKEN: '',
});
persistClaudeProvider('api-key');
log.info('Saved Anthropic API key for the Claude Agent SDK path.');
};
const configureGateway = async (): Promise<void> => {
const existing = loadClaudeMemEnv();
const baseUrlResult = await p.text({
message: 'Gateway URL:',
placeholder: existing.ANTHROPIC_BASE_URL || 'http://localhost:4000',
defaultValue: existing.ANTHROPIC_BASE_URL || '',
validate: (v?: string) => {
const value = v?.trim() ?? '';
if (!value) return 'Gateway URL required';
try {
new URL(value);
return undefined;
} catch {
return 'Enter a valid URL, for example http://localhost:4000';
}
},
});
if (p.isCancel(baseUrlResult)) {
log.warn('Gateway setup cancelled — leaving existing configuration untouched.');
return;
}
const tokenResult = await p.password({
message: 'Gateway key/token (leave blank to keep current token, or type a new one):',
mask: '*',
});
const tokenCancelled = p.isCancel(tokenResult);
const tokenInput = tokenCancelled ? '' : String(tokenResult).trim();
const env: Record<string, string> = {
ANTHROPIC_API_KEY: '',
ANTHROPIC_BASE_URL: String(baseUrlResult).trim(),
};
if (!tokenCancelled && tokenInput.length > 0) {
env.ANTHROPIC_AUTH_TOKEN = tokenInput;
}
saveClaudeMemEnv(env);
persistClaudeProvider('gateway');
if (tokenCancelled || tokenInput.length === 0) {
log.info('Gateway URL saved; existing gateway token preserved.');
} else {
log.info('Configured Claude Agent SDK gateway in ~/.claude-mem/.env.');
}
}; };
if (!isInteractive) { if (!isInteractive) {
@@ -611,29 +742,72 @@ async function promptProvider(options: InstallOptions): Promise<ProviderId> {
return initialProvider; return initialProvider;
} }
let selectedProvider: ProviderId; const runClaudeAuthFlow = async (): Promise<void> => {
if (options.provider) { const resolvedAuthMethod = resolveClaudeAuthMethod();
selectedProvider = options.provider; const initialAccessMode: ClaudeAccessMode =
} else { resolvedAuthMethod === 'subscription' ? 'subscription' : 'api-key';
const result = await p.select<ProviderId>({
message: 'Which LLM provider should claude-mem use to compress observations?', const result = await p.select<ClaudeAccessMode>({
message: 'Do you use a subscription plan or an API key/gateway for the memory agent?',
options: [ options: [
{ value: 'claude', label: 'Claude Code auth (default — no extra setup, uses your existing Claude Code subscription)' }, { value: 'subscription', label: 'Subscription plan (recommended — uses your logged-in Claude SDK account)' },
{ value: 'gemini', label: 'Gemini API key (free tier available — fast and cheap)' }, { value: 'api-key', label: 'API key or gateway (Anthropic, LiteLLM, or compatible proxy)' },
{ value: 'openrouter', label: 'OpenRouter API key (BYO model — wide selection of frontier and open models)' },
], ],
initialValue: initialProvider, initialValue: initialAccessMode,
}); });
if (p.isCancel(result)) { if (p.isCancel(result)) {
p.cancel('Installation cancelled.'); p.cancel('Installation cancelled.');
process.exit(0); process.exit(0);
} }
selectedProvider = result as ProviderId; if (result === 'subscription') {
useSubscriptionAuth();
return;
}
const apiModeResult = await p.select<ClaudeApiMode>({
message: 'How should claude-mem connect?',
options: [
{ value: 'direct', label: 'Anthropic API key' },
{ value: 'gateway', label: 'LiteLLM or custom gateway' },
],
initialValue: resolvedAuthMethod === 'gateway' || loadClaudeMemEnv().ANTHROPIC_BASE_URL ? 'gateway' : 'direct',
});
if (p.isCancel(apiModeResult)) {
p.cancel('Installation cancelled.');
process.exit(0);
}
if (apiModeResult === 'gateway') {
await configureGateway();
} else {
await configureDirectApiKey();
}
};
let selectedProvider: ProviderId;
if (options.provider) {
selectedProvider = options.provider;
} else {
const providerResult = await p.select<ProviderId>({
message: 'Which memory provider do you want to use?',
options: [
{ value: 'claude', label: 'Claude Agent SDK (recommended)' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'openrouter', label: 'OpenRouter' },
],
initialValue: initialProvider,
});
if (p.isCancel(providerResult)) {
p.cancel('Installation cancelled.');
process.exit(0);
}
selectedProvider = providerResult;
} }
if (selectedProvider === 'claude') { if (selectedProvider === 'claude') {
persistClaudeProvider(); await runClaudeAuthFlow();
return 'claude'; return 'claude';
} }
@@ -652,7 +826,7 @@ async function promptProvider(options: InstallOptions): Promise<ProviderId> {
const apiKeyResult = await p.password({ const apiKeyResult = await p.password({
message: `Paste your ${providerLabel} API key:`, message: `Paste your ${providerLabel} API key:`,
mask: '*', mask: '*',
validate: (v: string) => (!v || v.trim().length === 0) ? 'API key required' : undefined, validate: (v?: string) => (!v || v.trim().length === 0) ? 'API key required' : undefined,
}); });
if (p.isCancel(apiKeyResult)) { if (p.isCancel(apiKeyResult)) {
@@ -678,8 +852,9 @@ async function promptClaudeModel(options: InstallOptions): Promise<void> {
'claude-sonnet-4-6', 'claude-sonnet-4-6',
'claude-opus-4-7', 'claude-opus-4-7',
]); ]);
const allowCustomModel = resolveClaudeAuthMethod() === 'gateway';
if (options.model) { if (options.model && !allowCustomModel) {
if (!allowed.has(options.model)) { if (!allowed.has(options.model)) {
throw new Error( throw new Error(
`Unknown Claude model: ${options.model}. Allowed: ${[...allowed].join(', ')}`, `Unknown Claude model: ${options.model}. Allowed: ${[...allowed].join(', ')}`,
@@ -691,10 +866,39 @@ async function promptClaudeModel(options: InstallOptions): Promise<void> {
} }
return; return;
} }
if (options.model && allowCustomModel) {
const wrote = mergeSettings({ CLAUDE_MEM_MODEL: options.model });
if (wrote) {
log.info(`Saved gateway model=${options.model} to ~/.claude-mem/settings.json`);
}
return;
}
if (!isInteractive) return; if (!isInteractive) return;
const initialModel = getSetting('CLAUDE_MEM_MODEL'); const initialModel = getSetting('CLAUDE_MEM_MODEL');
if (allowCustomModel) {
const result = await p.text({
message: 'Which model should the gateway use?',
placeholder: 'claude-haiku-4-5-20251001',
defaultValue: initialModel || 'claude-haiku-4-5-20251001',
validate: (v?: string) => (!v || v.trim().length === 0) ? 'Model required' : undefined,
});
if (p.isCancel(result)) {
p.cancel('Installation cancelled.');
process.exit(0);
}
const selectedModel = String(result).trim();
const wrote = mergeSettings({ CLAUDE_MEM_MODEL: selectedModel });
if (wrote) {
log.info(`Saved gateway model=${selectedModel} to ~/.claude-mem/settings.json`);
}
return;
}
const initialValue = allowed.has(initialModel) ? initialModel : 'claude-haiku-4-5-20251001'; const initialValue = allowed.has(initialModel) ? initialModel : 'claude-haiku-4-5-20251001';
const result = await p.select<string>({ const result = await p.select<string>({
+1 -1
View File
@@ -150,7 +150,7 @@ export class ClaudeProvider {
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH); const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const maxConcurrent = parseInt(settings.CLAUDE_MEM_MAX_CONCURRENT_AGENTS, 10) || 2; const maxConcurrent = parseInt(settings.CLAUDE_MEM_MAX_CONCURRENT_AGENTS, 10) || 2;
await waitForSlot(maxConcurrent, 60_000); await waitForSlot(maxConcurrent, session.abortController.signal);
const isolatedEnv = sanitizeEnv(await buildIsolatedEnvWithFreshOAuth()); const isolatedEnv = sanitizeEnv(await buildIsolatedEnvWithFreshOAuth());
const authMethod = getAuthMethodDescription(); const authMethod = getAuthMethodDescription();
@@ -147,16 +147,16 @@ export class SessionRoutes extends BaseRouteHandler {
const pendingStore = this.sessionManager.getPendingMessageStore(); const pendingStore = this.sessionManager.getPendingMessageStore();
try { try {
const cleared = pendingStore.clearPendingForSession(session.sessionDbId); const reset = pendingStore.resetProcessingToPending(session.sessionDbId);
if (cleared > 0) { if (reset > 0) {
logger.error('SESSION', `Cleared pending messages after generator error`, { logger.warn('SESSION', `Reset processing messages after generator error`, {
sessionId: session.sessionDbId, sessionId: session.sessionDbId,
cleared reset
}); });
} }
} catch (dbError) { } catch (dbError) {
const normalizedDbError = dbError instanceof Error ? dbError : new Error(String(dbError)); const normalizedDbError = dbError instanceof Error ? dbError : new Error(String(dbError));
logger.error('HTTP', 'Failed to clear pending messages', { logger.error('HTTP', 'Failed to reset processing messages after generator error', {
sessionId: session.sessionDbId sessionId: session.sessionDbId
}, normalizedDbError); }, normalizedDbError);
} }
@@ -87,6 +87,7 @@ export class SettingsRoutes extends BaseRouteHandler {
'CLAUDE_MEM_WORKER_PORT', 'CLAUDE_MEM_WORKER_PORT',
'CLAUDE_MEM_WORKER_HOST', 'CLAUDE_MEM_WORKER_HOST',
'CLAUDE_MEM_PROVIDER', 'CLAUDE_MEM_PROVIDER',
'CLAUDE_MEM_CLAUDE_AUTH_METHOD',
'CLAUDE_MEM_GEMINI_API_KEY', 'CLAUDE_MEM_GEMINI_API_KEY',
'CLAUDE_MEM_GEMINI_MODEL', 'CLAUDE_MEM_GEMINI_MODEL',
'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED', 'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED',
@@ -194,6 +195,13 @@ export class SettingsRoutes extends BaseRouteHandler {
} }
} }
if (settings.CLAUDE_MEM_CLAUDE_AUTH_METHOD) {
const validClaudeAuthMethods = ['subscription', 'api-key', 'gateway', 'cli'];
if (!validClaudeAuthMethods.includes(settings.CLAUDE_MEM_CLAUDE_AUTH_METHOD)) {
return { valid: false, error: 'CLAUDE_MEM_CLAUDE_AUTH_METHOD must be "subscription", "api-key", "gateway", or "cli"' };
}
}
if (settings.CLAUDE_MEM_GEMINI_MODEL) { if (settings.CLAUDE_MEM_GEMINI_MODEL) {
const validGeminiModels = ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-3-flash-preview']; const validGeminiModels = ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-3-flash-preview'];
if (!validGeminiModels.includes(settings.CLAUDE_MEM_GEMINI_MODEL)) { if (!validGeminiModels.includes(settings.CLAUDE_MEM_GEMINI_MODEL)) {
+34 -5
View File
@@ -13,6 +13,10 @@ export const ENV_FILE_PATH = paths.envFile();
const BLOCKED_ENV_VARS = [ const BLOCKED_ENV_VARS = [
'ANTHROPIC_API_KEY', // Issue #733: Prevent auto-discovery from project .env files 'ANTHROPIC_API_KEY', // Issue #733: Prevent auto-discovery from project .env files
'ANTHROPIC_AUTH_TOKEN', // Same leak risk as ANTHROPIC_API_KEY; a token inherited from the
// shell would otherwise short-circuit OAuth lookup at spawn time.
// The fresh token from ~/.claude-mem/.env is re-injected below
// when explicit gateway credentials are configured.
'CLAUDECODE', // Prevent "cannot be launched inside another Claude Code session" error 'CLAUDECODE', // Prevent "cannot be launched inside another Claude Code session" error
'CLAUDE_CODE_OAUTH_TOKEN', // Issue #2215: prevent stale parent-process token from leaking into 'CLAUDE_CODE_OAUTH_TOKEN', // Issue #2215: prevent stale parent-process token from leaking into
// isolated env. The fresh token is read from the keychain at spawn // isolated env. The fresh token is read from the keychain at spawn
@@ -22,6 +26,7 @@ const BLOCKED_ENV_VARS = [
export interface ClaudeMemEnv { export interface ClaudeMemEnv {
ANTHROPIC_API_KEY?: string; ANTHROPIC_API_KEY?: string;
ANTHROPIC_BASE_URL?: string; ANTHROPIC_BASE_URL?: string;
ANTHROPIC_AUTH_TOKEN?: string;
GEMINI_API_KEY?: string; GEMINI_API_KEY?: string;
OPENROUTER_API_KEY?: string; OPENROUTER_API_KEY?: string;
} }
@@ -56,7 +61,7 @@ function parseEnvFile(content: string): Record<string, string> {
function serializeEnvFile(env: Record<string, string>): string { function serializeEnvFile(env: Record<string, string>): string {
const lines: string[] = [ const lines: string[] = [
'# claude-mem credentials', '# claude-mem credentials',
'# This file stores API keys for claude-mem memory agent', '# This file stores keys and gateway settings for the claude-mem memory agent',
'# Edit this file or use claude-mem settings to configure', '# Edit this file or use claude-mem settings to configure',
'', '',
]; ];
@@ -83,6 +88,7 @@ export function loadClaudeMemEnv(): ClaudeMemEnv {
const result: ClaudeMemEnv = {}; const result: ClaudeMemEnv = {};
if (parsed.ANTHROPIC_API_KEY) result.ANTHROPIC_API_KEY = parsed.ANTHROPIC_API_KEY; if (parsed.ANTHROPIC_API_KEY) result.ANTHROPIC_API_KEY = parsed.ANTHROPIC_API_KEY;
if (parsed.ANTHROPIC_BASE_URL) result.ANTHROPIC_BASE_URL = parsed.ANTHROPIC_BASE_URL; if (parsed.ANTHROPIC_BASE_URL) result.ANTHROPIC_BASE_URL = parsed.ANTHROPIC_BASE_URL;
if (parsed.ANTHROPIC_AUTH_TOKEN) result.ANTHROPIC_AUTH_TOKEN = parsed.ANTHROPIC_AUTH_TOKEN;
if (parsed.GEMINI_API_KEY) result.GEMINI_API_KEY = parsed.GEMINI_API_KEY; if (parsed.GEMINI_API_KEY) result.GEMINI_API_KEY = parsed.GEMINI_API_KEY;
if (parsed.OPENROUTER_API_KEY) result.OPENROUTER_API_KEY = parsed.OPENROUTER_API_KEY; if (parsed.OPENROUTER_API_KEY) result.OPENROUTER_API_KEY = parsed.OPENROUTER_API_KEY;
@@ -126,6 +132,13 @@ export function saveClaudeMemEnv(env: ClaudeMemEnv): void {
delete updated.ANTHROPIC_BASE_URL; delete updated.ANTHROPIC_BASE_URL;
} }
} }
if (env.ANTHROPIC_AUTH_TOKEN !== undefined) {
if (env.ANTHROPIC_AUTH_TOKEN) {
updated.ANTHROPIC_AUTH_TOKEN = env.ANTHROPIC_AUTH_TOKEN;
} else {
delete updated.ANTHROPIC_AUTH_TOKEN;
}
}
if (env.GEMINI_API_KEY !== undefined) { if (env.GEMINI_API_KEY !== undefined) {
if (env.GEMINI_API_KEY) { if (env.GEMINI_API_KEY) {
updated.GEMINI_API_KEY = env.GEMINI_API_KEY; updated.GEMINI_API_KEY = env.GEMINI_API_KEY;
@@ -171,6 +184,9 @@ export function buildIsolatedEnv(includeCredentials: boolean = true): Record<str
if (credentials.ANTHROPIC_BASE_URL) { if (credentials.ANTHROPIC_BASE_URL) {
isolatedEnv.ANTHROPIC_BASE_URL = credentials.ANTHROPIC_BASE_URL; isolatedEnv.ANTHROPIC_BASE_URL = credentials.ANTHROPIC_BASE_URL;
} }
if (credentials.ANTHROPIC_AUTH_TOKEN) {
isolatedEnv.ANTHROPIC_AUTH_TOKEN = credentials.ANTHROPIC_AUTH_TOKEN;
}
if (credentials.GEMINI_API_KEY) { if (credentials.GEMINI_API_KEY) {
isolatedEnv.GEMINI_API_KEY = credentials.GEMINI_API_KEY; isolatedEnv.GEMINI_API_KEY = credentials.GEMINI_API_KEY;
} }
@@ -214,10 +230,15 @@ export async function buildIsolatedEnvWithFreshOAuth(
if (!includeCredentials) return isolatedEnv; if (!includeCredentials) return isolatedEnv;
// If the user already configured an ANTHROPIC_API_KEY in ~/.claude-mem/.env, // If the user already configured explicit Anthropic/gateway credentials in
// honor that and skip OAuth lookup entirely. API key auth is preferred when // ~/.claude-mem/.env, honor those and skip OAuth lookup entirely. A bare
// explicitly configured because it's stateless and stable. // ANTHROPIC_BASE_URL counts because gateways may be tokenless, and falling
if (isolatedEnv.ANTHROPIC_API_KEY) { // 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(); clearStaleMarker();
return isolatedEnv; return isolatedEnv;
} }
@@ -276,10 +297,18 @@ export function hasAnthropicApiKey(): boolean {
return !!env.ANTHROPIC_API_KEY; return !!env.ANTHROPIC_API_KEY;
} }
export function hasAnthropicAuthToken(): boolean {
const env = loadClaudeMemEnv();
return !!env.ANTHROPIC_AUTH_TOKEN;
}
export function getAuthMethodDescription(): string { export function getAuthMethodDescription(): string {
if (hasAnthropicApiKey()) { if (hasAnthropicApiKey()) {
return 'API key (from ~/.claude-mem/.env)'; return 'API key (from ~/.claude-mem/.env)';
} }
if (hasAnthropicAuthToken()) {
return 'Gateway auth token (from ~/.claude-mem/.env)';
}
// Note: this is a quick sync hint for logging — the authoritative OAuth // Note: this is a quick sync hint for logging — the authoritative OAuth
// path is buildIsolatedEnvWithFreshOAuth() which reads the keychain at // path is buildIsolatedEnvWithFreshOAuth() which reads the keychain at
// spawn time. process.env may or may not carry a token here. // spawn time. process.env may or may not carry a token here.
+1 -1
View File
@@ -74,7 +74,7 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_WORKER_HOST: '127.0.0.1', CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion', CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude
CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'cli', // Default to CLI subscription billing (not API key) CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'subscription', // Default to logged-in Claude SDK auth (not API key)
CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM) CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM)
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users
+47 -8
View File
@@ -174,9 +174,11 @@ export class ProcessRegistry {
unregister(id: string): void { unregister(id: string): void {
this.initialize(); this.initialize();
const existing = this.entries.get(id);
this.entries.delete(id); this.entries.delete(id);
this.runtimeProcesses.delete(id); this.runtimeProcesses.delete(id);
this.persist(); this.persist();
if (existing?.type === 'sdk') notifySlotAvailable();
} }
clear(): void { clear(): void {
@@ -213,16 +215,19 @@ export class ProcessRegistry {
this.initialize(); this.initialize();
let removed = 0; let removed = 0;
let removedSdk = 0;
for (const [id, info] of this.entries) { for (const [id, info] of this.entries) {
if (isPidAlive(info.pid)) continue; if (isPidAlive(info.pid)) continue;
this.entries.delete(id); this.entries.delete(id);
this.runtimeProcesses.delete(id); this.runtimeProcesses.delete(id);
removed += 1; removed += 1;
if (info.type === 'sdk') removedSdk += 1;
} }
if (removed > 0) { if (removed > 0) {
this.persist(); this.persist();
} }
for (let i = 0; i < removedSdk; i += 1) notifySlotAvailable();
return removed; return removed;
} }
@@ -321,6 +326,9 @@ export class ProcessRegistry {
this.runtimeProcesses.delete(record.id); this.runtimeProcesses.delete(record.id);
} }
this.persist(); this.persist();
for (const record of sessionRecords) {
if (record.type === 'sdk') notifySlotAvailable();
}
logger.info('SYSTEM', `Reaped ${sessionRecords.length} process(es) for session ${sessionId}`, { logger.info('SYSTEM', `Reaped ${sessionRecords.length} process(es) for session ${sessionId}`, {
sessionId: sessionIdNum, sessionId: sessionIdNum,
@@ -428,6 +436,7 @@ export async function ensureSdkProcessExit(
} }
const TOTAL_PROCESS_HARD_CAP = 10; const TOTAL_PROCESS_HARD_CAP = 10;
const SLOT_RECHECK_INTERVAL_MS = 5_000;
const slotWaiters: Array<() => void> = []; const slotWaiters: Array<() => void> = [];
function getActiveSdkCount(): number { function getActiveSdkCount(): number {
@@ -439,7 +448,8 @@ function notifySlotAvailable(): void {
if (waiter) waiter(); if (waiter) waiter();
} }
export async function waitForSlot(maxConcurrent: number, timeoutMs: number = 60_000): Promise<void> { export async function waitForSlot(maxConcurrent: number, signal?: AbortSignal): Promise<void> {
getProcessRegistry().pruneDeadEntries();
const activeCount = getActiveSdkCount(); const activeCount = getActiveSdkCount();
if (activeCount >= TOTAL_PROCESS_HARD_CAP) { if (activeCount >= TOTAL_PROCESS_HARD_CAP) {
throw new Error(`Hard cap exceeded: ${activeCount} processes in registry (cap=${TOTAL_PROCESS_HARD_CAP}). Refusing to spawn more.`); throw new Error(`Hard cap exceeded: ${activeCount} processes in registry (cap=${TOTAL_PROCESS_HARD_CAP}). Refusing to spawn more.`);
@@ -447,25 +457,55 @@ export async function waitForSlot(maxConcurrent: number, timeoutMs: number = 60_
if (activeCount < maxConcurrent) return; 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...`); logger.info('PROCESS', `Pool limit reached (${activeCount}/${maxConcurrent}), waiting for slot...`);
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => { 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); const idx = slotWaiters.indexOf(onSlot);
if (idx >= 0) slotWaiters.splice(idx, 1); if (idx >= 0) slotWaiters.splice(idx, 1);
reject(new Error(`Timed out waiting for agent pool slot after ${timeoutMs}ms`)); };
}, timeoutMs);
const onSlot = () => { const onSlot = () => {
clearTimeout(timeout); const count = getActiveSdkCount();
if (getActiveSdkCount() < maxConcurrent) { if (count >= TOTAL_PROCESS_HARD_CAP) {
cleanup();
reject(new Error(`Hard cap exceeded: ${count} processes in registry (cap=${TOTAL_PROCESS_HARD_CAP}). Refusing to spawn more.`));
return;
}
if (count < maxConcurrent) {
cleanup();
resolve(); resolve();
} else { } else {
slotWaiters.push(onSlot); slotWaiters.push(onSlot);
} }
}; };
if (signal) {
abortHandler = () => {
cleanup();
reject(new Error('waitForSlot aborted'));
};
signal.addEventListener('abort', abortHandler, { once: true });
}
slotWaiters.push(onSlot); slotWaiters.push(onSlot);
recheckTimer = setInterval(() => {
const removed = getProcessRegistry().pruneDeadEntries();
if (removed > 0) {
logger.info('PROCESS', 'Pruned stale process registry entries while waiting for agent slot', { removed });
return;
}
notifySlotAvailable();
}, SLOT_RECHECK_INTERVAL_MS);
recheckTimer.unref?.();
}); });
} }
@@ -565,7 +605,6 @@ export function spawnSdkProcess(
logger.warn('SDK_SPAWN', `[session-${sessionDbId}] Claude process exited`, { code, signal, pid }); logger.warn('SDK_SPAWN', `[session-${sessionDbId}] Claude process exited`, { code, signal, pid });
} }
registry.unregister(recordId); registry.unregister(recordId);
notifySlotAvailable();
}); });
if (!child.stdin || !child.stdout || !child.stderr) { if (!child.stdin || !child.stdout || !child.stderr) {