grepmax 0.23.0 → 0.25.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.
Files changed (85) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +25 -6
  3. package/dist/commands/add.js +38 -31
  4. package/dist/commands/config.js +16 -15
  5. package/dist/commands/context.js +38 -13
  6. package/dist/commands/doctor.js +76 -83
  7. package/dist/commands/extract.js +12 -1
  8. package/dist/commands/impact.js +33 -1
  9. package/dist/commands/index.js +23 -24
  10. package/dist/commands/list.js +23 -14
  11. package/dist/commands/mcp.js +494 -162
  12. package/dist/commands/peek.js +16 -5
  13. package/dist/commands/repair.js +54 -120
  14. package/dist/commands/search-output.js +30 -9
  15. package/dist/commands/search-run.js +75 -47
  16. package/dist/commands/search-skeletons.js +28 -18
  17. package/dist/commands/search.js +45 -49
  18. package/dist/commands/serve.js +415 -373
  19. package/dist/commands/setup.js +2 -2
  20. package/dist/commands/similar.js +5 -5
  21. package/dist/commands/skeleton.js +67 -41
  22. package/dist/commands/status.js +5 -2
  23. package/dist/commands/summarize.js +6 -0
  24. package/dist/commands/surprises.js +150 -0
  25. package/dist/commands/watch.js +102 -38
  26. package/dist/config.js +3 -0
  27. package/dist/eval-surprising-connections.js +191 -0
  28. package/dist/index.js +2 -0
  29. package/dist/lib/analysis/surprising-connections.js +600 -0
  30. package/dist/lib/daemon/daemon.js +1101 -379
  31. package/dist/lib/daemon/ipc-handler.js +122 -13
  32. package/dist/lib/daemon/mlx-server-manager.js +268 -125
  33. package/dist/lib/daemon/process-manager.js +7 -4
  34. package/dist/lib/daemon/search-handler.js +23 -9
  35. package/dist/lib/daemon/watcher-manager.js +353 -110
  36. package/dist/lib/graph/impact-rollup.js +202 -0
  37. package/dist/lib/graph/impact.js +15 -1
  38. package/dist/lib/help/agent-cheatsheet.js +1 -0
  39. package/dist/lib/index/batch-processor.js +231 -67
  40. package/dist/lib/index/embedding-generation.js +109 -0
  41. package/dist/lib/index/embedding-status.js +23 -0
  42. package/dist/lib/index/file-policy.js +273 -0
  43. package/dist/lib/index/ignore-patterns.js +4 -0
  44. package/dist/lib/index/index-config.js +18 -4
  45. package/dist/lib/index/syncer.js +256 -79
  46. package/dist/lib/index/walker.js +66 -86
  47. package/dist/lib/index/watcher-batch.js +9 -0
  48. package/dist/lib/index/watcher.js +129 -3
  49. package/dist/lib/llm/server.js +6 -0
  50. package/dist/lib/search/searcher.js +30 -19
  51. package/dist/lib/skeleton/skeletonizer.js +118 -0
  52. package/dist/lib/skeleton/summary-formatter.js +8 -1
  53. package/dist/lib/store/store-lease.js +473 -0
  54. package/dist/lib/store/vector-db.js +302 -57
  55. package/dist/lib/utils/blocked-roots.js +63 -0
  56. package/dist/lib/utils/cross-project.js +7 -3
  57. package/dist/lib/utils/daemon-client.js +24 -1
  58. package/dist/lib/utils/daemon-launcher.js +38 -13
  59. package/dist/lib/utils/doctor-status.js +76 -0
  60. package/dist/lib/utils/file-utils.js +74 -4
  61. package/dist/lib/utils/keyed-mutex.js +101 -0
  62. package/dist/lib/utils/logger.js +57 -1
  63. package/dist/lib/utils/mlx-hf-cache.js +114 -0
  64. package/dist/lib/utils/operation-coordinator.js +146 -0
  65. package/dist/lib/utils/path-containment.js +106 -0
  66. package/dist/lib/utils/process.js +44 -0
  67. package/dist/lib/utils/project-registry.js +351 -3
  68. package/dist/lib/utils/scope-filter.js +3 -9
  69. package/dist/lib/utils/stale-hint.js +12 -19
  70. package/dist/lib/utils/watcher-launcher.js +5 -1
  71. package/dist/lib/utils/watcher-store.js +2 -2
  72. package/dist/lib/workers/colbert-math.js +15 -12
  73. package/dist/lib/workers/embeddings/colbert.js +3 -2
  74. package/dist/lib/workers/embeddings/granite.js +4 -3
  75. package/dist/lib/workers/embeddings/mlx-client.js +59 -16
  76. package/dist/lib/workers/orchestrator.js +39 -8
  77. package/dist/lib/workers/pool.js +150 -83
  78. package/dist/lib/workers/process-child.js +11 -2
  79. package/dist/lib/workers/serialized-handler.js +10 -0
  80. package/dist/lib/workers/worker.js +13 -4
  81. package/mlx-embed-server/server.py +21 -3
  82. package/mlx-embed-server/summarizer.py +8 -0
  83. package/package.json +6 -3
  84. package/plugins/grepmax/.claude-plugin/plugin.json +1 -1
  85. package/plugins/grepmax/hooks/start.js +3 -170
