grepmax 0.17.24 → 0.18.0

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.
@@ -41,13 +41,6 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
41
41
  step((generator = generator.apply(thisArg, _arguments || [])).next());
42
42
  });
43
43
  };
44
- var __asyncValues = (this && this.__asyncValues) || function (o) {
45
- if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
46
- var m = o[Symbol.asyncIterator], i;
47
- return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i);
48
- function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
49
- function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
50
- };
51
44
  var __importDefault = (this && this.__importDefault) || function (mod) {
52
45
  return (mod && mod.__esModule) ? mod : { "default": mod };
53
46
  };
@@ -56,12 +49,9 @@ exports.Daemon = void 0;
56
49
  const fs = __importStar(require("node:fs"));
57
50
  const net = __importStar(require("node:net"));
58
51
  const path = __importStar(require("node:path"));
59
- const watcher = __importStar(require("@parcel/watcher"));
60
52
  const proper_lockfile_1 = __importDefault(require("proper-lockfile"));
61
53
  const config_1 = require("../../config");
62
- const batch_processor_1 = require("../index/batch-processor");
63
54
  const syncer_1 = require("../index/syncer");
64
- const watcher_1 = require("../index/watcher");
65
55
  const search_handler_1 = require("./search-handler");
66
56
  const meta_cache_1 = require("../store/meta-cache");
67
57
  const vector_db_1 = require("../store/vector-db");
@@ -70,14 +60,14 @@ const project_registry_1 = require("../utils/project-registry");
70
60
  const watcher_store_1 = require("../utils/watcher-store");
71
61
  const server_1 = require("../llm/server");
72
62
  const ipc_handler_1 = require("./ipc-handler");
63
+ const process_manager_1 = require("./process-manager");
64
+ const mlx_server_manager_1 = require("./mlx-server-manager");
65
+ const watcher_manager_1 = require("./watcher-manager");
73
66
  const logger_1 = require("../utils/logger");
74
- const daemon_client_1 = require("../utils/daemon-client");
75
67
  const index_config_1 = require("../index/index-config");
76
68
  const log_rotate_1 = require("../utils/log-rotate");
77
69
  const pool_1 = require("../workers/pool");
78
70
  const daemon_launcher_1 = require("../utils/daemon-launcher");
79
- const node_child_process_1 = require("node:child_process");
80
- const http = __importStar(require("node:http"));
81
71
  // 30 min was too aggressive — every shutdown is a chance for races, FSEvents
82
72
  // drops, and orphan MLX cleanup. 4 hours keeps the daemon resident through a
83
73
  // normal workday while still freeing resources overnight. Override with
@@ -110,9 +100,6 @@ const envNum = (name, fallback) => {
110
100
  };
111
101
  const MAX_LIFETIME_MS = envNum("GMAX_DAEMON_MAX_LIFETIME_MS", 24 * 60 * 60 * 1000);
112
102
  const RSS_WATERMARK_MB = envNum("GMAX_DAEMON_RSS_WATERMARK_MB", 2560);
