specmem-hardwicksoftware 3.5.99 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,23 @@ import { join } from 'path';
17
17
  import { createHash } from 'crypto';
18
18
  import { logger } from '../utils/logger.js';
19
19
  import { glob } from 'glob';
20
+ import { getEmbeddingTimeout } from '../config/embeddingTimeouts.js';
21
+ /**
22
+ * Wrap an async operation with a timeout. Prevents sync/resync from hanging
23
+ * indefinitely when embedding service or DB becomes unresponsive.
24
+ */
25
+ function withSyncTimeout(operation, timeoutMs, operationName) {
26
+ return new Promise((resolve, reject) => {
27
+ const timeoutId = setTimeout(() => {
28
+ const err = new Error(`[Sync] ${operationName} timed out after ${Math.round(timeoutMs / 1000)}s`);
29
+ err.code = 'SYNC_TIMEOUT';
30
+ reject(err);
31
+ }, timeoutMs);
32
+ Promise.resolve().then(() => operation())
33
+ .then(result => { clearTimeout(timeoutId); resolve(result); })
34
+ .catch(error => { clearTimeout(timeoutId); reject(error); });
35
+ });
36
+ }
20
37
  /**
21
38
  * areWeStillInSync - sync status checker
22
39
  *
@@ -124,6 +141,9 @@ export class AreWeStillInSync {
124
141
  async resyncEverythingFrFr() {
125
142
  logger.info('starting full resync (non-blocking)...');
126
143
  const startTime = Date.now();
144
+ // Overall resync deadline: 10 minutes max (configurable via SPECMEM_RESYNC_TIMEOUT_MS)
145
+ const RESYNC_TIMEOUT_MS = parseInt(process.env['SPECMEM_RESYNC_TIMEOUT_MS'] || '600000');
146
+ const isOverDeadline = () => (Date.now() - startTime) > RESYNC_TIMEOUT_MS;
127
147
  const result = {
128
148
  success: false,
129
149
  filesAdded: 0,
@@ -140,21 +160,31 @@ export class AreWeStillInSync {
140
160
  missingFromDisk: driftReport.missingFromDisk.length,
141
161
  contentMismatch: driftReport.contentMismatch.length
142
162
  }, 'drift detected - starting resync');
143
- // Helper to process files in PARALLEL batches with concurrency control
163
+ // Helper to process files in PARALLEL batches with concurrency + retry on transient failure
144
164
  const CONCURRENCY = 20; // Process 20 files simultaneously for 10+ files/sec throughput
165
+ const PER_FILE_TIMEOUT = getEmbeddingTimeout('fileWatcher'); // 120s per file operation
166
+ const MAX_FILE_RETRIES = 1; // Retry failed files once before giving up
145
167
  const processInBatches = async (files, handler, operationType) => {
146
168
  const batchErrors = [];
147
169
  let processed = 0;
170
+ let retryQueue = []; // Files that failed transiently and should be retried
148
171
  for (let i = 0; i < files.length; i += CONCURRENCY) {
172
+ if (isOverDeadline()) break; // Respect overall deadline
149
173
  const batch = files.slice(i, i + CONCURRENCY);
150
- const results = await Promise.allSettled(batch.map(path => handler(path).then(() => path)));
151
- for (const result of results) {
174
+ const results = await Promise.allSettled(batch.map(path => withSyncTimeout(() => handler(path).then(() => path), PER_FILE_TIMEOUT, `${operationType} ${path}`)));
175
+ for (let j = 0; j < results.length; j++) {
176
+ const result = results[j];
152
177
  if (result.status === 'fulfilled') {
153
178
  processed++;
154
179
  } else {
155
180
  const errMsg = result.reason?.message || String(result.reason);
156
- batchErrors.push(`Failed to ${operationType}: ${errMsg}`);
157
- logger.error({ error: result.reason }, `failed to ${operationType} file during resync`);
181
+ const isTransient = errMsg.includes('timeout') || errMsg.includes('ECONNRESET') || errMsg.includes('socket') || errMsg.includes('QOMS');
182
+ if (isTransient) {
183
+ retryQueue.push(batch[j]);
184
+ } else {
185
+ batchErrors.push(`Failed to ${operationType}: ${errMsg}`);
186
+ logger.error({ error: result.reason }, `failed to ${operationType} file during resync`);
187
+ }
158
188
  }
159
189
  }
160
190
  // Yield to event loop between parallel batches
@@ -166,6 +196,24 @@ export class AreWeStillInSync {
166
196
  logger.info({ processed, total: files.length, operationType }, 'resync progress');
167
197
  }
168
198
  }
199
+ // Retry pass: process transiently-failed files once more with backoff
200
+ if (retryQueue.length > 0 && !isOverDeadline()) {
201
+ logger.info({ retryCount: retryQueue.length, operationType }, '[Sync] Retrying transiently-failed files after 2s backoff');
202
+ await new Promise(resolve => setTimeout(resolve, 2000));
203
+ for (let i = 0; i < retryQueue.length; i += CONCURRENCY) {
204
+ if (isOverDeadline()) break;
205
+ const retryBatch = retryQueue.slice(i, i + CONCURRENCY);
206
+ const retryResults = await Promise.allSettled(retryBatch.map(path => withSyncTimeout(() => handler(path).then(() => path), PER_FILE_TIMEOUT, `${operationType} retry ${path}`)));
207
+ for (const result of retryResults) {
208
+ if (result.status === 'fulfilled') {
209
+ processed++;
210
+ } else {
211
+ const errMsg = result.reason?.message || String(result.reason);
212
+ batchErrors.push(`Failed to ${operationType} (after retry): ${errMsg}`);
213
+ }
214
+ }
215
+ }
216
+ }
169
217
  return { processed, errors: batchErrors };
170
218
  };
171
219
  // 2. add missing files to MCP
@@ -182,6 +230,13 @@ export class AreWeStillInSync {
182
230
  }, 'add');
183
231
  result.filesAdded = addResult.processed;
184
232
  result.errors.push(...addResult.errors);
233
+ // Check overall deadline between phases
234
+ if (isOverDeadline()) {
235
+ logger.warn({ elapsedMs: Date.now() - startTime, phase: 'after-add' }, '[Sync] Resync deadline exceeded, stopping early');
236
+ result.errors.push(`Resync deadline exceeded after add phase (${Math.round((Date.now() - startTime) / 1000)}s)`);
237
+ result.duration = Date.now() - startTime;
238
+ return result;
239
+ }
185
240
  // 3. update files with content mismatch
186
241
  const updateResult = await processInBatches(driftReport.contentMismatch, async (path) => {
187
242
  const fullPath = join(this.config.rootPath, path);
@@ -196,6 +251,13 @@ export class AreWeStillInSync {
196
251
  }, 'update');
197
252
  result.filesUpdated = updateResult.processed;
198
253
  result.errors.push(...updateResult.errors);
254
+ // Check overall deadline between phases
255
+ if (isOverDeadline()) {
256
+ logger.warn({ elapsedMs: Date.now() - startTime, phase: 'after-update' }, '[Sync] Resync deadline exceeded, stopping early');
257
+ result.errors.push(`Resync deadline exceeded after update phase (${Math.round((Date.now() - startTime) / 1000)}s)`);
258
+ result.duration = Date.now() - startTime;
259
+ return result;
260
+ }
199
261
  // 4. mark deleted files
200
262
  const deleteResult = await processInBatches(driftReport.missingFromDisk, async (path) => {
201
263
  await this.config.changeHandler.handleChange({
@@ -229,38 +291,52 @@ export class AreWeStillInSync {
229
291
  /**
230
292
  * scanDiskFiles - scans filesystem for all files
231
293
  *
232
- * NON-BLOCKING: Yields to event loop periodically during batch processing
233
- * This prevents blocking the main thread even on large codebases
294
+ * NON-BLOCKING: Uses streaming/generator pattern to avoid loading all paths into memory.
295
+ * Processes files in configurable batches with memory pressure detection.
296
+ * Respects .gitignore and configurable ignore patterns.
297
+ *
298
+ * Env vars:
299
+ * SPECMEM_SCAN_BATCH_SIZE - files per batch (default 500)
300
+ * SPECMEM_SCAN_MAX_FILES - max files to scan (default 50000)
301
+ * SPECMEM_SCAN_MAX_HEAP_MB - heap limit before pausing (default 2048)
302
+ * SPECMEM_SCAN_IGNORE_PATTERNS - comma-separated ignore dirs (default "node_modules,.git,dist,build,.next,__pycache__")
234
303
  */
