Skip to content

Commit 98d87d7

Browse files
thedotmackclaude
andcommitted
chore: bump version to 10.0.4
Reverts v10.0.3 chroma-mcp spawn storm fix (broken release). Restores codebase to v10.0.2 state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0dda593 commit 98d87d7

13 files changed

Lines changed: 213 additions & 709 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"plugins": [
1111
{
1212
"name": "claude-mem",
13-
"version": "10.0.3",
13+
"version": "10.0.4",
1414
"source": "./plugin",
1515
"description": "Persistent memory system for Claude Code - context compression across sessions"
1616
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "claude-mem",
3-
"version": "10.0.3",
3+
"version": "10.0.4",
44
"description": "Memory compression system for Claude Code - persist context across sessions",
55
"keywords": [
66
"claude",

plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "claude-mem",
3-
"version": "10.0.3",
3+
"version": "10.0.4",
44
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
55
"author": {
66
"name": "Alex Newman"

plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "claude-mem-plugin",
3-
"version": "10.0.3",
3+
"version": "10.0.4",
44
"private": true,
55
"description": "Runtime dependencies for claude-mem bundled hooks",
66
"type": "module",

plugin/scripts/mcp-server.cjs

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

plugin/scripts/worker-service.cjs

Lines changed: 187 additions & 189 deletions
Large diffs are not rendered by default.

src/services/infrastructure/GracefulShutdown.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
*/
1010

1111
import http from 'http';
12-
import { execFileSync } from 'child_process';
1312
import { logger } from '../../utils/logger.js';
1413
import {
1514
getChildProcesses,
@@ -77,21 +76,6 @@ export async function performGracefulShutdown(config: GracefulShutdownConfig): P
7776
await config.dbManager.close();
7877
}
7978

80-
// STEP 5.5: Kill any chroma-mcp children that survived transport.close() (Unix only)
81-
// On Unix, getChildProcesses() returns [] (Windows-only), so chroma-mcp
82-
// subprocesses spawned via StdioClientTransport may escape STEP 5 cleanup
83-
if (process.platform !== 'win32') {
84-
try {
85-
execFileSync('pkill', ['-P', String(process.pid), '-f', 'chroma-mcp'], {
86-
timeout: 3000,
87-
stdio: 'ignore'
88-
});
89-
logger.info('SYSTEM', 'Killed chroma-mcp child processes');
90-
} catch {
91-
// pkill returns exit code 1 if no processes matched — that's fine
92-
}
93-
}
94-
9579
// STEP 6: Force kill any remaining child processes (Windows zombie port fix)
9680
if (childPids.length > 0) {
9781
logger.info('SYSTEM', 'Force killing remaining children');

src/services/infrastructure/ProcessManager.ts

Lines changed: 0 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -339,77 +339,6 @@ export async function cleanupOrphanedProcesses(): Promise<void> {
339339
logger.info('SYSTEM', 'Orphaned processes cleaned up', { count: pidsToKill.length });
340340
}
341341

342-
/**
343-
* Clean up excess chroma-mcp processes by count (not age).
344-
*
345-
* Unlike cleanupOrphanedProcesses() which uses ORPHAN_MAX_AGE_MINUTES = 30,
346-
* this function kills by count — essential for catching spawn storms where
347-
* all processes are young. Keeps the newest processes (by elapsed time)
348-
* and kills the rest.
349-
*
350-
* Returns the number of processes killed.
351-
*/
352-
export async function cleanupExcessChromaProcesses(maxAllowed: number = 2): Promise<number> {
353-
// Windows: Chroma is disabled entirely, no cleanup needed
354-
if (process.platform === 'win32') return 0;
355-
356-
try {
357-
const { stdout } = await execAsync(
358-
'ps -eo pid,etime,command | grep -E "chroma-mcp" | grep -v grep || true'
359-
);
360-
361-
if (!stdout.trim()) return 0;
362-
363-
const processes: Array<{ pid: number; ageMinutes: number }> = [];
364-
365-
for (const line of stdout.trim().split('\n')) {
366-
if (!line.trim()) continue;
367-
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
368-
if (!match) continue;
369-
370-
const pid = parseInt(match[1], 10);
371-
const etime = match[2];
372-
373-
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue;
374-
375-
const ageMinutes = parseElapsedTime(etime);
376-
// Skip entries with unparseable etime (-1) to avoid sort corruption
377-
if (ageMinutes < 0) continue;
378-
processes.push({ pid, ageMinutes });
379-
}
380-
381-
if (processes.length <= maxAllowed) return 0;
382-
383-
// Sort: newest first (lowest age), keep maxAllowed, kill rest
384-
processes.sort((a, b) => a.ageMinutes - b.ageMinutes);
385-
const toKill = processes.slice(maxAllowed);
386-
387-
let killed = 0;
388-
for (const { pid } of toKill) {
389-
try {
390-
process.kill(pid, 'SIGTERM');
391-
killed++;
392-
logger.info('SYSTEM', 'Killed excess chroma-mcp process', { pid });
393-
} catch {
394-
// Process may already be dead
395-
}
396-
}
397-
398-
if (killed > 0) {
399-
logger.warn('SYSTEM', 'Cleaned up excess chroma-mcp processes by count', {
400-
found: processes.length,
401-
killed,
402-
maxAllowed
403-
});
404-
}
405-
406-
return killed;
407-
} catch (error) {
408-
logger.debug('SYSTEM', 'Failed to enumerate chroma-mcp processes', {}, error as Error);
409-
return 0;
410-
}
411-
}
412-
413342
/**
414343
* Spawn a detached daemon process
415344
* Returns the child PID or undefined if spawn failed

src/services/sync/ChromaSync.ts

Lines changed: 20 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,12 @@ import { USER_SETTINGS_PATH } from '../../shared/paths.js';
1818
import path from 'path';
1919
import os from 'os';
2020
import fs from 'fs';
21-
import { execSync, execFileSync } from 'child_process';
22-
import { parseElapsedTime } from '../infrastructure/ProcessManager.js';
21+
import { execSync } from 'child_process';
2322

2423
// Version injected at build time by esbuild define
2524
declare const __DEFAULT_PACKAGE_VERSION__: string;
2625
const packageVersion = typeof __DEFAULT_PACKAGE_VERSION__ !== 'undefined' ? __DEFAULT_PACKAGE_VERSION__ : '0.0.0-dev';
2726

28-
// Maximum allowed chroma-mcp processes before pre-spawn guard kills excess
29-
const MAX_CHROMA_PROCESSES = 2; // 1 active + 1 starting
30-
3127
interface ChromaDocument {
3228
id: string;
3329
document: string;
@@ -94,16 +90,6 @@ export class ChromaSync {
9490
// MCP SDK's StdioClientTransport uses shell:false and no detached flag, so console is inherited.
9591
private readonly disabled: boolean = false;
9692

97-
// Layer 0: Connection mutex — coalesces concurrent callers onto single spawn
98-
private connectionPromise: Promise<void> | null = null;
99-
100-
// Layer 4: Circuit breaker — stops retry storms after repeated failures
101-
private consecutiveFailures: number = 0;
102-
private lastFailureTime: number = 0;
103-
private static readonly MAX_FAILURES = 3;
104-
private static readonly BACKOFF_BASE_MS = 2000;
105-
private static readonly CIRCUIT_OPEN_MS = 60000; // 1 minute cooldown
106-
10793
constructor(project: string) {
10894
this.project = project;
10995
this.collectionName = `cm__${project}`;
@@ -192,113 +178,13 @@ export class ChromaSync {
192178
}
193179

194180
/**
195-
* Ensure MCP client is connected to Chroma server (mutex wrapper).
196-
* Coalesces concurrent callers onto a single connection attempt.
197-
* This prevents N concurrent calls from each spawning a chroma-mcp subprocess.
181+
* Ensure MCP client is connected to Chroma server
182+
* Throws error if connection fails
198183
*/
199184
private async ensureConnection(): Promise<void> {
200-
if (this.connected && this.client) return;
201-
202-
// Layer 0: Coalesce concurrent callers onto a single connection attempt
203-
if (this.connectionPromise) {
204-
return this.connectionPromise;
205-
}
206-
207-
this.connectionPromise = this._doConnect();
208-
try {
209-
await this.connectionPromise;
210-
} finally {
211-
this.connectionPromise = null;
212-
}
213-
}
214-
215-
/**
216-
* Layer 4: Circuit breaker — refuse to spawn after repeated failures.
217-
* After MAX_FAILURES consecutive connection failures, stops all spawn
218-
* attempts for CIRCUIT_OPEN_MS to prevent process accumulation storms.
219-
*/
220-
private checkCircuitBreaker(): void {
221-
if (this.consecutiveFailures >= ChromaSync.MAX_FAILURES) {
222-
const elapsed = Date.now() - this.lastFailureTime;
223-
if (elapsed < ChromaSync.CIRCUIT_OPEN_MS) {
224-
throw new Error(
225-
`Chroma circuit breaker open: ${this.consecutiveFailures} consecutive failures. ` +
226-
`Retry in ${Math.ceil((ChromaSync.CIRCUIT_OPEN_MS - elapsed) / 1000)}s`
227-
);
228-
}
229-
// Cooldown expired, allow retry
230-
logger.info('CHROMA_SYNC', 'Circuit breaker cooldown expired, allowing retry', {
231-
consecutiveFailures: this.consecutiveFailures,
232-
cooldownMs: ChromaSync.CIRCUIT_OPEN_MS
233-
});
234-
}
235-
}
236-
237-
/**
238-
* Layer 1: Pre-spawn process count guard.
239-
* Kills excess chroma-mcp processes before spawning a new one.
240-
* Uses execFileSync (no shell) to list processes, filters in JavaScript.
241-
*/
242-
private killExcessChromaProcesses(): void {
243-
if (process.platform === 'win32') return; // Windows has Chroma disabled entirely
244-
245-
try {
246-
// Use execFileSync to avoid shell injection — filter and sort in JavaScript
247-
// Include etime column for reliable age-based sorting (PID order is unreliable)
248-
const output = execFileSync('ps', ['-eo', 'pid,etime,command'], {
249-
encoding: 'utf8',
250-
timeout: 5000,
251-
stdio: ['pipe', 'pipe', 'pipe']
252-
});
253-
254-
// Filter for chroma-mcp, parse elapsed time, sort by actual age
255-
const processes = output.split('\n')
256-
.filter(l => l.includes('chroma-mcp'))
257-
.map(l => {
258-
const parts = l.trim().split(/\s+/);
259-
const pid = parseInt(parts[0], 10);
260-
const etime = parts[1] || '';
261-
const ageMinutes = parseElapsedTime(etime);
262-
return { pid, ageMinutes };
263-
})
264-
.filter(p => p.pid > 0 && p.pid !== process.pid && p.ageMinutes >= 0)
265-
.sort((a, b) => a.ageMinutes - b.ageMinutes); // Ascending: newest (lowest age) first
266-
267-
if (processes.length < MAX_CHROMA_PROCESSES) return;
268-
269-
// Keep newest MAX_CHROMA_PROCESSES - 1 (making room for the one we're about to spawn)
270-
const toKill = processes.slice(MAX_CHROMA_PROCESSES - 1);
271-
for (const { pid } of toKill) {
272-
try {
273-
process.kill(pid, 'SIGTERM');
274-
} catch {
275-
// Process may already be dead
276-
}
277-
}
278-
279-
if (toKill.length > 0) {
280-
logger.warn('CHROMA_SYNC', 'Killed excess chroma-mcp processes before spawning', {
281-
found: processes.length,
282-
killed: toKill.length,
283-
maxAllowed: MAX_CHROMA_PROCESSES
284-
});
285-
}
286-
} catch {
287-
// ps may fail — don't block connection
185+
if (this.connected && this.client) {
186+
return;
288187
}
289-
}
290-
291-
/**
292-
* Internal connection logic — called only via ensureConnection() mutex.
293-
* Implements circuit breaker (Layer 4), pre-spawn guard (Layer 1),
294-
* and actual connection setup.
295-
*/
296-
private async _doConnect(): Promise<void> {
297-
// Layer 4: Circuit breaker check — refuse if too many recent failures
298-
this.checkCircuitBreaker();
299-
300-
// Layer 1: Kill excess processes before spawning a new one
301-
this.killExcessChromaProcesses();
302188

303189
logger.info('CHROMA_SYNC', 'Connecting to Chroma MCP server...', { project: this.project });
304190

@@ -352,20 +238,9 @@ export class ChromaSync {
352238
await this.client.connect(this.transport);
353239
this.connected = true;
354240

355-
// Layer 4: Reset circuit breaker on success
356-
this.consecutiveFailures = 0;
357-
358241
logger.info('CHROMA_SYNC', 'Connected to Chroma MCP server', { project: this.project });
359242
} catch (error) {
360-
// Layer 4: Track failure for circuit breaker
361-
this.consecutiveFailures++;
362-
this.lastFailureTime = Date.now();
363-
364-
logger.error('CHROMA_SYNC', 'Failed to connect to Chroma MCP server', {
365-
project: this.project,
366-
consecutiveFailures: this.consecutiveFailures,
367-
circuitBreakerThreshold: ChromaSync.MAX_FAILURES
368-
}, error as Error);
243+
logger.error('CHROMA_SYNC', 'Failed to connect to Chroma MCP server', { project: this.project }, error as Error);
369244
throw new Error(`Chroma connection failed: ${error instanceof Error ? error.message : String(error)}`);
370245
}
371246
}
@@ -416,7 +291,6 @@ export class ChromaSync {
416291
this.connected = false;
417292
this.client = null;
418293
this.transport = null;
419-
this.connectionPromise = null;
420294
logger.error('CHROMA_SYNC', 'Connection lost during collection check',
421295
{ collection: this.collectionName }, error as Error);
422296
throw new Error(`Chroma connection lost: ${errorMessage}`);
@@ -1086,7 +960,6 @@ export class ChromaSync {
1086960
this.connected = false;
1087961
this.client = null;
1088962
this.transport = null;
1089-
this.connectionPromise = null;
1090963
logger.error('CHROMA_SYNC', 'Connection lost during query',
1091964
{ project: this.project, query }, error as Error);
1092965
throw new Error(`Chroma query failed - connection lost: ${errorMessage}`);
@@ -1144,37 +1017,28 @@ export class ChromaSync {
11441017
}
11451018

11461019
/**
1147-
* Close the Chroma client connection and cleanup subprocess.
1148-
* Uses try-finally to guarantee state reset even if close() throws.
1149-
* Individual close calls use .catch() to prevent one failure from
1150-
* blocking the other (e.g., client.close() failing shouldn't prevent
1151-
* transport.close() from killing the subprocess).
1020+
* Close the Chroma client connection and cleanup subprocess
11521021
*/
11531022
async close(): Promise<void> {
11541023
if (!this.connected && !this.client && !this.transport) {
11551024
return;
11561025
}
11571026

1158-
try {
1159-
// Close client first, then transport — catch individual errors
1160-
if (this.client) {
1161-
await this.client.close().catch((err: Error) => {
1162-
logger.debug('CHROMA_SYNC', 'Client close error (may already be disconnected)', {}, err);
1163-
});
1164-
}
1165-
if (this.transport) {
1166-
await this.transport.close().catch((err: Error) => {
1167-
logger.debug('CHROMA_SYNC', 'Transport close error (may already be dead)', {}, err);
1168-
});
1169-
}
1170-
} finally {
1171-
// Always reset state, even if close throws
1172-
this.connected = false;
1173-
this.client = null;
1174-
this.transport = null;
1175-
this.connectionPromise = null;
1027+
// Close client first
1028+
if (this.client) {
1029+
await this.client.close();
1030+
}
1031+
1032+
// Explicitly close transport to kill subprocess
1033+
if (this.transport) {
1034+
await this.transport.close();
11761035
}
11771036

11781037
logger.info('CHROMA_SYNC', 'Chroma client and subprocess closed', { project: this.project });
1038+
1039+
// Always reset state
1040+
this.connected = false;
1041+
this.client = null;
1042+
this.transport = null;
11791043
}
11801044
}

src/services/worker-service.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ import {
6767
removePidFile,
6868
getPlatformTimeout,
6969
cleanupOrphanedProcesses,
70-
cleanupExcessChromaProcesses,
7170
cleanStalePidFile,
7271
spawnDaemon,
7372
createSignalHandler
@@ -335,7 +334,6 @@ export class WorkerService {
335334
private async initializeBackground(): Promise<void> {
336335
try {
337336
await cleanupOrphanedProcesses();
338-
await cleanupExcessChromaProcesses();
339337

340338
// Load mode configuration
341339
const { ModeManager } = await import('./domain/ModeManager.js');

0 commit comments

Comments
 (0)