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.
@@ -44,6 +44,13 @@ export class WatchForChangesNoCap {
44
44
  debouncedHandlers = new Map();
45
45
  // FIX MED-14 & LOW-15: Track latest event data per key to avoid stale closures
46
46
  pendingEventData = new Map();
47
+ // FIX Issue #13: Track when each debounce entry was created for age-based cleanup
48
+ debounceEntryTimestamps = new Map();
49
+ // FIX Issue #13: Configurable limits for debounce map growth
50
+ debounceMapMaxSize = parseInt(process.env['SPECMEM_DEBOUNCE_MAP_MAX_SIZE'] || '5000');
51
+ debounceCleanupIntervalMs = parseInt(process.env['SPECMEM_DEBOUNCE_CLEANUP_INTERVAL_MS'] || '300000'); // 5 min
52
+ debounceMaxAgeMs = parseInt(process.env['SPECMEM_DEBOUNCE_MAX_AGE_MS'] || '60000'); // 60s
53
+ debounceCleanupTimer = null;
47
54
  // stats tracking
48
55
  stats = {
49
56
  filesWatched: 0,
@@ -130,6 +137,11 @@ export class WatchForChangesNoCap {
130
137
  '**/memory-dumps/**',
131
138
  '**/tmp/**',
132
139
  '**/temp/**',
140
+ // SpecMem runtime directories - NEVER watch these
141
+ // statusbar-state.json changes every few seconds causing infinite feedback loops
142
+ '**/specmem/sockets/**',
143
+ '**/specmem/run/**',
144
+ '**/.specmem/**',
133
145
  // Test artifacts
134
146
  '**/coverage/**',
135
147
  '**/__pycache__/**',
@@ -185,6 +197,10 @@ export class WatchForChangesNoCap {
185
197
  this.setupEventHandlers();
186
198
  this.isWatching = true;
187
199
  this.restartCount = 0;
200
+ // FIX Issue #13: Start periodic cleanup of stale debounce entries
201
+ this.debounceCleanupTimer = setInterval(() => {
202
+ this.cleanupStaleDebounceEntries();
203
+ }, this.debounceCleanupIntervalMs);
188
204
  logger.info('file watcher started successfully - were LIVE');
189
205
  }
190
206
  catch (error) {
@@ -205,6 +221,11 @@ export class WatchForChangesNoCap {
205
221
  await this.watcher.close();
206
222
  this.watcher = null;
207
223
  }
224
+ // FIX Issue #13: Clear debounce cleanup timer
225
+ if (this.debounceCleanupTimer) {
226
+ clearInterval(this.debounceCleanupTimer);
227
+ this.debounceCleanupTimer = null;
228
+ }
208
229
  // FIX MED-13: Cancel all debounced handlers before clearing to prevent memory leaks
209
230
  // The debounce library's clear() method cancels pending timer execution
210
231
  for (const handler of this.debouncedHandlers.values()) {
@@ -212,6 +233,7 @@ export class WatchForChangesNoCap {
212
233
  }
213
234
  this.debouncedHandlers.clear();
214
235
  this.pendingEventData.clear();
236
+ this.debounceEntryTimestamps.clear();
215
237
  this.isWatching = false;
216
238
  logger.info({ stats: this.stats }, 'file watcher stopped - peace out');
217
239
  }
@@ -319,8 +341,14 @@ export class WatchForChangesNoCap {
319
341
  // already debouncing this file - the handler will use updated pendingEventData
320
342
  // FIX MED-14: Don't return early - we've already updated the event data above
321
343
  // The debounced function will pick up the latest event when it fires
344
+ // Update the timestamp for the existing entry
345
+ this.debounceEntryTimestamps.set(key, Date.now());
322
346
  return;
323
347
  }
348
+ // FIX Issue #13: If debounce map exceeds max size, flush oldest entries immediately
349
+ if (this.debouncedHandlers.size >= this.debounceMapMaxSize) {
350
+ this.flushOldestDebounceEntries();
351
+ }
324
352
  // create debounced handler that reads latest event data when executing
325
353
  const debouncedHandler = debounce(async () => {
326
354
  try {
@@ -342,17 +370,109 @@ export class WatchForChangesNoCap {
342
370
  logger.error({ error, event: latestEvent }, 'error processing file change');
343
371
  }
344
372
  finally {
345
- // remove from debounce map and pending data
373
+ // remove from debounce map, pending data, and timestamps
346
374
  this.debouncedHandlers.delete(key);
347
375
  this.pendingEventData.delete(key);
376
+ this.debounceEntryTimestamps.delete(key);
348
377
  }
349
378
  }, this.config.debounceMs);
350
379
  this.debouncedHandlers.set(key, debouncedHandler);
380
+ // FIX Issue #13: Track when this debounce entry was created
381
+ this.debounceEntryTimestamps.set(key, Date.now());
351
382
  debouncedHandler();
352
383
  if (this.config.verbose) {
353
384
  logger.debug({ event }, 'file change detected');
354
385
  }
355
386
  }
387
+ /**
388
+ * FIX Issue #13: Flush the oldest debounce entries immediately when map exceeds max size.
389
+ * Fires the debounced handlers immediately (processes them without waiting for debounce timer).
390
+ */
391
+ flushOldestDebounceEntries() {
392
+ // Sort entries by timestamp (oldest first) and flush 10% of the map
393
+ const entries = [...this.debounceEntryTimestamps.entries()]
394
+ .sort((a, b) => a[1] - b[1]);
395
+ const flushCount = Math.max(1, Math.ceil(entries.length * 0.1));
396
+ let flushedCount = 0;
397
+ for (let i = 0; i < flushCount && i < entries.length; i++) {
398
+ const [key] = entries[i];
399
+ const handler = this.debouncedHandlers.get(key);
400
+ if (handler) {
401
+ // Cancel the debounce timer and fire immediately
402
+ handler.clear();
403
+ this.debouncedHandlers.delete(key);
404
+ this.debounceEntryTimestamps.delete(key);
405
+ // Fire the handler immediately with the pending event data
406
+ const latestEvent = this.pendingEventData.get(key);
407
+ if (latestEvent && this.changeHandler) {
408
+ this.pendingEventData.delete(key);
409
+ // Fire and forget - don't await, just dispatch
410
+ Promise.resolve().then(async () => {
411
+ try {
412
+ latestEvent.timestamp = new Date();
413
+ await this.changeHandler(latestEvent);
414
+ this.stats.eventsProcessed++;
415
+ this.stats.lastEventTime = new Date();
416
+ }
417
+ catch (error) {
418
+ this.stats.errors++;
419
+ logger.error({ error, event: latestEvent }, 'error processing flushed debounce entry');
420
+ }
421
+ });
422
+ }
423
+ else {
424
+ this.pendingEventData.delete(key);
425
+ }
426
+ flushedCount++;
427
+ }
428
+ }
429
+ if (flushedCount > 0) {
430
+ logger.warn({ flushedCount, mapSize: this.debouncedHandlers.size, maxSize: this.debounceMapMaxSize }, 'Flushed oldest debounce entries due to map size limit');
431
+ }
432
+ }
433
+ /**
434
+ * FIX Issue #13: Periodic cleanup of stale debounce entries older than debounceMaxAgeMs.
435
+ * Prevents unbounded growth when debounce timers keep getting reset.
436
+ */
437
+ cleanupStaleDebounceEntries() {
438
+ const now = Date.now();
439
+ let cleanedCount = 0;
440
+ for (const [key, timestamp] of this.debounceEntryTimestamps.entries()) {
441
+ if (now - timestamp > this.debounceMaxAgeMs) {
442
+ const handler = this.debouncedHandlers.get(key);
443
+ if (handler) {
444
+ // Cancel the debounce timer and fire immediately
445
+ handler.clear();
446
+ this.debouncedHandlers.delete(key);
447
+ // Fire the handler immediately with the pending event data
448
+ const latestEvent = this.pendingEventData.get(key);
449
+ if (latestEvent && this.changeHandler) {
450
+ this.pendingEventData.delete(key);
451
+ Promise.resolve().then(async () => {
452
+ try {
453
+ latestEvent.timestamp = new Date();
454
+ await this.changeHandler(latestEvent);
455
+ this.stats.eventsProcessed++;
456
+ this.stats.lastEventTime = new Date();
457
+ }
458
+ catch (error) {
459
+ this.stats.errors++;
460
+ logger.error({ error, event: latestEvent }, 'error processing stale debounce entry');
461
+ }
462
+ });
463
+ }
464
+ else {
465
+ this.pendingEventData.delete(key);
466
+ }
467
+ }
468
+ this.debounceEntryTimestamps.delete(key);
469
+ cleanedCount++;
470
+ }
471
+ }
472
+ if (cleanedCount > 0) {
473
+ logger.warn({ cleanedCount, remainingEntries: this.debouncedHandlers.size }, 'Cleaned up stale debounce entries');
474
+ }
475
+ }
356
476
  /**
357
477
  * skipTheBoringShit - checks if file should be ignored
358
478
  *
@@ -27,6 +27,8 @@ export class WatcherManager {
27
27
  syncChecker;
28
28
  isRunning = false;
29
29
  syncInterval = null;
30
+ syncInProgress = false;
31
+ syncTimeout = null;
30
32
  constructor(config) {
31
33
  // Create handler first - it's the core component
32
34
  this.handler = new AutoUpdateTheMemories(config.handler);
@@ -59,36 +61,13 @@ export class WatcherManager {
59
61
  });
60
62
  // Mark as running IMMEDIATELY - don't wait for sync
61
63
  this.isRunning = true;
62
- // 3. start periodic sync checking (this is cheap, just sets up interval)
63
- this.syncInterval = setInterval(async () => {
64
- try {
65
- const report = await this.syncChecker.checkSync();
66
- await this.writeSyncScore(report.syncScore);
67
- if (!report.inSync) {
68
- logger.warn({
69
- driftPercentage: report.driftPercentage,
70
- missingFromMcp: report.missingFromMcp.length,
71
- contentMismatch: report.contentMismatch.length
72
- }, 'drift detected during periodic check');
73
- // Auto-resync when drift is detected
74
- if (report.missingFromMcp.length > 0 || report.contentMismatch.length > 0) {
75
- logger.info('periodic check triggering auto-resync...');
76
- const resyncResult = await this.syncChecker.resyncEverythingFrFr();
77
- logger.info({
78
- filesAdded: resyncResult.filesAdded,
79
- filesUpdated: resyncResult.filesUpdated,
80
- errors: resyncResult.errors.length
81
- }, 'periodic auto-resync complete');
82
- // Update score after resync
83
- const postReport = await this.syncChecker.checkSync();
84
- await this.writeSyncScore(postReport.syncScore);
85
- }
86
- }
87
- }
88
- catch (error) {
89
- logger.error({ error }, 'periodic sync check failed');
90
- }
91
- }, syncCheckIntervalMinutes * 60 * 1000);
64
+ // 3. start periodic sync checking using setTimeout + recursive scheduling
65
+ // This prevents sync interval stacking (Issue #2) - if a sync takes longer
66
+ // than the interval, the next one won't start until the previous completes.
67
+ // Interval is configurable via SPECMEM_SYNC_CHECK_INTERVAL_MS env var.
68
+ const syncIntervalMs = parseInt(process.env['SPECMEM_SYNC_CHECK_INTERVAL_MS'] || String(syncCheckIntervalMinutes * 60 * 1000), 10);
69
+ logger.info({ syncIntervalMs }, 'scheduling periodic sync checks with recursive setTimeout');
70
+ this.scheduleSyncCheck(syncIntervalMs);
92
71
  logger.info('watcher manager started - ready for changes');
93
72
  // 4. BACKGROUND sync check - does NOT block startup
94
73
  // Runs the full filesystem scan in background so becomes responsive immediately
@@ -104,6 +83,67 @@ export class WatcherManager {
104
83
  throw error;
105
84
  }
106
85
  }
86
+ /**
87
+ * scheduleSyncCheck - schedules the next sync check using setTimeout
88
+ * Uses recursive scheduling instead of setInterval to prevent stacking (Issue #2).
89
+ * The next check is only scheduled AFTER the current one completes.
90
+ */
91
+ scheduleSyncCheck(intervalMs) {
92
+ if (!this.isRunning) {
93
+ return;
94
+ }
95
+ this.syncTimeout = setTimeout(async () => {
96
+ await this.runPeriodicSync();
97
+ // Schedule next check only after current one completes - prevents stacking
98
+ this.scheduleSyncCheck(intervalMs);
99
+ }, intervalMs);
100
+ // Allow process to exit even if timeout is pending
101
+ if (this.syncTimeout && typeof this.syncTimeout.unref === 'function') {
102
+ this.syncTimeout.unref();
103
+ }
104
+ }
105
+ /**
106
+ * runPeriodicSync - executes a single periodic sync check with guard
107
+ * Uses syncInProgress flag to prevent concurrent sync operations (Issue #2).
108
+ * If a sync is already running, logs a skip and returns immediately.
109
+ */
110
+ async runPeriodicSync() {
111
+ if (this.syncInProgress) {
112
+ logger.warn('periodic sync check skipped - another sync is already in progress (preventing stacking)');
113
+ return;
114
+ }
115
+ this.syncInProgress = true;
116
+ try {
117
+ const report = await this.syncChecker.checkSync();
118
+ await this.writeSyncScore(report.syncScore);
119
+ if (!report.inSync) {
120
+ logger.warn({
121
+ driftPercentage: report.driftPercentage,
122
+ missingFromMcp: report.missingFromMcp.length,
123
+ contentMismatch: report.contentMismatch.length
124
+ }, 'drift detected during periodic check');
125
+ // Auto-resync when drift is detected
126
+ if (report.missingFromMcp.length > 0 || report.contentMismatch.length > 0) {
127
+ logger.info('periodic check triggering auto-resync...');
128
+ const resyncResult = await this.syncChecker.resyncEverythingFrFr();
129
+ logger.info({
130
+ filesAdded: resyncResult.filesAdded,
131
+ filesUpdated: resyncResult.filesUpdated,
132
+ errors: resyncResult.errors.length
133
+ }, 'periodic auto-resync complete');
134
+ // Update score after resync
135
+ const postReport = await this.syncChecker.checkSync();
136
+ await this.writeSyncScore(postReport.syncScore);
137
+ }
138
+ }
139
+ }
140
+ catch (error) {
141
+ logger.error({ error }, 'periodic sync check failed');
142
+ }
143
+ finally {
144
+ this.syncInProgress = false;
145
+ }
146
+ }
107
147
  /**
108
148
  * runBackgroundSyncCheck - runs sync check in background without blocking
109
149
  *
@@ -169,11 +209,15 @@ export class WatcherManager {
169
209
  return;
170
210
  }
171
211
  logger.info('stopping watcher manager...');
172
- // stop sync checking
212
+ // stop sync checking (clear both legacy interval and new timeout)
173
213
  if (this.syncInterval) {
174
214
  clearInterval(this.syncInterval);
175
215
  this.syncInterval = null;
176
216
  }
217
+ if (this.syncTimeout) {
218
+ clearTimeout(this.syncTimeout);
219
+ this.syncTimeout = null;
220
+ }
177
221
  // stop file watcher
178
222
  await this.watcher.stopWatching();
179
223
  // stop queue processing