235
304
  async scanDiskFiles() {
236
- logger.debug('scanning disk files (non-blocking)...');
237
- // build ignore patterns for glob
305
+ logger.debug('scanning disk files (streaming, non-blocking)...');
306
+ // Configurable limits via environment variables
307
+ const SCAN_BATCH_SIZE = parseInt(process.env['SPECMEM_SCAN_BATCH_SIZE'] || '500');
308
+ const SCAN_MAX_FILES = parseInt(process.env['SPECMEM_SCAN_MAX_FILES'] || '50000');
309
+ const SCAN_MAX_HEAP_MB = parseInt(process.env['SPECMEM_SCAN_MAX_HEAP_MB'] || '2048');
310
+ const SCAN_MAX_HEAP_BYTES = SCAN_MAX_HEAP_MB * 1024 * 1024;
311
+ // Configurable ignore patterns via env var (comma-separated directory names)
312
+ const defaultIgnoreDirs = 'node_modules,.git,dist,build,.next,__pycache__';
313
+ const envIgnoreDirs = process.env['SPECMEM_SCAN_IGNORE_PATTERNS'] || defaultIgnoreDirs;
314
+ const ignoreDirNames = envIgnoreDirs.split(',').map(d => d.trim()).filter(Boolean);
315
+ // Build glob ignore patterns from the directory names
238
316
  const ignorePatterns = [
239
- '**/node_modules/**',
240
- '**/.git/**',
241
- '**/dist/**',
242
- '**/build/**',
243
- '**/.next/**',
317
+ ...ignoreDirNames.map(d => `**/${d}/**`),
244
318
  '**/coverage/**',
245
319
  '**/.cache/**',
246
320
  ...this.config.ignorePatterns
247
321
  ];
248
- // scan all files - glob itself is async so this part is non-blocking
249
- const files = await glob('**/*', {
322
+ // Use glob stream to avoid loading all paths into a single array
323
+ const fileStream = glob.stream('**/*', {
250
324
  cwd: this.config.rootPath,
251
325
  ignore: ignorePatterns,
252
326
  nodir: true, // only files
253
327
  dot: false, // ignore dotfiles
254
328
  absolute: false
255
329
  });
256
- // read and hash each file in batches, yielding to event loop between batches
257
330
  const fileData = [];
258
- const BATCH_SIZE = this.config.batchSize;
331
+ let batch = [];
259
332
  let processedCount = 0;
260
- for (let i = 0; i < files.length; i += BATCH_SIZE) {
261
- const batch = files.slice(i, i + BATCH_SIZE);
262
- // Process batch
263
- for (const file of batch) {
333
+ let totalEnumerated = 0;
334
+ let memoryPressurePaused = false;
335
+ /**
336
+ * Process a single batch of file paths: stat, read, hash.
337
+ */
338
+ const processBatch = async (fileBatch) => {
339
+ for (const file of fileBatch) {
264
340
  try {
265
341
  const fullPath = join(this.config.rootPath, file);
266
342
  const stats = await fs.stat(fullPath);
@@ -282,70 +358,179 @@ export class AreWeStillInSync {
282
358
  logger.debug({ error, path: file }, 'failed to process file');
283
359
  }
284
360
  }
285
- processedCount += batch.length;
286
- // Yield to event loop after each batch - prevents blocking
287
- // This lets other operations (like MCP requests) process between batches
288
- if (i + BATCH_SIZE < files.length) {
361
+ };
362
+ // Consume the glob stream in batches
363
+ for await (const filePath of fileStream) {
364
+ totalEnumerated++;
365
+ // Enforce max files limit to prevent runaway scanning
366
+ if (totalEnumerated > SCAN_MAX_FILES) {
367
+ logger.warn({ maxFiles: SCAN_MAX_FILES, enumerated: totalEnumerated }, '[SyncChecker] Max files limit reached, stopping scan');
368
+ break;
369
+ }
370
+ batch.push(typeof filePath === 'string' ? filePath : filePath.toString());
371
+ // When batch is full, process it
372
+ if (batch.length >= SCAN_BATCH_SIZE) {
373
+ // Memory pressure detection
374
+ const heapUsed = process.memoryUsage().heapUsed;
375
+ if (heapUsed > SCAN_MAX_HEAP_BYTES) {
376
+ if (!memoryPressurePaused) {
377
+ logger.warn({
378
+ heapUsedMB: Math.round(heapUsed / (1024 * 1024)),
379
+ limitMB: SCAN_MAX_HEAP_MB,
380
+ processedSoFar: processedCount,
381
+ enumerated: totalEnumerated
382
+ }, '[SyncChecker] Memory pressure detected during disk scan, pausing to allow GC');
383
+ memoryPressurePaused = true;
384
+ }
385
+ // Force GC if available, then yield to let it run
386
+ if (global.gc) {
387
+ global.gc();
388
+ }
389
+ await new Promise(resolve => setTimeout(resolve, 100));
390
+ // Re-check after pause
391
+ const heapAfter = process.memoryUsage().heapUsed;
392
+ if (heapAfter > SCAN_MAX_HEAP_BYTES) {
393
+ logger.warn({
394
+ heapUsedMB: Math.round(heapAfter / (1024 * 1024)),
395
+ limitMB: SCAN_MAX_HEAP_MB,
396
+ processedSoFar: processedCount
397
+ }, '[SyncChecker] Memory pressure persists after GC pause, stopping scan early');
398
+ break;
399
+ }
400
+ memoryPressurePaused = false;
401
+ }
402
+ await processBatch(batch);
403
+ processedCount += batch.length;
404
+ batch = [];
405
+ // Yield to event loop between batches - prevents blocking
289
406
  await new Promise(resolve => setImmediate(resolve));
290
- // Log progress for large scans
291
- if (processedCount % 500 === 0) {
292
- logger.debug({ processed: processedCount, total: files.length }, 'disk scan progress');
407
+ // Log progress periodically
408
+ if (processedCount % 1000 === 0) {
409
+ logger.debug({ processed: processedCount, hashed: fileData.length, enumerated: totalEnumerated }, 'disk scan progress');
293
410
  }
294
411
  }
295
412
  }
296
- logger.debug({ count: fileData.length, total: files.length }, 'disk scan complete');
413
+ // Process any remaining files in the last partial batch
414
+ if (batch.length > 0) {
415
+ await processBatch(batch);
416
+ processedCount += batch.length;
417
+ }
418
+ logger.debug({ count: fileData.length, totalProcessed: processedCount, totalEnumerated, maxFiles: SCAN_MAX_FILES }, 'disk scan complete');
297
419
  return fileData;
298
420
  }
299
421
  /**
300
422
  * scanMcpMemories - gets all file-watcher memories from MCP
301
- * Also checks codebase_files table where actual indexed files are stored
423
+ * Also checks codebase_files table where actual indexed files are stored.
424
+ *
425
+ * Uses pagination to handle codebases with >10K memories.
426
+ *
427
+ * Env vars:
428
+ * SPECMEM_SYNC_MEMORY_LIMIT - max total memories to fetch (default 50000)
429
+ * SPECMEM_SYNC_MEMORY_PAGE_SIZE - page size for paginated queries (default 5000)
302
430
  */
303
431
  async scanMcpMemories() {
304
432
  logger.debug('scanning MCP memories and codebase_files...');
433
+ const MEMORY_LIMIT = parseInt(process.env['SPECMEM_SYNC_MEMORY_LIMIT'] || '50000');
434
+ const PAGE_SIZE = parseInt(process.env['SPECMEM_SYNC_MEMORY_PAGE_SIZE'] || '5000');
305
435
  const allFiles = [];
306
436
  const seenPaths = new Set();
307
437
  try {
308
438
  // 1. Check codebase_files table first (this is where indexed files actually live)
309
439
  const pool = this.config.search.getPool();
310
- const codebaseResult = await pool.queryWithSwag(`SELECT file_path, content_hash
311
- FROM codebase_files
312
- WHERE project_path = $1 AND content_hash IS NOT NULL`, [this.config.rootPath]);
313
- for (const row of codebaseResult.rows) {
314
- if (row.file_path && !seenPaths.has(row.file_path)) {
315
- seenPaths.add(row.file_path);
316
- allFiles.push({
317
- path: row.file_path,
318
- hash: row.content_hash || '',
319
- deleted: false
320
- });
440
+ // Get total count first for logging
441
+ const countResult = await pool.queryWithSwag(`SELECT COUNT(*) as total
442
+ FROM codebase_files
443
+ WHERE project_path = $1 AND content_hash IS NOT NULL`, [this.config.rootPath]);
444
+ const totalCodebaseFiles = parseInt(countResult.rows[0]?.total || '0');
445
+ logger.debug({ totalCodebaseFiles }, 'codebase_files total count');
446
+ // Paginated fetch of codebase_files
447
+ let codebaseOffset = 0;
448
+ let codebaseFetched = 0;
449
+ while (codebaseFetched < MEMORY_LIMIT) {
450
+ const currentPageSize = Math.min(PAGE_SIZE, MEMORY_LIMIT - codebaseFetched);
451
+ const codebaseResult = await pool.queryWithSwag(`SELECT file_path, content_hash
452
+ FROM codebase_files
453
+ WHERE project_path = $1 AND content_hash IS NOT NULL
454
+ ORDER BY file_path
455
+ LIMIT $2 OFFSET $3`, [this.config.rootPath, currentPageSize, codebaseOffset]);
456
+ if (codebaseResult.rows.length === 0) {
457
+ break; // No more rows
458
+ }
459
+ for (const row of codebaseResult.rows) {
460
+ if (row.file_path && !seenPaths.has(row.file_path)) {
461
+ seenPaths.add(row.file_path);
462
+ allFiles.push({
463
+ path: row.file_path,
464
+ hash: row.content_hash || '',
465
+ deleted: false
466
+ });
467
+ }
321
468
  }
469
+ codebaseFetched += codebaseResult.rows.length;
470
+ codebaseOffset += codebaseResult.rows.length;
471
+ // If we got fewer than page size, we've reached the end
472
+ if (codebaseResult.rows.length < currentPageSize) {
473
+ break;
474
+ }
475
+ // Yield to event loop between pages
476
+ await new Promise(resolve => setImmediate(resolve));
322
477
  }
323
- logger.debug({ codebaseFilesCount: codebaseResult.rows.length }, 'codebase_files scan complete');
478
+ logger.debug({ codebaseFilesCount: codebaseFetched, totalInDb: totalCodebaseFiles }, 'codebase_files scan complete');
324
479
  // 2. Also check memories table for file-watcher entries (legacy support)
325
- const results = await this.config.search.textSearch({
326
- query: 'file-watcher',
327
- limit: 10000,
328
- projectPath: this.config.rootPath
329
- });
330
- const memoriesData = results
331
- .filter(m => m.memory.metadata?.source === 'file-watcher')
332
- .map(m => ({
333
- path: m.memory.metadata?.filePath || '',
334
- hash: m.memory.metadata?.contentHash || '',
335
- deleted: m.memory.metadata?.deleted === true
336
- }))
337
- .filter(m => m.path);
338
- // Merge memories entries (avoiding duplicates)
339
- for (const mem of memoriesData) {
340
- if (!seenPaths.has(mem.path)) {
341
- seenPaths.add(mem.path);
342
- allFiles.push(mem);
480
+ // Use pagination to avoid the old hardcoded 10K limit
481
+ const memoriesCountResult = await pool.queryWithSwag(`SELECT COUNT(*) as total
482
+ FROM memories
483
+ WHERE project_path = $1 AND metadata->>'source' = 'file-watcher'`, [this.config.rootPath]);
484
+ const totalMemories = parseInt(memoriesCountResult.rows[0]?.total || '0');
485
+ logger.debug({ totalMemories }, 'file-watcher memories total count');
486
+ let memoriesOffset = 0;
487
+ let memoriesFetched = 0;
488
+ let memoriesAdded = 0;
489
+ const memoryFetchLimit = MEMORY_LIMIT - codebaseFetched; // Respect overall limit
490
+ while (memoriesFetched < memoryFetchLimit && memoriesFetched < totalMemories) {
491
+ const currentPageSize = Math.min(PAGE_SIZE, memoryFetchLimit - memoriesFetched);
492
+ const results = await this.config.search.textSearch({
493
+ query: 'file-watcher',
494
+ limit: currentPageSize,
495
+ offset: memoriesOffset,
496
+ projectPath: this.config.rootPath
497
+ });
498
+ if (!results || results.length === 0) {
499
+ break; // No more results
343
500
  }
501
+ const memoriesData = results
502
+ .filter(m => m.memory.metadata?.source === 'file-watcher')
503
+ .map(m => ({
504
+ path: m.memory.metadata?.filePath || '',
505
+ hash: m.memory.metadata?.contentHash || '',
506
+ deleted: m.memory.metadata?.deleted === true
507
+ }))
508
+ .filter(m => m.path);
509
+ // Merge memories entries (avoiding duplicates)
510
+ for (const mem of memoriesData) {
511
+ if (!seenPaths.has(mem.path)) {
512
+ seenPaths.add(mem.path);
513
+ allFiles.push(mem);
514
+ memoriesAdded++;
515
+ }
516
+ }
517
+ memoriesFetched += results.length;
518
+ memoriesOffset += results.length;
519
+ // If we got fewer than page size, we've reached the end
520
+ if (results.length < currentPageSize) {
521
+ break;
522
+ }
523
+ // Yield to event loop between pages
524
+ await new Promise(resolve => setImmediate(resolve));
344
525
  }
345
526
  logger.debug({
346
527
  totalCount: allFiles.length,
347
- fromCodebaseFiles: codebaseResult.rows.length,
348
- fromMemories: memoriesData.length
528
+ fromCodebaseFiles: codebaseFetched,
529
+ fromMemories: memoriesAdded,
530
+ totalMemoriesInDb: totalMemories,
531
+ totalCodebaseInDb: totalCodebaseFiles,
532
+ memoryLimit: MEMORY_LIMIT,
533
+ pageSize: PAGE_SIZE
349
534
  }, 'MCP scan complete');
350
535
  return allFiles;
351
536
  }