fix: address PR review feedback for subprocess cleanup and binary resolution

Wrap SDK query loop in try/finally so subprocess cleanup runs on error paths.
Swap Chroma binary check order to try project-level .bin first (common case).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-02-15 23:24:00 -05:00
parent 67ba17cc8a
commit 055888e181
3 changed files with 183 additions and 179 deletions
File diff suppressed because one or more lines are too long
+6 -5
View File
@@ -115,14 +115,15 @@ export class ChromaServerManager {
try { try {
// chromadb package installs a 'chroma' bin entry // chromadb package installs a 'chroma' bin entry
const chromaBinDir = path.dirname(require.resolve('chromadb/package.json')); const chromaBinDir = path.dirname(require.resolve('chromadb/package.json'));
const chromaBin = path.join(chromaBinDir, 'node_modules', '.bin', isWindows ? 'chroma.cmd' : 'chroma'); // Check project-level .bin first (most common npm/bun installation layout)
// Fallback: check project-level .bin
const projectBin = path.join(chromaBinDir, '..', '.bin', isWindows ? 'chroma.cmd' : 'chroma'); const projectBin = path.join(chromaBinDir, '..', '.bin', isWindows ? 'chroma.cmd' : 'chroma');
// Fallback: nested node_modules .bin (rare — pnpm or workspace hoisting)
const nestedBin = path.join(chromaBinDir, 'node_modules', '.bin', isWindows ? 'chroma.cmd' : 'chroma');
if (existsSync(chromaBin)) { if (existsSync(projectBin)) {
command = chromaBin;
} else if (existsSync(projectBin)) {
command = projectBin; command = projectBin;
} else if (existsSync(nestedBin)) {
command = nestedBin;
} else { } else {
// Last resort: npx with explicit cwd // Last resort: npx with explicit cwd
command = isWindows ? 'npx.cmd' : 'npx'; command = isWindows ? 'npx.cmd' : 'npx';
+127 -124
View File
@@ -141,143 +141,146 @@ export class SDKAgent {
} }
}); });
// Process SDK messages // Process SDK messages — cleanup in finally ensures subprocess termination
for await (const message of queryResult) { // even if the loop throws (e.g., context overflow, invalid API key)
// Capture or update memory session ID from SDK message try {
// IMPORTANT: The SDK may return a DIFFERENT session_id on resume than what we sent! for await (const message of queryResult) {
// We must always sync the DB to match what the SDK actually uses. // Capture or update memory session ID from SDK message
// // IMPORTANT: The SDK may return a DIFFERENT session_id on resume than what we sent!
// MULTI-TERMINAL COLLISION FIX (FK constraint bug): // We must always sync the DB to match what the SDK actually uses.
// Use ensureMemorySessionIdRegistered() instead of updateMemorySessionId() because: //
// 1. It's idempotent - safe to call multiple times // MULTI-TERMINAL COLLISION FIX (FK constraint bug):
// 2. It verifies the update happened (SELECT before UPDATE) // Use ensureMemorySessionIdRegistered() instead of updateMemorySessionId() because:
// 3. Consistent with ResponseProcessor's usage pattern // 1. It's idempotent - safe to call multiple times
// This ensures FK constraint compliance BEFORE any observations are stored. // 2. It verifies the update happened (SELECT before UPDATE)
if (message.session_id && message.session_id !== session.memorySessionId) { // 3. Consistent with ResponseProcessor's usage pattern
const previousId = session.memorySessionId; // This ensures FK constraint compliance BEFORE any observations are stored.
session.memorySessionId = message.session_id; if (message.session_id && message.session_id !== session.memorySessionId) {
// Persist to database IMMEDIATELY for FK constraint compliance const previousId = session.memorySessionId;
// This must happen BEFORE any observations referencing this ID are stored session.memorySessionId = message.session_id;
this.dbManager.getSessionStore().ensureMemorySessionIdRegistered( // Persist to database IMMEDIATELY for FK constraint compliance
session.sessionDbId, // This must happen BEFORE any observations referencing this ID are stored
message.session_id this.dbManager.getSessionStore().ensureMemorySessionIdRegistered(
); session.sessionDbId,
// Verify the update by reading back from DB message.session_id
const verification = this.dbManager.getSessionStore().getSessionById(session.sessionDbId); );
const dbVerified = verification?.memory_session_id === message.session_id; // Verify the update by reading back from DB
const logMessage = previousId const verification = this.dbManager.getSessionStore().getSessionById(session.sessionDbId);
? `MEMORY_ID_CHANGED | sessionDbId=${session.sessionDbId} | from=${previousId} | to=${message.session_id} | dbVerified=${dbVerified}` const dbVerified = verification?.memory_session_id === message.session_id;
: `MEMORY_ID_CAPTURED | sessionDbId=${session.sessionDbId} | memorySessionId=${message.session_id} | dbVerified=${dbVerified}`; const logMessage = previousId
logger.info('SESSION', logMessage, { ? `MEMORY_ID_CHANGED | sessionDbId=${session.sessionDbId} | from=${previousId} | to=${message.session_id} | dbVerified=${dbVerified}`
sessionId: session.sessionDbId, : `MEMORY_ID_CAPTURED | sessionDbId=${session.sessionDbId} | memorySessionId=${message.session_id} | dbVerified=${dbVerified}`;
memorySessionId: message.session_id, logger.info('SESSION', logMessage, {
previousId sessionId: session.sessionDbId,
}); memorySessionId: message.session_id,
if (!dbVerified) { previousId
logger.error('SESSION', `MEMORY_ID_MISMATCH | sessionDbId=${session.sessionDbId} | expected=${message.session_id} | got=${verification?.memory_session_id}`, {
sessionId: session.sessionDbId
}); });
} if (!dbVerified) {
// Debug-level alignment log for detailed tracing logger.error('SESSION', `MEMORY_ID_MISMATCH | sessionDbId=${session.sessionDbId} | expected=${message.session_id} | got=${verification?.memory_session_id}`, {
logger.debug('SDK', `[ALIGNMENT] ${previousId ? 'Updated' : 'Captured'} | contentSessionId=${session.contentSessionId} → memorySessionId=${message.session_id} | Future prompts will resume with this ID`); sessionId: session.sessionDbId
} });
}
// Handle assistant messages // Debug-level alignment log for detailed tracing
if (message.type === 'assistant') { logger.debug('SDK', `[ALIGNMENT] ${previousId ? 'Updated' : 'Captured'} | contentSessionId=${session.contentSessionId} → memorySessionId=${message.session_id} | Future prompts will resume with this ID`);
const content = message.message.content;
const textContent = Array.isArray(content)
? content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n')
: typeof content === 'string' ? content : '';
// Check for context overflow - prevents infinite retry loops
if (textContent.includes('prompt is too long') ||
textContent.includes('context window')) {
logger.error('SDK', 'Context overflow detected - terminating session');
session.abortController.abort();
return;
} }
const responseSize = textContent.length; // Handle assistant messages
if (message.type === 'assistant') {
const content = message.message.content;
const textContent = Array.isArray(content)
? content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n')
: typeof content === 'string' ? content : '';
// Capture token state BEFORE updating (for delta calculation) // Check for context overflow - prevents infinite retry loops
const tokensBeforeResponse = session.cumulativeInputTokens + session.cumulativeOutputTokens; if (textContent.includes('prompt is too long') ||
textContent.includes('context window')) {
// Extract and track token usage logger.error('SDK', 'Context overflow detected - terminating session');
const usage = message.message.usage; session.abortController.abort();
if (usage) { return;
session.cumulativeInputTokens += usage.input_tokens || 0;
session.cumulativeOutputTokens += usage.output_tokens || 0;
// Cache creation counts as discovery, cache read doesn't
if (usage.cache_creation_input_tokens) {
session.cumulativeInputTokens += usage.cache_creation_input_tokens;
} }
logger.debug('SDK', 'Token usage captured', { const responseSize = textContent.length;
sessionId: session.sessionDbId,
inputTokens: usage.input_tokens, // Capture token state BEFORE updating (for delta calculation)
outputTokens: usage.output_tokens, const tokensBeforeResponse = session.cumulativeInputTokens + session.cumulativeOutputTokens;
cacheCreation: usage.cache_creation_input_tokens || 0,
cacheRead: usage.cache_read_input_tokens || 0, // Extract and track token usage
cumulativeInput: session.cumulativeInputTokens, const usage = message.message.usage;
cumulativeOutput: session.cumulativeOutputTokens if (usage) {
}); session.cumulativeInputTokens += usage.input_tokens || 0;
session.cumulativeOutputTokens += usage.output_tokens || 0;
// Cache creation counts as discovery, cache read doesn't
if (usage.cache_creation_input_tokens) {
session.cumulativeInputTokens += usage.cache_creation_input_tokens;
}
logger.debug('SDK', 'Token usage captured', {
sessionId: session.sessionDbId,
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheCreation: usage.cache_creation_input_tokens || 0,
cacheRead: usage.cache_read_input_tokens || 0,
cumulativeInput: session.cumulativeInputTokens,
cumulativeOutput: session.cumulativeOutputTokens
});
}
// Calculate discovery tokens (delta for this response only)
const discoveryTokens = (session.cumulativeInputTokens + session.cumulativeOutputTokens) - tokensBeforeResponse;
// Process response (empty or not) and mark messages as processed
// Capture earliest timestamp BEFORE processing (will be cleared after)
const originalTimestamp = session.earliestPendingTimestamp;
if (responseSize > 0) {
const truncatedResponse = responseSize > 100
? textContent.substring(0, 100) + '...'
: textContent;
logger.dataOut('SDK', `Response received (${responseSize} chars)`, {
sessionId: session.sessionDbId,
promptNumber: session.lastPromptNumber
}, truncatedResponse);
}
// Detect fatal context overflow and terminate gracefully (issue #870)
if (typeof textContent === 'string' && textContent.includes('Prompt is too long')) {
throw new Error('Claude session context overflow: prompt is too long');
}
// Detect invalid API key — SDK returns this as response text, not an error.
// Throw so it surfaces in health endpoint and prevents silent failures.
if (typeof textContent === 'string' && textContent.includes('Invalid API key')) {
throw new Error('Invalid API key: check your API key configuration in ~/.claude-mem/settings.json or ~/.claude-mem/.env');
}
// Parse and process response using shared ResponseProcessor
await processAgentResponse(
textContent,
session,
this.dbManager,
this.sessionManager,
worker,
discoveryTokens,
originalTimestamp,
'SDK',
cwdTracker.lastCwd
);
} }
// Calculate discovery tokens (delta for this response only) // Log result messages
const discoveryTokens = (session.cumulativeInputTokens + session.cumulativeOutputTokens) - tokensBeforeResponse; if (message.type === 'result' && message.subtype === 'success') {
// Usage telemetry is captured at SDK level
// Process response (empty or not) and mark messages as processed
// Capture earliest timestamp BEFORE processing (will be cleared after)
const originalTimestamp = session.earliestPendingTimestamp;
if (responseSize > 0) {
const truncatedResponse = responseSize > 100
? textContent.substring(0, 100) + '...'
: textContent;
logger.dataOut('SDK', `Response received (${responseSize} chars)`, {
sessionId: session.sessionDbId,
promptNumber: session.lastPromptNumber
}, truncatedResponse);
} }
// Detect fatal context overflow and terminate gracefully (issue #870)
if (typeof textContent === 'string' && textContent.includes('Prompt is too long')) {
throw new Error('Claude session context overflow: prompt is too long');
}
// Detect invalid API key — SDK returns this as response text, not an error.
// Throw so it surfaces in health endpoint and prevents silent failures.
if (typeof textContent === 'string' && textContent.includes('Invalid API key')) {
throw new Error('Invalid API key: check your API key configuration in ~/.claude-mem/settings.json or ~/.claude-mem/.env');
}
// Parse and process response using shared ResponseProcessor
await processAgentResponse(
textContent,
session,
this.dbManager,
this.sessionManager,
worker,
discoveryTokens,
originalTimestamp,
'SDK',
cwdTracker.lastCwd
);
} }
} finally {
// Log result messages // Ensure subprocess is terminated after query completes (or on error)
if (message.type === 'result' && message.subtype === 'success') { const tracked = getProcessBySession(session.sessionDbId);
// Usage telemetry is captured at SDK level if (tracked && !tracked.process.killed && tracked.process.exitCode === null) {
await ensureProcessExit(tracked, 5000);
} }
} }
// Ensure subprocess is terminated after query completes
const tracked = getProcessBySession(session.sessionDbId);
if (tracked && !tracked.process.killed && tracked.process.exitCode === null) {
await ensureProcessExit(tracked, 5000);
}
// Mark session complete // Mark session complete
const sessionDuration = Date.now() - session.startTime; const sessionDuration = Date.now() - session.startTime;
logger.success('SDK', 'Agent completed', { logger.success('SDK', 'Agent completed', {