@@ -47,6 +47,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
47
47
  });
48
48
  };
49
49
  Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.validateMlxEmbeddingResponse = validateMlxEmbeddingResponse;
50
51
  exports.isMlxUp = isMlxUp;
51
52
  exports.mlxEmbed = mlxEmbed;
52
53
  const http = __importStar(require("node:http"));
@@ -55,11 +56,31 @@ const MLX_PORT = parseInt(process.env.MLX_EMBED_PORT || "8100", 10);
55
56
  const MLX_HOST = "127.0.0.1";
56
57
  const MLX_TIMEOUT_MS = 10000;
57
58
  const EMBED_MODE = process.env.GMAX_EMBED_MODE || "auto";
59
+ const FLOAT32_MAX = 3.4028234663852886e38;
58
60
  let mlxAvailable = null;
59
61
  let lastCheck = 0;
60
62
  const CHECK_INTERVAL_MS = 30000;
61
63
  let lastMlxWarning = 0;
62
64
  const MLX_WARNING_INTERVAL_MS = 60000;
65
+ let checkedModel;
66
+ function validateMlxEmbeddingResponse(data, textCount, expectedModel, expectedDim) {
67
+ if (!data || typeof data !== "object")
68
+ return false;
69
+ const response = data;
70
+ return (typeof response.model === "string" &&
71
+ (!expectedModel || response.model === expectedModel) &&
72
+ typeof response.dim === "number" &&
73
+ Number.isInteger(response.dim) &&
74
+ (expectedDim === undefined || response.dim === expectedDim) &&
75
+ Array.isArray(response.vectors) &&
76
+ response.vectors.length === textCount &&
77
+ response.vectors.every((vector) => Array.isArray(vector) &&
78
+ vector.length === response.dim &&
79
+ (expectedDim === undefined || vector.length === expectedDim) &&
80
+ vector.every((element) => typeof element === "number" &&
81
+ Number.isFinite(element) &&
82
+ Math.abs(element) <= FLOAT32_MAX)));
83
+ }
63
84
  function postJSON(reqPath, body) {
64
85
  const start = performance.now();
65
86
  return new Promise((resolve) => {
@@ -106,15 +127,24 @@ function postJSON(reqPath, body) {
106
127
  /**
107
128
  * Check if MLX server is reachable. Caches result for CHECK_INTERVAL_MS.
108
129
  */
109
- function checkHealth() {
130
+ function checkHealth(expectedModel) {
110
131
  return __awaiter(this, void 0, void 0, function* () {
111
132
  const start = performance.now();
112
133
  return new Promise((resolve) => {
113
134
  const req = http.get({ hostname: MLX_HOST, port: MLX_PORT, path: "/health", timeout: 2000 }, (res) => {
114
- res.resume();
115
- const ok = res.statusCode === 200;
116
- (0, logger_1.debug)("mlx", `health → ${ok ? "ok" : `status=${res.statusCode}`} ${(performance.now() - start).toFixed(0)}ms`);
117
- resolve(ok);
135
+ const chunks = [];
136
+ res.on("data", (chunk) => chunks.push(chunk));
137
+ res.on("end", () => {
138
+ let model;
139
+ try {
140
+ model = JSON.parse(Buffer.concat(chunks).toString("utf8")).model;
141
+ }
142
+ catch (_a) { }
143
+ const ok = res.statusCode === 200 &&
144
+ (!expectedModel || model === expectedModel);
145
+ (0, logger_1.debug)("mlx", `health → ${ok ? "ok" : `status=${res.statusCode} model=${String(model)}`} ${(performance.now() - start).toFixed(0)}ms`);
146
+ resolve(ok);
147
+ });
118
148
  });
119
149
  req.on("error", (err) => {
120
150
  (0, logger_1.debug)("mlx", `health → error: ${err.message} ${(performance.now() - start).toFixed(0)}ms`);
@@ -128,19 +158,21 @@ function checkHealth() {
128
158
  });
129
159
  });
130
160
  }
131
- function isMlxUp() {
161
+ function isMlxUp(expectedModel) {
132
162
  return __awaiter(this, void 0, void 0, function* () {
133
163
  const now = Date.now();
134
- if (mlxAvailable !== null && now - lastCheck < CHECK_INTERVAL_MS) {
164
+ if (checkedModel === expectedModel &&
165
+ mlxAvailable !== null &&
166
+ now - lastCheck < CHECK_INTERVAL_MS) {
135
167
  (0, logger_1.debug)("mlx", `isMlxUp cached=${mlxAvailable} age=${now - lastCheck}ms`);
136
168
  return mlxAvailable;
137
169
  }
138
- let result = yield checkHealth();
170
+ let result = yield checkHealth(expectedModel);
139
171
  // On first check (cold start), retry once after 3s — server may still be loading
140
172
  if (!result && mlxAvailable === null) {
141
173
  console.log("[mlx] Embed server not ready, retrying in 3s...");
142
174
  yield new Promise((r) => setTimeout(r, 3000));
143
- result = yield checkHealth();
175
+ result = yield checkHealth(expectedModel);
144
176
  if (result) {
145
177
  console.log("[mlx] Embed server ready");
146
178
  }
@@ -149,6 +181,7 @@ function isMlxUp() {
149
181
  }
150
182
  }
151
183
  mlxAvailable = result;
184
+ checkedModel = expectedModel;
152
185
  lastCheck = now;
153
186
  return result;
154
187
  });
@@ -157,16 +190,21 @@ function isMlxUp() {
157
190
  * Get dense embeddings from MLX server.
158
191
  * Returns Float32Array[] on success, null if server unavailable.
159
192
  */
160
- function mlxEmbed(texts) {
193
+ function mlxEmbed(texts, options) {
161
194
  return __awaiter(this, void 0, void 0, function* () {
162
- if (EMBED_MODE === "cpu")
195
+ var _a;
196
+ const mode = (_a = options === null || options === void 0 ? void 0 : options.mode) !== null && _a !== void 0 ? _a : EMBED_MODE;
197
+ if (mode === "cpu")
163
198
  return null;
164
- if (!(yield isMlxUp()))
199
+ if (!(yield isMlxUp(options === null || options === void 0 ? void 0 : options.expectedModel)))
165
200
  return null;
166
201
  (0, logger_1.debug)("mlx", `embed ${texts.length} texts`);
167
202
  let postResult;
168
203
  try {
169
- postResult = yield postJSON("/embed", { texts });
204
+ postResult = yield postJSON("/embed", {
205
+ texts,
206
+ expected_model: options === null || options === void 0 ? void 0 : options.expectedModel,
207
+ });
170
208
  }
171
209
  catch (error) {
172
210
  mlxAvailable = false;
@@ -178,7 +216,8 @@ function mlxEmbed(texts) {
178
216
  return null;
179
217
  }
180
218
  const { ok, data } = postResult;
181
- if (!ok || !(data === null || data === void 0 ? void 0 : data.vectors)) {
219
+ const responseMatches = validateMlxEmbeddingResponse(data, texts.length, options === null || options === void 0 ? void 0 : options.expectedModel, options === null || options === void 0 ? void 0 : options.expectedDim);
220
+ if (!ok || !responseMatches) {
182
221
  const wasPreviouslyAvailable = mlxAvailable !== false;
183
222
  mlxAvailable = false;
184
223
  const now = Date.now();
@@ -186,8 +225,12 @@ function mlxEmbed(texts) {
186
225
  now - lastMlxWarning >= MLX_WARNING_INTERVAL_MS) {
187
226
  console.error("[mlx] Embed server failed: bad response (ok=" +
188
227
  ok +
189
- ", hasVectors=" +
190
- !!(data === null || data === void 0 ? void 0 : data.vectors) +
228
+ ", validResponse=" +
229
+ responseMatches +
230
+ ", dim=" +
231
+ String(data === null || data === void 0 ? void 0 : data.dim) +
232
+ ", model=" +
233
+ String(data === null || data === void 0 ? void 0 : data.model) +
191
234
  ")");
192
235
  lastMlxWarning = now;
193
236
  }
@@ -51,9 +51,11 @@ const ort = __importStar(require("onnxruntime-node"));
51
51
  const uuid_1 = require("uuid");
52
52
  const config_1 = require("../../config");
53
53
  const chunker_1 = require("../index/chunker");
54
+ const embedding_generation_1 = require("../index/embedding-generation");
54
55
  const skeleton_1 = require("../skeleton");
55
56
  const file_utils_1 = require("../utils/file-utils");
56
57
  const logger_1 = require("../utils/logger");
58
+ const path_containment_1 = require("../utils/path-containment");
57
59
  const colbert_math_1 = require("./colbert-math");
58
60
  const colbert_1 = require("./embeddings/colbert");
59
61
  const granite_1 = require("./embeddings/granite");
@@ -115,13 +117,16 @@ if (fs.existsSync(LOCAL_MODELS)) {
115
117
  log(`Worker: Using local models from ${LOCAL_MODELS}`);
116
118
  }
117
119
  class WorkerOrchestrator {
118
- constructor(vectorDim) {
119
- this.colbert = new colbert_1.ColbertModel();
120
+ constructor(generation, embedMode) {
121
+ this.generation = generation;
122
+ this.embedMode = embedMode;
120
123
  this.chunker = new chunker_1.TreeSitterChunker();
121
124
  this.skeletonizer = new skeleton_1.Skeletonizer();
122
125
  this.initPromise = null;
123
- this.vectorDimensions = vectorDim !== null && vectorDim !== void 0 ? vectorDim : config_1.CONFIG.VECTOR_DIM;
124
- this.granite = new granite_1.GraniteModel(this.vectorDimensions);
126
+ this.vectorDimensions = generation.vectorDim;
127
+ this.allowOnnxFallback = (0, embedding_generation_1.isOnnxFallbackCompatible)(generation);
128
+ this.granite = new granite_1.GraniteModel(generation.vectorDim, generation.onnxModel);
129
+ this.colbert = new colbert_1.ColbertModel(generation.colbertModel);
125
130
  }
126
131
  ensureReady() {
127
132
  return __awaiter(this, void 0, void 0, function* () {
@@ -159,17 +164,31 @@ class WorkerOrchestrator {
159
164
  const BATCH_SIZE = Number.isFinite(envBatch) && envBatch > 0
160
165
  ? Math.max(4, Math.min(16, envBatch))
161
166
  : 16;
167
+ let denseBackend = null;
162
168
  for (let i = 0; i < texts.length; i += BATCH_SIZE) {
163
169
  if (i > 0)
164
170
  onProgress === null || onProgress === void 0 ? void 0 : onProgress();
165
171
  const batchTexts = texts.slice(i, i + BATCH_SIZE);
166
172
  const batchStart = performance.now();
167
173
  // Try MLX GPU server first, fall back to ONNX CPU
168
- const mlxResult = yield (0, mlx_client_1.mlxEmbed)(batchTexts);
174
+ const mlxResult = denseBackend === "onnx"
175
+ ? null
176
+ : yield (0, mlx_client_1.mlxEmbed)(batchTexts, {
177
+ mode: this.embedMode,
178
+ expectedModel: this.generation.mlxModel,
179
+ expectedDim: this.generation.vectorDim,
180
+ });
181
+ if (!mlxResult && !this.allowOnnxFallback) {
182
+ throw new Error(`MLX embedding model ${this.generation.mlxModel} is unavailable; ONNX fallback is disabled for this custom embedding generation`);
183
+ }
184
+ if (!mlxResult && denseBackend === "mlx") {
185
+ throw new Error(`MLX embedding model ${this.generation.mlxModel} became unavailable during this embedding operation`);
186
+ }
169
187
  if (!mlxResult && !mlxFallbackWarned) {
170
188
  console.warn("[embed] MLX unavailable, falling back to CPU (ONNX)");
171
189
  mlxFallbackWarned = true;
172
190
  }
191
+ denseBackend = mlxResult ? "mlx" : "onnx";
173
192
  const denseBatch = mlxResult !== null && mlxResult !== void 0 ? mlxResult : (yield this.granite.runBatch(batchTexts));
174
193
  const colbertBatch = yield this.colbert.runBatch(batchTexts, denseBatch, this.vectorDimensions);
175
194
  (0, logger_1.debug)("orch", `embed batch ${i / BATCH_SIZE + 1}/${Math.ceil(texts.length / BATCH_SIZE)} (${batchTexts.length} texts) mlx=${!!mlxResult} ${(performance.now() - batchStart).toFixed(0)}ms`);
@@ -243,8 +262,13 @@ class WorkerOrchestrator {
243
262
  : input.absolutePath
244
263
  ? input.absolutePath
245
264
  : path.join(PROJECT_ROOT, input.path);
265
+ (0, path_containment_1.resolveContainedPath)(input.projectRoot, absolutePath, {
266
+ verifyExistingTarget: true,
267
+ });
246
268
  (0, logger_1.debug)("orch", `processFile start: ${input.path}`);
247
- const { buffer, mtimeMs, size } = yield (0, file_utils_1.readFileSnapshot)(absolutePath);
269
+ const { buffer, mtimeMs, size } = yield (0, file_utils_1.readFileSnapshot)(absolutePath, {
270
+ projectRoot: input.projectRoot,
271
+ });
248
272
  const hash = (0, file_utils_1.computeContentHash)(buffer, absolutePath);
249
273
  if (!(0, file_utils_1.isIndexableFile)(absolutePath, size)) {
250
274
  (0, logger_1.debug)("orch", `skip ${input.path} (non-indexable, size=${size})`);
@@ -309,7 +333,14 @@ class WorkerOrchestrator {
309
333
  var _a;
310
334
  yield this.ensureReady();
311
335
  // Try MLX GPU server first, fall back to ONNX CPU
312
- const mlxResult = yield (0, mlx_client_1.mlxEmbed)([text]);
336
+ const mlxResult = yield (0, mlx_client_1.mlxEmbed)([text], {
337
+ mode: this.embedMode,
338
+ expectedModel: this.generation.mlxModel,
339
+ expectedDim: this.generation.vectorDim,
340
+ });
341
+ if (!mlxResult && !this.allowOnnxFallback) {
342
+ throw new Error(`MLX embedding model ${this.generation.mlxModel} is unavailable; ONNX fallback is disabled for this custom embedding generation`);
343
+ }
313
344
  const denseVector = (_a = mlxResult === null || mlxResult === void 0 ? void 0 : mlxResult[0]) !== null && _a !== void 0 ? _a : (yield this.granite.runBatch([text]))[0];
314
345
  const encoded = yield this.colbert.encodeQuery(text);
315
346
  const feeds = {
@@ -398,7 +429,7 @@ class WorkerOrchestrator {
398
429
  const tokenIds = Array.isArray(doc.token_ids) && doc.token_ids.length === seqLen
399
430
  ? doc.token_ids
400
431
  : undefined;
401
- return (0, colbert_math_1.maxSim)(queryMatrix, docMatrix, tokenIds);
432
+ return (0, colbert_math_1.maxSim)(queryMatrix, docMatrix, tokenIds, this.generation.colbertModel);
402
433
  });
403
434
  });
404
435
  }
@@ -55,6 +55,7 @@ const childProcess = __importStar(require("node:child_process"));
55
55
  const fs = __importStar(require("node:fs"));
56
56
  const path = __importStar(require("node:path"));
57
57
  const config_1 = require("../../config");
58
+ const embedding_generation_1 = require("../index/embedding-generation");
58
59
  const index_config_1 = require("../index/index-config");
59
60
  const logger_1 = require("../utils/logger");
60
61
  function reviveBufferLike(input) {
@@ -120,16 +121,19 @@ const REAP_FORCE_KILL_GRACE_MS = 5000;
120
121
  * every worker user (index, add, search, daemon, mcp). Merged so process.env can
121
122
  * override — see the spread order at fork().
122
123
  */
123
- function embeddingEnv() {
124
- const { modelTier } = (0, index_config_1.readGlobalConfig)();
125
- const ids = (0, index_config_1.getModelIdsForTier)(modelTier);
124
+ function embeddingEnv(generation, embedMode) {
125
+ const config = generation && embedMode ? null : (0, index_config_1.readGlobalConfig)();
126
+ const resolved = generation !== null && generation !== void 0 ? generation : (0, embedding_generation_1.resolveEmbeddingGeneration)(config);
127
+ const mode = embedMode !== null && embedMode !== void 0 ? embedMode : config.embedMode;
126
128
  return {
127
- GMAX_VECTOR_DIM: String(ids.vectorDim),
128
- GMAX_EMBED_ONNX_MODEL: ids.embed,
129
+ GMAX_VECTOR_DIM: String(resolved.vectorDim),
130
+ GMAX_EMBED_ONNX_MODEL: resolved.onnxModel,
131
+ GMAX_EMBED_MODE: mode,
132
+ GMAX_EMBEDDING_GENERATION: JSON.stringify(resolved),
129
133
  };
130
134
  }
131
135
  class ProcessWorker {
132
- constructor(modulePath, execArgv, maxMemoryMb) {
136
+ constructor(modulePath, execArgv, embeddingEnvironment, maxMemoryMb) {
133
137
  this.modulePath = modulePath;
134
138
  this.execArgv = execArgv;
135
139
  this.busy = false;
@@ -140,16 +144,17 @@ class ProcessWorker {
140
144
  this.busySince = null;
141
145
  // Most recent RSS (bytes) the worker reported, for memory-based recycling.
142
146
  this.lastRssBytes = 0;
147
+ // Consecutive post-task readings over WORKER_RSS_RECYCLE_MB. Recycling
148
+ // waits for BLOAT_STREAK_TO_RECYCLE of these; reset by any lean reading.
149
+ this.bloatStreak = 0;
143
150
  // Set when the pool has cleaned up after this worker (via exit or error
144
151
  // event). Guards against handleWorkerExit running twice when both events
145
152
  // fire for the same crash.
146
153
  this.cleanedUp = false;
147
154
  const memArgs = maxMemoryMb ? [`--max-old-space-size=${maxMemoryMb}`] : [];
148
- // embeddingEnv() first so a manually-exported GMAX_VECTOR_DIM /
149
- // GMAX_EMBED_ONNX_MODEL in process.env still wins (spread last).
150
155
  this.child = childProcess.fork(modulePath, {
151
156
  execArgv: [...memArgs, ...execArgv],
152
- env: Object.assign(Object.assign({}, embeddingEnv()), process.env),
157
+ env: Object.assign(Object.assign({}, process.env), embeddingEnvironment),
153
158
  });
154
159
  }
155
160
  }
@@ -160,7 +165,10 @@ function resolveProcessWorker() {
160
165
  return { filename: jsWorker, execArgv: [] };
161
166
  }
162
167
  if (fs.existsSync(tsWorker)) {
163
- return { filename: tsWorker, execArgv: ["-r", "ts-node/register"] };
168
+ return {
169
+ filename: tsWorker,
170
+ execArgv: ["--import", require.resolve("tsx")],
171
+ };
164
172
  }
165
173
  throw new Error("Process worker file not found");
166
174
  }
@@ -171,17 +179,28 @@ const IDLE_WORKER_TIMEOUT_MS = 60000; // reap idle workers after 60s
171
179
  // load models) on the rare search is preferable to keeping a second worker
172
180
  // warm. The pool still scales up to maxWorkers on demand for indexing bursts.
173
181
  const MIN_KEEP_WORKERS = 1;
174
- // Recycle an idle worker whose RSS has grown past this. ONNX native memory
175
- // (model arenas) lives outside V8, so --max-old-space-size can't bound it a
176
- // worker that processed one big file can stay pinned at ~2 GB. Replacing it
177
- // with a fresh worker reclaims that. 0 (or negative) disables the check.
182
+ // Recycle a worker whose RSS has grown past this. ONNX native memory lives
183
+ // outside V8, so --max-old-space-size can't bound it; replacing the worker is
184
+ // the only reclaim. The threshold must sit ABOVE the healthy working set or
185
+ // the pool executes warm workers for normal behavior: measured steady state
186
+ // is ~700-800 MB with MLX serving dense embeds and ~900-1050 MB in ONNX CPU
187
+ // fallback (granite session loaded in-process), flat across task count and
188
+ // file size. The old 800 default was calibrated for the pre-
189
+ // enableCpuMemArena:false era (arenas pinned ~2 GB) and sat inside the
190
+ // healthy range — every busy worker was recycled after nearly every task,
191
+ // paying a full model reload each time. 0 (or negative) disables the check.
178
192
  const WORKER_RSS_RECYCLE_MB = (() => {
179
193
  var _a;
180
194
  const fromEnv = Number.parseInt((_a = process.env.GMAX_WORKER_RSS_RECYCLE_MB) !== null && _a !== void 0 ? _a : "", 10);
181
195
  if (Number.isFinite(fromEnv))
182
196
  return fromEnv;
183
- return 800;
197
+ return 1536;
184
198
  })();
199
+ // Post-task recycling waits for this many consecutive over-threshold readings.
200
+ // V8 holds freed heap after a big file, so a single post-task reading can show
201
+ // a transient high-water mark that the next task's GC releases; the second
202
+ // reading confirms the memory is actually sticky.
203
+ const BLOAT_STREAK_TO_RECYCLE = 2;
185
204
  // Methods that must skip the indexing backlog. encodeQuery is the search hot
186
205
  // path: a single query is ~17ms but waits behind every queued processFile.
187
206
  // rerank is similarly small and latency-sensitive.
@@ -190,7 +209,7 @@ const PRIORITY_METHODS = new Set([
190
209
  "rerank",
191
210
  ]);
192
211
  class WorkerPool {
193
- constructor() {
212
+ constructor(generation, embedMode) {
194
213
  this.workers = [];
195
214
  // Two queues so searches don't wait behind a long indexing backlog. Priority
196
215
  // tasks (encodeQuery/rerank) are dispatched first; processFile tasks queue
@@ -198,7 +217,6 @@ class WorkerPool {
198
217
  this.priorityQueue = [];
199
218
  this.taskQueue = [];
200
219
  this.tasks = new Map();
201
- this.abortedTasks = new Set();
202
220
  this.nextId = 1;
203
221
  this.destroyed = false;
204
222
  this.destroyPromise = null;
@@ -209,6 +227,14 @@ class WorkerPool {
209
227
  this.modulePath = resolved.filename;
210
228
  this.execArgv = resolved.execArgv;
211
229
  this.maxWorkers = Math.max(1, config_1.CONFIG.WORKER_THREADS);
230
+ const config = generation && embedMode ? null : (0, index_config_1.readGlobalConfig)();
231
+ this.generation = generation !== null && generation !== void 0 ? generation : (0, embedding_generation_1.resolveEmbeddingGeneration)(config);
232
+ this.embedMode = embedMode !== null && embedMode !== void 0 ? embedMode : config.embedMode;
233
+ const generatedEmbeddingEnv = embeddingEnv(this.generation, this.embedMode);
234
+ // Replacement workers must use the same embedding generation as the pool
235
+ // that created them, even if config changes while tasks are running. Other
236
+ // process environment remains live and is copied at each fork.
237
+ this.embeddingEnvironment = Object.freeze(generatedEmbeddingEnv);
212
238
  // Lazy spawn: start with 1 worker, scale up on demand
213
239
  this.spawnWorker();
214
240
  // Periodically reap idle workers back to MIN_KEEP, and force-kill any
@@ -280,6 +306,10 @@ class WorkerPool {
280
306
  }
281
307
  completeTask(task, worker) {
282
308
  this.clearTaskTimeout(task);
309
+ if (task.signal && task.abortListener) {
310
+ task.signal.removeEventListener("abort", task.abortListener);
311
+ task.abortListener = undefined;
312
+ }
283
313
  this.tasks.delete(task.id);
284
314
  this.removeFromQueue(task.id);
285
315
  if (worker) {
@@ -320,23 +350,12 @@ class WorkerPool {
320
350
  }
321
351
  }
322
352
  spawnWorker() {
323
- const worker = new ProcessWorker(this.modulePath, this.execArgv, config_1.MAX_WORKER_MEMORY_MB);
353
+ const worker = new ProcessWorker(this.modulePath, this.execArgv, this.embeddingEnvironment, config_1.MAX_WORKER_MEMORY_MB);
324
354
  (0, logger_1.log)("pool", `spawn PID:${worker.child.pid} (${this.workers.length + 1}/${Math.max(1, config_1.CONFIG.WORKER_THREADS)})`);
325
355
  const onMessage = (msg) => {
326
356
  var _a, _b, _c, _d, _e, _f, _g, _h;
327
357
  if (typeof msg.rss === "number")
328
358
  worker.lastRssBytes = msg.rss;
329
- // Fast cleanup for tasks that were aborted while running
330
- if (this.abortedTasks.has(msg.id)) {
331
- this.abortedTasks.delete(msg.id);
332
- const task = this.tasks.get(msg.id);
333
- if (task) {
334
- this.completeTask(task, worker);
335
- this.recycleIfBloated(worker);
336
- this.dispatch();
337
- }
338
- return;
339
- }
340
359
  const task = this.tasks.get(msg.id);
341
360
  if (!task)
342
361
  return;
@@ -416,34 +435,26 @@ class WorkerPool {
416
435
  payload,
417
436
  resolve: safeResolve,
418
437
  reject: safeReject,
438
+ signal,
419
439
  };
420
440
  if (signal) {
421
- signal.addEventListener("abort", () => {
422
- // If task is still queued (in either queue), remove it
423
- const pi = this.priorityQueue.indexOf(id);
424
- if (pi !== -1)
425
- this.priorityQueue.splice(pi, 1);
426
- const idx = this.taskQueue.indexOf(id);
427
- if (pi !== -1 || idx !== -1) {
428
- if (idx !== -1)
429
- this.taskQueue.splice(idx, 1);
430
- this.tasks.delete(id);
431
- const err = new Error("Aborted");
432
- err.name = "AbortError";
433
- safeReject(err);
434
- }
435
- // If task is already running (assigned to worker), we can't easily kill it without
436
- // killing the worker. For now, we just let it finish but reject the promise early so
437
- // the caller doesn't wait. The worker will eventually finish and we'll ignore the result.
438
- else if (this.tasks.has(id)) {
439
- // Task is running. Reject caller immediately.
440
- const err = new Error("Aborted");
441
- err.name = "AbortError";
442
- safeReject(err);
443
- // Track for fast cleanup when the worker eventually finishes.
444
- this.abortedTasks.add(id);
441
+ task.abortListener = () => {
442
+ if (!this.tasks.has(id))
443
+ return;
444
+ const err = new Error("Aborted");
445
+ err.name = "AbortError";
446
+ safeReject(err);
447
+ if (!task.worker) {
448
+ this.completeTask(task, null);
449
+ this.dispatch();
450
+ return;
445
451
  }
446
- }, { once: true });
452
+ // Caller settlement and worker lifecycle settlement are separate.
453
+ // Keep the assignment and timers until the child sends a terminal
454
+ // result/error or exits.
455
+ task.callerAborted = true;
456
+ };
457
+ signal.addEventListener("abort", task.abortListener, { once: true });
447
458
  }
448
459
  this.tasks.set(id, task);
449
460
  if (PRIORITY_METHODS.has(method)) {
@@ -590,14 +601,17 @@ class WorkerPool {
590
601
  this.recycleWorker(w, "idle");
591
602
  }
592
603
  /**
593
- * Recycle a worker whose RSS exceeds the threshold the instant it goes free
594
- * between tasks. The idle reaper alone can't catch this: under continuous
595
- * churn (a busy monorepo trickling one small file at a time) a worker is
596
- * dispatched again within the 60s idle window, so a worker that peaked at
597
- * ~1.4 GB on one large file never looks "idle" and stays pinned. Checking at
598
- * task completion — when busy was just cleared and before the next dispatch —
599
- * bounds RSS regardless of how steady the churn is. No-op while busy, so an
600
- * in-flight task is never interrupted.
604
+ * Recycle a worker whose RSS stays over the threshold across consecutive
605
+ * task completions. The idle reaper alone can't catch this: under
606
+ * continuous churn (a busy monorepo trickling one small file at a time) a
607
+ * worker is dispatched again within the 60s idle window, so a genuinely
608
+ * pinned worker never looks "idle". Checking at task completion — when busy
609
+ * was just cleared and before the next dispatch — bounds RSS regardless of
610
+ * how steady the churn is. Requires BLOAT_STREAK_TO_RECYCLE consecutive
611
+ * over-threshold readings so a one-off high-water mark (V8 holding heap it
612
+ * frees on the next task's GC) doesn't execute a warm worker and force a
613
+ * model reload. No-op while busy, so an in-flight task is never
614
+ * interrupted.
601
615
  */
602
616
  recycleIfBloated(worker) {
603
617
  if (this.destroyed ||
@@ -607,7 +621,13 @@ class WorkerPool {
607
621
  return;
608
622
  }
609
623
  if (worker.lastRssBytes > WORKER_RSS_RECYCLE_MB * 1024 * 1024) {
610
- this.recycleWorker(worker, "post-task");
624
+ worker.bloatStreak++;
625
+ if (worker.bloatStreak >= BLOAT_STREAK_TO_RECYCLE) {
626
+ this.recycleWorker(worker, "post-task");
627
+ }
628
+ }
629
+ else {
630
+ worker.bloatStreak = 0;
611
631
  }
612
632
  }
613
633
  /**
@@ -702,7 +722,7 @@ class WorkerPool {
702
722
  });
703
723
  }
704
724
  destroy() {
705
- return __awaiter(this, void 0, void 0, function* () {
725
+ return __awaiter(this, arguments, void 0, function* (options = {}) {
706
726
  if (this.destroyPromise)
707
727
  return this.destroyPromise;
708
728
  if (this.destroyed)
@@ -713,46 +733,86 @@ class WorkerPool {
713
733
  this.idleReapInterval = null;
714
734
  }
715
735
  for (const task of this.tasks.values()) {
716
- this.clearTaskTimeout(task);
717
736
  task.reject(new Error("Worker pool destroyed"));
737
+ this.completeTask(task, null);
718
738
  }
719
- this.tasks.clear();
720
739
  this.taskQueue = [];
721
740
  this.priorityQueue = [];
722
- const killPromises = this.workers.map((w) => new Promise((resolve) => {
741
+ const exitedWorkers = new Set();
742
+ const killPromises = this.workers.map((w) => new Promise((resolve, reject) => {
723
743
  var _a, _b;
724
744
  let settled = false;
725
745
  let force;
726
- let fallback;
727
- const cleanup = () => {
746
+ let deadline;
747
+ const settle = (error) => {
728
748
  if (settled)
729
749
  return;
730
750
  settled = true;
731
751
  if (force)
732
752
  clearTimeout(force);
733
- if (fallback)
734
- clearTimeout(fallback);
735
- resolve();
753
+ if (deadline)
754
+ clearTimeout(deadline);
755
+ if (error)
756
+ reject(error);
757
+ else
758
+ resolve();
759
+ };
760
+ const exited = () => {
761
+ exitedWorkers.add(w);
762
+ settle();
736
763
  };
737
764
  w.cleanedUp = true;
738
765
  w.child.removeAllListeners("message");
739
766
  w.child.removeAllListeners("exit");
740
767
  w.child.removeAllListeners("error");
741
- w.child.once("exit", cleanup);
768
+ w.child.once("exit", exited);
742
769
  force = setTimeout(() => {
743
770
  try {
744
- w.child.kill("SIGKILL");
771
+ if (!w.child.kill("SIGKILL") && options.requireExit) {
772
+ settle(new Error(`Failed to SIGKILL worker PID:${w.child.pid}`));
773
+ }
774
+ }
775
+ catch (error) {
776
+ if (options.requireExit) {
777
+ settle(new Error(`Failed to SIGKILL worker PID:${w.child.pid}: ${error instanceof Error ? error.message : String(error)}`));
778
+ }
745
779
  }
746
- catch (_a) { }
747
780
  }, FORCE_KILL_GRACE_MS);
748
781
  (_a = force.unref) === null || _a === void 0 ? void 0 : _a.call(force);
749
- fallback = setTimeout(cleanup, config_1.WORKER_TIMEOUT_MS);
750
- (_b = fallback.unref) === null || _b === void 0 ? void 0 : _b.call(fallback);
751
- w.child.kill("SIGTERM");
782
+ deadline = setTimeout(() => {
783
+ if (options.requireExit) {
784
+ settle(new Error(`Worker PID:${w.child.pid} did not exit within ${config_1.WORKER_TIMEOUT_MS}ms`));
785
+ }
786
+ else {
787
+ settle();
788
+ }
789
+ }, config_1.WORKER_TIMEOUT_MS);
790
+ (_b = deadline.unref) === null || _b === void 0 ? void 0 : _b.call(deadline);
791
+ try {
792
+ if (!w.child.kill("SIGTERM") && options.requireExit) {
793
+ settle(new Error(`Failed to SIGTERM worker PID:${w.child.pid}`));
794
+ }
795
+ }
796
+ catch (error) {
797
+ if (options.requireExit) {
798
+ settle(new Error(`Failed to SIGTERM worker PID:${w.child.pid}: ${error instanceof Error ? error.message : String(error)}`));
799
+ }
800
+ else {
801
+ settle();
802
+ }
803
+ }
752
804
  }));
753
- this.destroyPromise = Promise.allSettled(killPromises).then(() => {
754
- this.workers = [];
755
- this.destroyPromise = null;
805
+ this.destroyPromise = Promise.allSettled(killPromises).then((results) => {
806
+ this.workers = options.requireExit
807
+ ? this.workers.filter((worker) => !exitedWorkers.has(worker))
808
+ : [];
809
+ const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
810
+ if (failures.length > 0) {
811
+ const details = failures
812
+ .map((failure) => failure instanceof Error ? failure.message : String(failure))
813
+ .join("; ");
814
+ throw new Error(`Strict worker pool shutdown failed: ${details}`);
815
+ }
756
816
  });
757
817
  yield this.destroyPromise;
758
818
  });
@@ -761,9 +821,16 @@ class WorkerPool {
761
821
  exports.WorkerPool = WorkerPool;
762
822
  WorkerPool.MAX_RESPAWNS = 10;
763
823
  let singleton = null;
764
- function getWorkerPool() {
824
+ function getWorkerPool(generation, embedMode) {
765
825
  if (!singleton) {
766
- singleton = new WorkerPool();
826
+ singleton = new WorkerPool(generation, embedMode);
827
+ }
828
+ else if (generation &&
829
+ singleton.generation.fingerprint !== generation.fingerprint) {
830
+ throw new Error(`Worker pool embedding generation mismatch: active ${singleton.generation.fingerprint}, requested ${generation.fingerprint}`);
831
+ }
832
+ else if (embedMode && singleton.embedMode !== embedMode) {
833
+ throw new Error(`Worker pool embed mode mismatch: active ${singleton.embedMode}, requested ${embedMode}`);
767
834
  }
768
835
  return singleton;
769
836
  }