113
- // Watcher health windows used for FSEvents auto-recovery.
114
- const FSEVENTS_RECOVERY_INTERVAL_MS = 60 * 60 * 1000; // try recovery hourly
115
- const FSEVENTS_HEALTH_WINDOW_MS = 5 * 60 * 1000; // 5 min of quiet = "healthy"
116
103
  class Daemon {
117
104
  constructor() {
118
105
  this.processors = new Map();
@@ -127,19 +114,23 @@ class Daemon {
127
114
  this.heartbeatInterval = null;
128
115
  this.idleInterval = null;
129
116
  this.heartbeatTick = 0;
130
- this.mlxRecoveryInFlight = false;
131
117
  this.shuttingDown = false;
132
118
  this.recycling = false;
133
- // PIDs flagged as orphan workers on the previous sweep. A worker must look
134
- // orphaned twice in a row before we kill it, so a worker the pool forked
135
- // between our process snapshot and its array update is never killed by a race.
136
- this.suspectedOrphanWorkers = new Set();
137
- this.pendingOps = new Set();
138
- this.watcherFailCount = new Map();
139
- this.pollIntervals = new Map();
140
- this.pollRecoveryTimers = new Map();
141
- this.lastOverflowMs = new Map();
142
- this.lastCatchupEndMs = new Map();
119
+ this.processManager = new process_manager_1.ProcessManager({
120
+ getShuttingDown: () => this.shuttingDown,
121
+ });
122
+ this.mlxServerManager = new mlx_server_manager_1.MlxServerManager({
123
+ getShuttingDown: () => this.shuttingDown,
124
+ });
125
+ this.watcherManager = new watcher_manager_1.WatcherManager({
126
+ processors: this.processors,
127
+ subscriptions: this.subscriptions,
128
+ getVectorDb: () => this.vectorDb,
129
+ getMetaCache: () => this.metaCache,
130
+ getShuttingDown: () => this.shuttingDown,
131
+ touchActivity: () => { this.lastActivity = Date.now(); },
132
+ evictSearcher: (root) => { this.searchers.delete(root); },
133
+ });
143
134
  this.projectLocks = new Map();
144
135
  // Full-index progress per root while initialSync runs (--reset / initial
145
136
  // index). Presence = a full index is in flight; value drives the partial-
@@ -147,13 +138,12 @@ class Daemon {
147
138
  this.indexProgress = new Map();
148
139
  this.shutdownAbortControllers = new Set();
149
140
  this.llmServer = null;
150
- this.mlxChild = null;
151
141
  }
152
142
  start() {
153
143
  return __awaiter(this, void 0, void 0, function* () {
154
144
  process.title = "gmax-daemon";
155
145
  // 0. Singleton enforcement: find and kill ALL stale daemon/worker processes
156
- yield this.killStaleProcesses();
146
+ yield this.processManager.killStaleProcesses();
157
147
  // 1. Acquire exclusive lock — kernel-enforced, atomic, auto-released on death
158
148
  fs.mkdirSync(path.dirname(config_1.PATHS.daemonLockFile), { recursive: true });
159
149
  fs.writeFileSync(config_1.PATHS.daemonLockFile, "", { flag: "a" }); // ensure file exists
@@ -270,7 +260,7 @@ class Daemon {
270
260
  const globalConfig = (0, index_config_1.readGlobalConfig)();
271
261
  const isAppleSilicon = process.arch === "arm64" && process.platform === "darwin";
272
262
  if (isAppleSilicon && globalConfig.embedMode === "gpu") {
273
- yield this.ensureMlxServer(globalConfig.mlxModel);
263
+ yield this.mlxServerManager.ensureMlxServer(globalConfig.mlxModel);
274
264
  }
275
265
  // 7. Register daemon (only after resources are open)
276
266
  (0, watcher_store_1.registerDaemon)(process.pid);
@@ -323,8 +313,8 @@ class Daemon {
323
313
  // after a frozen MLX process kept the port bound (v0.17.0 bug #1).
324
314
  this.heartbeatTick++;
325
315
  if (this.heartbeatTick % 5 === 0) {
326
- void this.checkMlxHealth();
327
- this.sweepOrphanWorkers();
316
+ void this.mlxServerManager.checkMlxHealth();
317
+ this.processManager.sweepOrphanWorkers();
328
318
  this.maybeRecycle();
329
319
  }
330
320
  }, HEARTBEAT_INTERVAL_MS);
@@ -390,355 +380,7 @@ class Daemon {
390
380
  }
391
381
  watchProject(root) {
392
382
  return __awaiter(this, void 0, void 0, function* () {
393
- if (this.processors.has(root) || this.pendingOps.has(root))
394
- return;
395
- if (!this.vectorDb || !this.metaCache)
396
- return;
397
- this.pendingOps.add(root);
398
- const processor = new batch_processor_1.ProjectBatchProcessor({
399
- projectRoot: root,
400
- vectorDb: this.vectorDb,
401
- metaCache: this.metaCache,
402
- onReindex: (files, ms) => __awaiter(this, void 0, void 0, function* () {
403
- console.log(`[daemon:${path.basename(root)}] Reindexed ${files} file${files !== 1 ? "s" : ""} (${(ms / 1000).toFixed(1)}s)`);
404
- // Update project registry so gmax status shows fresh data
405
- const proj = (0, project_registry_1.getProject)(root);
406
- if (proj) {
407
- let chunkCount = proj.chunkCount;
408
- try {
409
- chunkCount = yield this.vectorDb.countRowsForPath(root);
410
- }
411
- catch (err) {
412
- console.warn(`[daemon:${path.basename(root)}] Failed to query chunk count: ${err}`);
413
- }
414
- (0, project_registry_1.registerProject)(Object.assign(Object.assign({}, proj), { lastIndexed: new Date().toISOString(), chunkCount }));
415
- }
416
- // Back to watching after batch completes
417
- (0, watcher_store_1.registerWatcher)({
418
- pid: process.pid,
419
- projectRoot: root,
420
- startTime: Date.now(),
421
- status: "watching",
422
- lastHeartbeat: Date.now(),
423
- lastReindex: Date.now(),
424
- });
425
- }),
426
- onActivity: () => {
427
- this.lastActivity = Date.now();
428
- // Mark as syncing while processing
429
- (0, watcher_store_1.registerWatcher)({
430
- pid: process.pid,
431
- projectRoot: root,
432
- startTime: Date.now(),
433
- status: "syncing",
434
- lastHeartbeat: Date.now(),
435
- });
436
- },
437
- });
438
- this.processors.set(root, processor);
439
- // Subscribe with @parcel/watcher — native backend, no polling.
440
- // If the kernel refuses (e.g. FSEvents slots stuck after a prior kill -9),
441
- // fall straight through to poll mode. The retry/backoff path inside
442
- // recoverWatcher is for transient overflows, not hard kernel-level
443
- // subscribe failures, so we skip it on startup by priming failCount past
444
- // MAX before invoking it.
445
- try {
446
- yield this.subscribeWatcher(root, processor);
447
- }
448
- catch (err) {
449
- const name = path.basename(root);
450
- console.error(`[daemon:${name}] Subscribe failed at startup (${err instanceof Error ? err.message : err}) — switching to poll mode`);
451
- this.watcherFailCount.set(root, 1000); // > MAX_WATCHER_RETRIES
452
- this.lastOverflowMs.set(root, Date.now());
453
- this.recoverWatcher(root, processor);
454
- }
455
- (0, watcher_store_1.registerWatcher)({
456
- pid: process.pid,
457
- projectRoot: root,
458
- startTime: Date.now(),
459
- status: "watching",
460
- lastHeartbeat: Date.now(),
461
- });
462
- // Catchup scan — find files changed while daemon was offline
463
- this.catchupScan(root, processor).catch((err) => {
464
- console.error(`[daemon:${path.basename(root)}] Catchup scan failed:`, err);
465
- });
466
- this.pendingOps.delete(root);
467
- console.log(`[daemon] Watching ${root}`);
468
- });
469
- }
470
- subscribeWatcher(root, processor) {
471
- return __awaiter(this, void 0, void 0, function* () {
472
- const name = path.basename(root);
473
- // Unsubscribe existing watcher if any (e.g. during recovery)
474
- const existingSub = this.subscriptions.get(root);
475
- if (existingSub) {
476
- try {
477
- yield existingSub.unsubscribe();
478
- }
479
- catch (_a) { }
480
- this.subscriptions.delete(root);
481
- }
482
- const sub = yield watcher.subscribe(root, (err, events) => {
483
- var _a;
484
- if (err) {
485
- console.error(`[daemon:${name}] Watcher error:`, err);
486
- this.recoverWatcher(root, processor);
487
- return;
488
- }
489
- // Only reset fail counter after sustained health (5min since last overflow)
490
- const lastOverflow = (_a = this.lastOverflowMs.get(root)) !== null && _a !== void 0 ? _a : 0;
491
- if (Date.now() - lastOverflow > 5 * 60 * 1000) {
492
- this.watcherFailCount.delete(root);
493
- }
494
- for (const event of events) {
495
- processor.handleFileEvent(event.type === "delete" ? "unlink" : "change", event.path);
496
- }
497
- this.lastActivity = Date.now();
498
- }, { ignore: watcher_1.WATCHER_IGNORE_GLOBS });
499
- this.subscriptions.set(root, sub);
500
- });
501
- }
502
- recoverWatcher(root, processor) {
503
- var _a;
504
- const name = path.basename(root);
505
- if (this.shuttingDown)
506
- return;
507
- // Debounce: avoid multiple overlapping recovery attempts
508
- const recoveryKey = `recover:${root}`;
509
- if (this.pendingOps.has(recoveryKey))
510
- return;
511
- this.pendingOps.add(recoveryKey);
512
- const fails = ((_a = this.watcherFailCount.get(root)) !== null && _a !== void 0 ? _a : 0) + 1;
513
- this.watcherFailCount.set(root, fails);
514
- this.lastOverflowMs.set(root, Date.now());
515
- const MAX_WATCHER_RETRIES = 3;
516
- const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
517
- if (fails > MAX_WATCHER_RETRIES) {
518
- // FSEvents can't handle this project — degrade to periodic catchup scans
519
- // Always tear down the broken sub, even if poll mode is already active —
520
- // this can happen if a recovery attempt resubscribed successfully then
521
- // re-overflowed during the 5-min health window.
522
- const sub = this.subscriptions.get(root);
523
- if (sub) {
524
- sub.unsubscribe().catch(() => { });
525
- this.subscriptions.delete(root);
526
- }
527
- if (!this.pollIntervals.has(root)) {
528
- console.error(`[daemon:${name}] FSEvents unreliable after ${fails} failures — switching to poll mode (${POLL_INTERVAL_MS / 60000}min interval)`);
529
- // Run an immediate catchup, then schedule periodic ones
530
- this.catchupScan(root, processor).catch((err) => {
531
- console.error(`[daemon:${name}] Poll catchup failed:`, err);
532
- });
533
- const interval = setInterval(() => {
534
- if (this.shuttingDown)
535
- return;
536
- this.lastActivity = Date.now();
537
- this.catchupScan(root, processor).catch((err) => {
538
- console.error(`[daemon:${name}] Poll catchup failed:`, err);
539
- });
540
- }, POLL_INTERVAL_MS);
541
- this.pollIntervals.set(root, interval);
542
- // Schedule periodic attempts to climb back to native FSEvents — after
543
- // a transient burst (large git checkout, npm install) the kernel
544
- // buffer often calms down within an hour.
545
- this.schedulePollModeRecovery(root, processor);
546
- (0, watcher_store_1.registerWatcher)({
547
- pid: process.pid,
548
- projectRoot: root,
549
- startTime: Date.now(),
550
- status: "watching",
551
- lastHeartbeat: Date.now(),
552
- });
553
- }
554
- this.pendingOps.delete(recoveryKey);
555
- return;
556
- }
557
- // Backoff: wait before re-subscribing (3s, 6s, 12s)
558
- const delayMs = 3000 * Math.pow(2, fails - 1);
559
- console.error(`[daemon:${name}] Recovering watcher (attempt ${fails}/${MAX_WATCHER_RETRIES}, backoff ${delayMs}ms)...`);
560
- setTimeout(() => {
561
- if (this.shuttingDown) {
562
- this.pendingOps.delete(recoveryKey);
563
- return;
564
- }
565
- (() => __awaiter(this, void 0, void 0, function* () {
566
- var _a;
567
- try {
568
- yield this.subscribeWatcher(root, processor);
569
- const lastCatchup = (_a = this.lastCatchupEndMs.get(root)) !== null && _a !== void 0 ? _a : 0;
570
- const CATCHUP_COOLDOWN_MS = 60000;
571
- if (Date.now() - lastCatchup < CATCHUP_COOLDOWN_MS) {
572
- console.log(`[daemon:${name}] Skipping catchup scan (last completed ${Math.round((Date.now() - lastCatchup) / 1000)}s ago)`);
573
- }
574
- else {
575
- yield this.catchupScan(root, processor);
576
- }
577
- console.log(`[daemon:${name}] Watcher recovered`);
578
- }
579
- catch (err) {
580
- console.error(`[daemon:${name}] Watcher recovery failed:`, err);
581
- }
582
- finally {
583
- this.pendingOps.delete(recoveryKey);
584
- }
585
- }))();
586
- }, delayMs);
587
- }
588
- /**
589
- * Once a project has fallen back to poll mode, periodically try to upgrade
590
- * back to native FSEvents. The buffer overflows that triggered the fallback
591
- * are usually transient (big git checkout, npm install, build output) — no
592
- * point staying in 5-min poll mode forever.
593
- */
594
- schedulePollModeRecovery(root, processor) {
595
- if (this.pollRecoveryTimers.has(root))
596
- return;
597
- const name = path.basename(root);
598
- const timer = setInterval(() => {
599
- if (this.shuttingDown)
600
- return;
601
- // Skip if a watcher recovery is already in flight or we're not in poll mode anymore.
602
- if (!this.pollIntervals.has(root)) {
603
- const t = this.pollRecoveryTimers.get(root);
604
- if (t)
605
- clearInterval(t);
606
- this.pollRecoveryTimers.delete(root);
607
- return;
608
- }
609
- if (this.pendingOps.has(`recover:${root}`))
610
- return;
611
- void (() => __awaiter(this, void 0, void 0, function* () {
612
- var _a;
613
- console.log(`[daemon:${name}] Attempting to leave poll mode and reattach FSEvents...`);
614
- try {
615
- // Reset failure counter so subscribeWatcher's error path treats this
616
- // as a fresh start. If it fails again, we'll fall right back into
617
- // poll mode via the same recoverWatcher path.
618
- this.watcherFailCount.delete(root);
619
- yield this.subscribeWatcher(root, processor);
620
- // Wait one health window — if the new subscription survives without
621
- // another overflow, we consider it recovered and tear down poll mode.
622
- yield new Promise((r) => setTimeout(r, FSEVENTS_HEALTH_WINDOW_MS));
623
- if (this.shuttingDown)
624
- return;
625
- const lastOverflow = (_a = this.lastOverflowMs.get(root)) !== null && _a !== void 0 ? _a : 0;
626
- if (Date.now() - lastOverflow < FSEVENTS_HEALTH_WINDOW_MS) {
627
- console.log(`[daemon:${name}] FSEvents recovery aborted — fresh overflow within health window, staying in poll mode`);
628
- return; // recoverWatcher will have re-armed poll mode if needed
629
- }
630
- // Healthy — drop poll mode.
631
- const pollInterval = this.pollIntervals.get(root);
632
- if (pollInterval) {
633
- clearInterval(pollInterval);
634
- this.pollIntervals.delete(root);
635
- }
636
- const recoveryTimer = this.pollRecoveryTimers.get(root);
637
- if (recoveryTimer) {
638
- clearInterval(recoveryTimer);
639
- this.pollRecoveryTimers.delete(root);
640
- }
641
- console.log(`[daemon:${name}] FSEvents recovered — poll mode disabled`);
642
- }
643
- catch (err) {
644
- console.error(`[daemon:${name}] Poll-mode recovery attempt failed:`, err);
645
- }
646
- }))();
647
- }, FSEVENTS_RECOVERY_INTERVAL_MS);
648
- timer.unref();
649
- this.pollRecoveryTimers.set(root, timer);
650
- }
651
- catchupScan(root, processor) {
652
- return __awaiter(this, void 0, void 0, function* () {
653
- var _a, e_1, _b, _c;
654
- const { walk } = yield Promise.resolve().then(() => __importStar(require("../index/walker")));
655
- const { INDEXABLE_EXTENSIONS, MAX_FILE_SIZE_BYTES } = yield Promise.resolve().then(() => __importStar(require("../../config")));
656
- const { isFileCached } = yield Promise.resolve().then(() => __importStar(require("../utils/cache-check")));
657
- const rootPrefix = root.endsWith("/") ? root : `${root}/`;
658
- const cachedPaths = yield this.metaCache.getKeysWithPrefix(rootPrefix);
659
- const seenPaths = new Set();
660
- let queued = 0;
661
- let skipped = 0;
662
- let debugSamples = 0;
663
- try {
664
- for (var _d = true, _e = __asyncValues(walk(root, {
665
- additionalPatterns: ["**/.git/**", "**/.gmax/**"],
666
- })), _f; _f = yield _e.next(), _a = _f.done, !_a; _d = true) {
667
- _c = _f.value;
668
- _d = false;
669
- const relPath = _c;
670
- const absPath = path.join(root, relPath);
671
- const ext = path.extname(absPath).toLowerCase();
672
- const bn = path.basename(absPath).toLowerCase();
673
- if (!INDEXABLE_EXTENSIONS.has(ext) && !INDEXABLE_EXTENSIONS.has(bn))
674
- continue;
675
- seenPaths.add(absPath);
676
- try {
677
- const stats = yield fs.promises.stat(absPath);
678
- // Skip files that are too large or empty — they'll never be indexed
679
- if (stats.size === 0 || stats.size > MAX_FILE_SIZE_BYTES)
680
- continue;
681
- const cached = this.metaCache.get(absPath);
682
- if (!isFileCached(cached, stats)) {
683
- // Fast path: if only mtime changed but size is identical and we have a hash,
684
- // just verify the hash in-process instead of sending to a worker.
685
- if (cached && cached.hash && cached.size === stats.size) {
686
- const { computeBufferHash } = yield Promise.resolve().then(() => __importStar(require("../utils/file-utils")));
687
- const buf = yield fs.promises.readFile(absPath);
688
- const hash = computeBufferHash(buf);
689
- if (hash === cached.hash) {
690
- // Content unchanged — update mtime in cache and skip worker
691
- this.metaCache.put(absPath, Object.assign(Object.assign({}, cached), { mtimeMs: stats.mtimeMs }));
692
- skipped++;
693
- continue;
694
- }
695
- }
696
- // Debug: log first few misses to diagnose re-queue loops
697
- if (debugSamples < 5) {
698
- (0, logger_1.debug)("catchup", `miss ${relPath}: cached=${cached ? `mtime=${Math.trunc(cached.mtimeMs)} size=${cached.size}` : "null"} stat=mtime=${Math.trunc(stats.mtimeMs)} size=${stats.size}`);
699
- debugSamples++;
700
- }
701
- processor.handleFileEvent("change", absPath);
702
- queued++;
703
- // Throttle: pause periodically during large catchup scans to let the
704
- // batch processor drain and compaction run between bursts.
705
- if (queued % 500 === 0) {
706
- (0, logger_1.debug)("catchup", `${path.basename(root)}: throttle pause at ${queued} queued`);
707
- yield new Promise(r => setTimeout(r, 5000));
708
- }
709
- }
710
- else {
711
- skipped++;
712
- }
713
- }
714
- catch (_g) { }
715
- }
716
- }
717
- catch (e_1_1) { e_1 = { error: e_1_1 }; }
718
- finally {
719
- try {
720
- if (!_d && !_a && (_b = _e.return)) yield _b.call(_e);
721
- }
722
- finally { if (e_1) throw e_1.error; }
723
- }
724
- (0, logger_1.debug)("catchup", `${path.basename(root)}: ${queued} queued, ${skipped} skipped (cached ok), ${seenPaths.size} total`);
725
- // Purge files deleted while daemon was offline
726
- let purged = 0;
727
- for (const cachedPath of cachedPaths) {
728
- if (!seenPaths.has(cachedPath)) {
729
- processor.handleFileEvent("unlink", cachedPath);
730
- purged++;
731
- }
732
- }
733
- if (queued > 0 || purged > 0) {
734
- const parts = [];
735
- if (queued > 0)
736
- parts.push(`${queued} changed`);
737
- if (purged > 0)
738
- parts.push(`${purged} deleted`);
739
- console.log(`[daemon:${path.basename(root)}] Catchup: ${parts.join(", ")} file(s) while offline`);
740
- }
741
- this.lastCatchupEndMs.set(root, Date.now());
383
+ return this.watcherManager.watchProject(root);
742
384
  });
743
385
  }
744
386
  indexPendingProject(root) {
@@ -786,21 +428,7 @@ class Daemon {
786
428
  }
787
429
  unwatchProject(root) {
788
430
  return __awaiter(this, void 0, void 0, function* () {
789
- const processor = this.processors.get(root);
790
- if (!processor)
791
- return;
792
- yield processor.close();
793
- const sub = this.subscriptions.get(root);
794
- if (sub) {
795
- yield sub.unsubscribe();
796
- this.subscriptions.delete(root);
797
- }
798
- this.processors.delete(root);
799
- this.searchers.delete(root);
800
- this.lastOverflowMs.delete(root);
801
- this.lastCatchupEndMs.delete(root);
802
- (0, watcher_store_1.unregisterWatcherByRoot)(root);
803
- console.log(`[daemon] Unwatched ${root}`);
431
+ return this.watcherManager.unwatchProject(root);
804
432
  });
805
433
  }
806
434
  /**
@@ -1167,135 +795,6 @@ class Daemon {
1167
795
  }
1168
796
  });
1169
797
  }
1170
- // --- MLX embed server management ---
1171
- isMlxServerUp() {
1172
- return __awaiter(this, void 0, void 0, function* () {
1173
- const port = parseInt(process.env.MLX_EMBED_PORT || "8100", 10);
1174
- return new Promise((resolve) => {
1175
- const req = http.get({ hostname: "127.0.0.1", port, path: "/health", timeout: 2000 }, (res) => { res.resume(); resolve(res.statusCode === 200); });
1176
- req.on("error", () => resolve(false));
1177
- req.on("timeout", () => { req.destroy(); resolve(false); });
1178
- });
1179
- });
1180
- }
1181
- getPortPid(port) {
1182
- try {
1183
- const out = (0, node_child_process_1.execSync)(`lsof -ti :${port}`, { timeout: 5000 }).toString().trim();
1184
- const pid = parseInt(out.split("\n")[0], 10);
1185
- return Number.isFinite(pid) ? pid : null;
1186
- }
1187
- catch (_a) {
1188
- return null;
1189
- }
1190
- }
1191
- checkMlxHealth() {
1192
- return __awaiter(this, void 0, void 0, function* () {
1193
- if (this.shuttingDown || this.mlxRecoveryInFlight)
1194
- return;
1195
- if (yield this.isMlxServerUp())
1196
- return;
1197
- const port = parseInt(process.env.MLX_EMBED_PORT || "8100", 10);
1198
- const stalePid = this.getPortPid(port);
1199
- if (!stalePid)
1200
- return; // No process — let the next user-facing path spawn it.
1201
- this.mlxRecoveryInFlight = true;
1202
- try {
1203
- console.log(`[daemon] MLX zombie detected on port ${port} (PID ${stalePid}) — killing and respawning`);
1204
- yield (0, process_1.killProcess)(stalePid);
1205
- yield new Promise((r) => setTimeout(r, 500));
1206
- yield this.ensureMlxServer();
1207
- }
1208
- catch (err) {
1209
- console.error(`[daemon] MLX recovery failed: ${err instanceof Error ? err.message : String(err)}`);
1210
- }
1211
- finally {
1212
- this.mlxRecoveryInFlight = false;
1213
- }
1214
- });
1215
- }
1216
- ensureMlxServer(mlxModel) {
1217
- return __awaiter(this, void 0, void 0, function* () {
1218
- if (yield this.isMlxServerUp()) {
1219
- console.log("[daemon] MLX embed server already running");
1220
- return;
1221
- }
1222
- // Kill stale process holding the port (orphaned from a previous daemon)
1223
- const port = parseInt(process.env.MLX_EMBED_PORT || "8100", 10);
1224
- const stalePid = this.getPortPid(port);
1225
- if (stalePid) {
1226
- console.log(`[daemon] Killing stale MLX process on port ${port} (PID: ${stalePid})`);
1227
- yield (0, process_1.killProcess)(stalePid);
1228
- // Brief pause for OS to release the port
1229
- yield new Promise((r) => setTimeout(r, 500));
1230
- }
1231
- // Find mlx-embed-server/server.py relative to the grepmax package
1232
- const candidates = [
1233
- path.resolve(__dirname, "../../../mlx-embed-server"),
1234
- path.resolve(__dirname, "../../mlx-embed-server"),
1235
- ];
1236
- const serverDir = candidates.find((d) => fs.existsSync(path.join(d, "server.py")));
1237
- if (!serverDir) {
1238
- console.warn("[daemon] MLX embed server not found — falling back to CPU embeddings");
1239
- return;
1240
- }
1241
- const logFd = (0, log_rotate_1.openRotatedLog)(path.join(config_1.PATHS.logsDir, "mlx-embed-server.log"));
1242
- const env = Object.assign({}, process.env);
1243
- if (mlxModel)
1244
- env.MLX_EMBED_MODEL = mlxModel;
1245
- this.mlxChild = (0, node_child_process_1.spawn)("uv", ["run", "python", "server.py"], {
1246
- cwd: serverDir,
1247
- detached: true,
1248
- stdio: ["ignore", logFd, logFd],
1249
- env,
1250
- });
1251
- this.mlxChild.unref();
1252
- console.log(`[daemon] Starting MLX embed server (PID: ${this.mlxChild.pid})`);
1253
- // Poll for readiness (up to 30s)
1254
- for (let i = 0; i < 30; i++) {
1255
- yield new Promise((r) => setTimeout(r, 1000));
1256
- if (yield this.isMlxServerUp()) {
1257
- console.log("[daemon] MLX embed server ready");
1258
- return;
1259
- }
1260
- }
1261
- console.error("[daemon] MLX embed server failed to start within 30s — falling back to CPU embeddings");
1262
- this.mlxChild = null;
1263
- });
1264
- }
1265
- stopMlxServer() {
1266
- var _a;
1267
- // The spawned process is `uv`, which forks `python` then exits. Killing the
1268
- // recorded PID alone leaves python orphaned (the orphan source for port 8100
1269
- // collisions across daemon restarts). Always also kill whoever owns the port.
1270
- if ((_a = this.mlxChild) === null || _a === void 0 ? void 0 : _a.pid) {
1271
- try {
1272
- process.kill(-this.mlxChild.pid, "SIGTERM");
1273
- }
1274
- catch (_b) {
1275
- try {
1276
- process.kill(this.mlxChild.pid, "SIGTERM");
1277
- }
1278
- catch (_c) { }
1279
- }
1280
- console.log(`[daemon] Stopped MLX embed server (PID: ${this.mlxChild.pid})`);
1281
- this.mlxChild = null;
1282
- }
1283
- const port = parseInt(process.env.MLX_EMBED_PORT || "8100", 10);
1284
- const portOwner = this.getPortPid(port);
1285
- if (portOwner) {
1286
- try {
1287
- process.kill(portOwner, "SIGTERM");
1288
- console.log(`[daemon] Killed orphan MLX on port ${port} (PID: ${portOwner})`);
1289
- }
1290
- catch (_d) { }
1291
- }
1292
- }
1293
- /**
1294
- * Find and kill all stale gmax-daemon and gmax-worker processes.
1295
- * Uses pgrep to scan by process title rather than relying solely on
1296
- * the PID file, which becomes stale when a daemon is orphaned through
1297
- * the lock-compromise path.
1298
- */
1299
798
  /**
1300
799
  * Gracefully hand off to a fresh daemon when this one has grown too old or
1301
800
  * too large. Only fires when quiet — no active compaction and no in-flight
@@ -1324,104 +823,6 @@ class Daemon {
1324
823
  this.recycling = true;
1325
824
  void this.shutdown({ relaunch: true }).finally(() => process.exit(0));
1326
825
  }
1327
- /**
1328
- * Kill gmax-worker processes that are children of THIS daemon but the worker
1329
- * pool no longer tracks — strays left behind if a kill ever failed silently.
1330
- * Filters by parent PID so a per-project `gmax watch`'s own workers are never
1331
- * touched. Requires a worker to look orphaned on two consecutive sweeps so a
1332
- * just-forked worker can't be killed by a snapshot race.
1333
- */
1334
- sweepOrphanWorkers() {
1335
- if (this.shuttingDown || !(0, pool_1.isWorkerPoolInitialized)())
1336
- return;
1337
- const tracked = new Set((0, pool_1.getWorkerPool)().getWorkerPids());
1338
- const workerPids = new Set(this.findProcessesByTitle("gmax-worker"));
1339
- const ourChildren = this.findChildPids();
1340
- const orphans = ourChildren.filter((pid) => workerPids.has(pid) && !tracked.has(pid));
1341
- const confirmed = orphans.filter((pid) => this.suspectedOrphanWorkers.has(pid));
1342
- this.suspectedOrphanWorkers = new Set(orphans);
1343
- for (const pid of confirmed) {
1344
- console.log(`[daemon] Killing orphan worker PID:${pid} (untracked by pool)`);
1345
- try {
1346
- process.kill(pid, "SIGKILL");
1347
- }
1348
- catch (_a) { }
1349
- this.suspectedOrphanWorkers.delete(pid);
1350
- }
1351
- }
1352
- /** Child PIDs of this process (workers, MLX, llama-server). */
1353
- findChildPids() {
1354
- try {
1355
- const out = (0, node_child_process_1.execSync)(`pgrep -P ${process.pid}`, {
1356
- timeout: 5000,
1357
- encoding: "utf-8",
1358
- }).trim();
1359
- if (!out)
1360
- return [];
1361
- return out
1362
- .split("\n")
1363
- .map((s) => parseInt(s.trim(), 10))
1364
- .filter((n) => Number.isFinite(n) && n > 0);
1365
- }
1366
- catch (_a) {
1367
- return [];
1368
- }
1369
- }
1370
- killStaleProcesses() {
1371
- return __awaiter(this, void 0, void 0, function* () {
1372
- // 1. Check for other daemon processes
1373
- const daemonPids = this.findProcessesByTitle("gmax-daemon")
1374
- .filter((pid) => pid !== process.pid);
1375
- const workerPids = this.findProcessesByTitle("gmax-worker");
1376
- if (daemonPids.length === 0 && workerPids.length === 0) {
1377
- (0, logger_1.log)("daemon", "No stale processes found");
1378
- return;
1379
- }
1380
- for (const pid of daemonPids) {
1381
- (0, logger_1.log)("daemon", `found daemon PID:${pid}, checking liveness...`);
1382
- // A busy daemon (mid-index, compaction, big LMDB write) can block the
1383
- // event loop long enough to miss a ping. Two independent liveness
1384
- // probes — if either says "alive", defer to the running peer instead
1385
- // of killing its workers mid-flight.
1386
- // 1. daemon.lock mtime (refreshed by heartbeat every 60s)
1387
- // 2. socket ping with a generous 10s timeout
1388
- const heartbeatFresh = (0, daemon_client_1.isDaemonHeartbeatFresh)();
1389
- const responsive = yield (0, daemon_client_1.isDaemonRunning)({ timeoutMs: 10000 });
1390
- if (heartbeatFresh || responsive) {
1391
- (0, logger_1.log)("daemon", `existing daemon PID:${pid} is alive (heartbeat=${heartbeatFresh} ping=${responsive}) — exiting`);
1392
- process.exit(0);
1393
- }
1394
- (0, logger_1.log)("daemon", `stale daemon PID:${pid} unresponsive and heartbeat stale — killing`);
1395
- yield (0, process_1.killProcess)(pid);
1396
- (0, logger_1.log)("daemon", `killed stale daemon PID:${pid}`);
1397
- }
1398
- // 2. Kill orphaned workers from previous daemon instances.
1399
- // Safe because this runs before the new daemon's worker pool is initialized.
1400
- for (const pid of workerPids) {
1401
- (0, logger_1.log)("daemon", `killing orphaned worker PID:${pid}`);
1402
- yield (0, process_1.killProcess)(pid);
1403
- }
1404
- (0, logger_1.log)("daemon", `Cleaned up ${daemonPids.length} stale daemon(s), ${workerPids.length} orphaned worker(s)`);
1405
- });
1406
- }
1407
- findProcessesByTitle(title) {
1408
- try {
1409
- const out = (0, node_child_process_1.execSync)(`pgrep -x "${title}"`, {
1410
- timeout: 5000,
1411
- encoding: "utf-8",
1412
- }).trim();
1413
- if (!out)
1414
- return [];
1415
- return out
1416
- .split("\n")
1417
- .map((s) => parseInt(s.trim(), 10))
1418
- .filter((n) => Number.isFinite(n) && n > 0);
1419
- }
1420
- catch (_a) {
1421
- // pgrep exits 1 when no processes match — not an error
1422
- return [];
1423
- }
1424
- }
1425
826
  shutdown() {
1426
827
  return __awaiter(this, arguments, void 0, function* (opts = {}) {
1427
828
  var _a, _b, _c, _d;
@@ -1471,7 +872,7 @@ class Daemon {
1471
872
  }
1472
873
  catch (_g) { }
1473
874
  // Stop MLX embed server if we started it
1474
- this.stopMlxServer();
875
+ this.mlxServerManager.stopMlxServer();
1475
876
  // Destroy worker pool to prevent orphaned child processes
1476
877
  if ((0, pool_1.isWorkerPoolInitialized)()) {
1477
878
  try {
@@ -1479,23 +880,8 @@ class Daemon {
1479
880
  }
1480
881
  catch (_h) { }
1481
882
  }
1482
- // Stop poll intervals + their FSEvents recovery probes
1483
- for (const interval of this.pollIntervals.values()) {
1484
- clearInterval(interval);
1485
- }
1486
- this.pollIntervals.clear();
1487
- for (const interval of this.pollRecoveryTimers.values()) {
1488
- clearInterval(interval);
1489
- }
1490
- this.pollRecoveryTimers.clear();
1491
- // Unsubscribe all watchers
1492
- for (const sub of this.subscriptions.values()) {
1493
- try {
1494
- yield sub.unsubscribe();
1495
- }
1496
- catch (_j) { }
1497
- }
1498
- this.subscriptions.clear();
883
+ // Stop watcher poll intervals + FSEvents recovery probes, unsubscribe all
884
+ yield this.watcherManager.teardown();
1499
885
  // Close server (socket/pid/lock already dropped at the top of shutdown)
1500
886
  (_b = this.server) === null || _b === void 0 ? void 0 : _b.close();
1501
887
  // Unregister all
@@ -1508,11 +894,11 @@ class Daemon {
1508
894
  try {
1509
895
  yield ((_c = this.metaCache) === null || _c === void 0 ? void 0 : _c.close());
1510
896
  }
1511
- catch (_k) { }
897
+ catch (_j) { }
1512
898
  try {
1513
899
  yield ((_d = this.vectorDb) === null || _d === void 0 ? void 0 : _d.close());
1514
900
  }
1515
- catch (_l) { }
901
+ catch (_k) { }
1516
902
  // Hand off to a successor only after every resource is released and the
1517
903
  // liveness markers (socket/pid/lock) are already gone — so the fresh
1518
904
  // daemon's singleton check sees a clean slate and opens LanceDB/LMDB