grepmax 0.16.10 → 0.17.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.
@@ -521,7 +521,7 @@ exports.mcp = new commander_1.Command("mcp")
521
521
  }
522
522
  }
523
523
  }
524
- const result = yield searcher.search(query, limit, { rerank: true }, Object.keys(filters).length > 0 ? filters : undefined, pathPrefix);
524
+ const result = yield searcher.search(query, limit, { rerank: process.env.GMAX_RERANK === "1" }, Object.keys(filters).length > 0 ? filters : undefined, pathPrefix);
525
525
  if (!result.data || result.data.length === 0) {
526
526
  return ok("No matches found. Try broadening your query, using fewer keywords, or check `gmax status` to verify the project is indexed.");
527
527
  }
@@ -1578,7 +1578,7 @@ exports.mcp = new commander_1.Command("mcp")
1578
1578
  const rel = (p) => p.startsWith(`${projectRoot}/`) ? p.slice(projectRoot.length + 1) : p;
1579
1579
  if (query) {
1580
1580
  const searcher = getSearcher();
1581
- const response = yield searcher.search(query, limit, { rerank: true }, {}, projectRoot);
1581
+ const response = yield searcher.search(query, limit, { rerank: process.env.GMAX_RERANK === "1" }, {}, projectRoot);
1582
1582
  const changedSet = new Set(changedFiles);
1583
1583
  let filtered = response.data.filter((r) => changedSet.has(String(r.path || "")));
1584
1584
  if (role)
@@ -1753,7 +1753,7 @@ exports.mcp = new commander_1.Command("mcp")
1753
1753
  const limit = Math.min(Math.max(Number(args.limit) || 10, 1), 25);
1754
1754
  try {
1755
1755
  const searcher = getSearcher();
1756
- const response = yield searcher.search(topic, limit, { rerank: true }, {}, projectRoot);
1756
+ const response = yield searcher.search(topic, limit, { rerank: process.env.GMAX_RERANK === "1" }, {}, projectRoot);
1757
1757
  if (response.data.length === 0)
1758
1758
  return ok(`No results found for "${topic}".`);
1759
1759
  const rel = (p) => p.startsWith(`${projectRoot}/`) ? p.slice(projectRoot.length + 1) : p;
@@ -571,7 +571,7 @@ Examples:
571
571
  ? searchFilters
572
572
  : undefined,
573
573
  pathPrefix: pathFilter,
574
- rerank: true,
574
+ rerank: process.env.GMAX_RERANK === "1",
575
575
  explain: options.explain,
576
576
  includeSkeletons: options.skeleton,
577
577
  includeGraph: options.symbol,
@@ -668,7 +668,7 @@ Examples:
668
668
  }
669
669
  }
670
670
  const searcher = new searcher_1.Searcher(vectorDb);
671
- searchResult = yield searcher.search(pattern, parseInt(options.m, 10), { rerank: true, explain: options.explain }, Object.keys(searchFilters).length > 0
671
+ searchResult = yield searcher.search(pattern, parseInt(options.m, 10), { rerank: process.env.GMAX_RERANK === "1", explain: options.explain }, Object.keys(searchFilters).length > 0
672
672
  ? searchFilters
673
673
  : undefined, pathFilter);
674
674
  } // end if (!searchResult) — in-process fallback
package/dist/eval.js CHANGED
@@ -572,11 +572,19 @@ function run() {
572
572
  process.exit(1);
573
573
  }
574
574
  const results = [];
575
- console.log("Starting evaluation...\n");
575
+ const jsonModeEarly = process.env.GMAX_EVAL_JSON === "1" || process.argv.includes("--json");
576
+ // In JSON mode, route all human-readable preamble to stderr so stdout
577
+ // stays a single parseable JSON object.
578
+ const log = jsonModeEarly ? console.error : console.log;
579
+ log("Starting evaluation...\n");
576
580
  const startTime = performance.now();
581
+ // Rerank is OFF by default — measures pre-rerank quality so ranking-only
582
+ // changes show up clearly. Set GMAX_EVAL_RERANK=1 to measure the full
583
+ // production pipeline (slower, but more representative).
584
+ const rerank = process.env.GMAX_EVAL_RERANK === "1";
577
585
  for (const c of exports.cases) {
578
586
  const queryStart = performance.now();
579
- const res = yield searcher.search(c.query, topK, { rerank: false });
587
+ const res = yield searcher.search(c.query, topK, { rerank });
580
588
  const queryEnd = performance.now();
581
589
  const timeMs = queryEnd - queryStart;
582
590
  results.push(evaluateCase(res, c, timeMs));
@@ -585,25 +593,46 @@ function run() {
585
593
  const mrr = results.reduce((sum, r) => sum + r.rr, 0) / results.length;
586
594
  const recallAt10 = results.reduce((sum, r) => sum + r.recall, 0) / results.length;
587
595
  const avgTime = results.reduce((sum, r) => sum + r.timeMs, 0) / results.length;
588
- console.log("=".repeat(80));
589
- console.log(`Eval results for store at: ${paths.lancedbDir}`);
590
- console.log("=".repeat(80));
591
- results.forEach((r) => {
592
- const status = r.found ? `rank ${(1 / r.rr).toFixed(0)}` : "❌ missed";
593
- const emoji = r.found ? (r.rr === 1 ? "🎯" : "✓") : "❌";
594
- console.log(`${emoji} ${r.query}`);
595
- console.log(` => ${status} (target: ${r.path}) [${r.timeMs.toFixed(0)}ms]`);
596
- if (r.note) {
597
- console.log(` // ${r.note}`);
598
- }
599
- });
600
- console.log("=".repeat(80));
601
- console.log(`MRR: ${mrr.toFixed(3)}`);
602
- console.log(`Recall@10: ${recallAt10.toFixed(3)}`);
603
- console.log(`Avg query time: ${avgTime.toFixed(0)}ms`);
604
- console.log(`Total time: ${totalTime.toFixed(0)}ms`);
605
- console.log(`Found: ${results.filter((r) => r.found).length}/${results.length}`);
606
- console.log("=".repeat(80));
596
+ const summary = {
597
+ cases: results.length,
598
+ found: results.filter((r) => r.found).length,
599
+ hitsAt1: results.filter((r) => r.rr === 1).length,
600
+ mrrAt10: Number(mrr.toFixed(4)),
601
+ recallAt10: Number(recallAt10.toFixed(4)),
602
+ avgTimeMs: Math.round(avgTime),
603
+ totalTimeMs: Math.round(totalTime),
604
+ storePath: paths.lancedbDir,
605
+ rerank,
606
+ };
607
+ // JSON mode (GMAX_EVAL_JSON=1 or --json arg) emits a single stable object
608
+ // on stdout for tooling. Human output goes to stderr so both can be
609
+ // captured separately. Public-compatible shape — adding fixtures later
610
+ // doesn't change the schema.
611
+ const jsonMode = process.env.GMAX_EVAL_JSON === "1" || process.argv.includes("--json");
612
+ if (jsonMode) {
613
+ process.stdout.write(`${JSON.stringify({ summary, results }, null, 2)}\n`);
614
+ }
615
+ else {
616
+ console.log("=".repeat(80));
617
+ console.log(`Eval results for store at: ${paths.lancedbDir}`);
618
+ console.log("=".repeat(80));
619
+ results.forEach((r) => {
620
+ const status = r.found ? `rank ${(1 / r.rr).toFixed(0)}` : "❌ missed";
621
+ const emoji = r.found ? (r.rr === 1 ? "🎯" : "✓") : "❌";
622
+ console.log(`${emoji} ${r.query}`);
623
+ console.log(` => ${status} (target: ${r.path}) [${r.timeMs.toFixed(0)}ms]`);
624
+ if (r.note) {
625
+ console.log(` // ${r.note}`);
626
+ }
627
+ });
628
+ console.log("=".repeat(80));
629
+ console.log(`MRR: ${mrr.toFixed(3)}`);
630
+ console.log(`Recall@10: ${recallAt10.toFixed(3)}`);
631
+ console.log(`Avg query time: ${avgTime.toFixed(0)}ms`);
632
+ console.log(`Total time: ${totalTime.toFixed(0)}ms`);
633
+ console.log(`Found: ${summary.found}/${results.length}`);
634
+ console.log("=".repeat(80));
635
+ }
607
636
  yield (0, exit_1.gracefulExit)(0);
608
637
  });
609
638
  }
@@ -109,6 +109,8 @@ class Daemon {
109
109
  this.startTime = Date.now();
110
110
  this.heartbeatInterval = null;
111
111
  this.idleInterval = null;
112
+ this.heartbeatTick = 0;
113
+ this.mlxRecoveryInFlight = false;
112
114
  this.shuttingDown = false;
113
115
  this.pendingOps = new Set();
114
116
  this.watcherFailCount = new Map();
@@ -289,6 +291,14 @@ class Daemon {
289
291
  }
290
292
  catch (_a) { }
291
293
  (0, log_rotate_1.rotateLogFds)(path.join(config_1.PATHS.logsDir, "daemon.log"));
294
+ // Every 5 ticks (5 min), probe the MLX embed server and respawn if
295
+ // it's gone zombie (port held but /health unresponsive). Closes the
296
+ // 42h-degradation window where workers silently fell back to ONNX CPU
297
+ // after a frozen MLX process kept the port bound (v0.17.0 bug #1).
298
+ this.heartbeatTick++;
299
+ if (this.heartbeatTick % 5 === 0) {
300
+ void this.checkMlxHealth();
301
+ }
292
302
  }, HEARTBEAT_INTERVAL_MS);
293
303
  // 10. Idle timeout (skip when disabled via env)
294
304
  if (IDLE_TIMEOUT_MS > 0) {
@@ -796,7 +806,7 @@ class Daemon {
796
806
  this.lastActivity = Date.now();
797
807
  let result;
798
808
  try {
799
- result = yield searcher.search(payload.query, payload.limit, { rerank: payload.rerank !== false, explain: payload.explain === true }, payload.filters, payload.pathPrefix, undefined, signal);
809
+ result = yield searcher.search(payload.query, payload.limit, { rerank: payload.rerank === true, explain: payload.explain === true }, payload.filters, payload.pathPrefix, undefined, signal);
800
810
  }
801
811
  catch (err) {
802
812
  if ((err === null || err === void 0 ? void 0 : err.name) === "AbortError") {
@@ -910,6 +920,7 @@ class Daemon {
910
920
  conn.on("close", () => ac.abort());
911
921
  this.shutdownAbortControllers.add(ac);
912
922
  this.vectorDb.pauseMaintenanceLoop();
923
+ const stopHeartbeat = (0, ipc_handler_1.startHeartbeat)(conn);
913
924
  let lastProgressTime = 0;
914
925
  try {
915
926
  const result = yield (0, syncer_1.initialSync)({
@@ -934,6 +945,7 @@ class Daemon {
934
945
  if (!this.shuttingDown) {
935
946
  yield this.watchProject(root);
936
947
  }
948
+ stopHeartbeat();
937
949
  (0, ipc_handler_1.writeDone)(conn, {
938
950
  ok: true,
939
951
  processed: result.processed,
@@ -945,9 +957,11 @@ class Daemon {
945
957
  catch (err) {
946
958
  const msg = err instanceof Error ? err.message : String(err);
947
959
  console.error(`[daemon] addProject failed for ${path.basename(root)}:`, msg);
960
+ stopHeartbeat();
948
961
  (0, ipc_handler_1.writeDone)(conn, { ok: false, error: msg });
949
962
  }
950
963
  finally {
964
+ stopHeartbeat();
951
965
  this.shutdownAbortControllers.delete(ac);
952
966
  (_a = this.vectorDb) === null || _a === void 0 ? void 0 : _a.resumeMaintenanceLoop();
953
967
  }
@@ -977,6 +991,7 @@ class Daemon {
977
991
  conn.on("close", () => ac.abort());
978
992
  this.shutdownAbortControllers.add(ac);
979
993
  this.vectorDb.pauseMaintenanceLoop();
994
+ const stopHeartbeat = (0, ipc_handler_1.startHeartbeat)(conn);
980
995
  let lastProgressTime = 0;
981
996
  try {
982
997
  const result = yield (0, syncer_1.initialSync)({
@@ -1000,6 +1015,7 @@ class Daemon {
1000
1015
  });
1001
1016
  },
1002
1017
  });
1018
+ stopHeartbeat();
1003
1019
  (0, ipc_handler_1.writeDone)(conn, {
1004
1020
  ok: true,
1005
1021
  processed: result.processed,
@@ -1011,9 +1027,11 @@ class Daemon {
1011
1027
  catch (err) {
1012
1028
  const msg = err instanceof Error ? err.message : String(err);
1013
1029
  console.error(`[daemon] indexProject failed for ${path.basename(root)}:`, msg);
1030
+ stopHeartbeat();
1014
1031
  (0, ipc_handler_1.writeDone)(conn, { ok: false, error: msg });
1015
1032
  }
1016
1033
  finally {
1034
+ stopHeartbeat();
1017
1035
  this.shutdownAbortControllers.delete(ac);
1018
1036
  (_a = this.vectorDb) === null || _a === void 0 ? void 0 : _a.resumeMaintenanceLoop();
1019
1037
  // Re-enable watcher (skip if shutting down)
@@ -1036,6 +1054,7 @@ class Daemon {
1036
1054
  (0, ipc_handler_1.writeDone)(conn, { ok: false, error: "daemon resources not ready" });
1037
1055
  return;
1038
1056
  }
1057
+ const stopHeartbeat = (0, ipc_handler_1.startHeartbeat)(conn);
1039
1058
  try {
1040
1059
  yield this.unwatchProject(root);
1041
1060
  const rootPrefix = root.endsWith("/") ? root : `${root}/`;
@@ -1044,13 +1063,18 @@ class Daemon {
1044
1063
  for (const key of keys) {
1045
1064
  this.metaCache.delete(key);
1046
1065
  }
1066
+ stopHeartbeat();
1047
1067
  (0, ipc_handler_1.writeDone)(conn, { ok: true });
1048
1068
  }
1049
1069
  catch (err) {
1050
1070
  const msg = err instanceof Error ? err.message : String(err);
1051
1071
  console.error(`[daemon] removeProject failed for ${path.basename(root)}:`, msg);
1072
+ stopHeartbeat();
1052
1073
  (0, ipc_handler_1.writeDone)(conn, { ok: false, error: msg });
1053
1074
  }
1075
+ finally {
1076
+ stopHeartbeat();
1077
+ }
1054
1078
  }));
1055
1079
  });
1056
1080
  }
@@ -1063,6 +1087,7 @@ class Daemon {
1063
1087
  return;
1064
1088
  }
1065
1089
  const rootPrefix = (_a = opts.pathPrefix) !== null && _a !== void 0 ? _a : (root.endsWith("/") ? root : `${root}/`);
1090
+ const stopHeartbeat = (0, ipc_handler_1.startHeartbeat)(conn);
1066
1091
  let lastProgressTime = 0;
1067
1092
  try {
1068
1093
  const result = yield (0, syncer_1.generateSummaries)(this.vectorDb, rootPrefix, (done, total) => {
@@ -1073,6 +1098,7 @@ class Daemon {
1073
1098
  lastProgressTime = now;
1074
1099
  (0, ipc_handler_1.writeProgress)(conn, { summarized: done, total });
1075
1100
  }, opts.limit);
1101
+ stopHeartbeat();
1076
1102
  (0, ipc_handler_1.writeDone)(conn, {
1077
1103
  ok: true,
1078
1104
  summarized: result.summarized,
@@ -1082,8 +1108,12 @@ class Daemon {
1082
1108
  catch (err) {
1083
1109
  const msg = err instanceof Error ? err.message : String(err);
1084
1110
  console.error(`[daemon] summarizeProject failed for ${path.basename(root)}:`, msg);
1111
+ stopHeartbeat();
1085
1112
  (0, ipc_handler_1.writeDone)(conn, { ok: false, error: msg });
1086
1113
  }
1114
+ finally {
1115
+ stopHeartbeat();
1116
+ }
1087
1117
  }));
1088
1118
  });
1089
1119
  }
@@ -1165,6 +1195,31 @@ class Daemon {
1165
1195
  return null;
1166
1196
  }
1167
1197
  }
1198
+ checkMlxHealth() {
1199
+ return __awaiter(this, void 0, void 0, function* () {
1200
+ if (this.shuttingDown || this.mlxRecoveryInFlight)
1201
+ return;
1202
+ if (yield this.isMlxServerUp())
1203
+ return;
1204
+ const port = parseInt(process.env.MLX_EMBED_PORT || "8100", 10);
1205
+ const stalePid = this.getPortPid(port);
1206
+ if (!stalePid)
1207
+ return; // No process — let the next user-facing path spawn it.
1208
+ this.mlxRecoveryInFlight = true;
1209
+ try {
1210
+ console.log(`[daemon] MLX zombie detected on port ${port} (PID ${stalePid}) — killing and respawning`);
1211
+ yield (0, process_1.killProcess)(stalePid);
1212
+ yield new Promise((r) => setTimeout(r, 500));
1213
+ yield this.ensureMlxServer();
1214
+ }
1215
+ catch (err) {
1216
+ console.error(`[daemon] MLX recovery failed: ${err instanceof Error ? err.message : String(err)}`);
1217
+ }
1218
+ finally {
1219
+ this.mlxRecoveryInFlight = false;
1220
+ }
1221
+ });
1222
+ }
1168
1223
  ensureMlxServer(mlxModel) {
1169
1224
  return __awaiter(this, void 0, void 0, function* () {
1170
1225
  if (yield this.isMlxServerUp()) {
@@ -44,6 +44,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
45
  exports.writeProgress = writeProgress;
46
46
  exports.writeDone = writeDone;
47
+ exports.startHeartbeat = startHeartbeat;
47
48
  exports.handleCommand = handleCommand;
48
49
  const fs = __importStar(require("node:fs"));
49
50
  const path = __importStar(require("node:path"));
@@ -73,6 +74,26 @@ function writeDone(conn, data) {
73
74
  conn.write(`${JSON.stringify(Object.assign({ type: "done" }, data))}\n`);
74
75
  conn.end();
75
76
  }
77
+ const HEARTBEAT_INTERVAL_MS = 5000;
78
+ /**
79
+ * Emit periodic heartbeat lines so the client's watchdog timer resets even
80
+ * when no progress is reported (e.g., during a long DB flush or compaction).
81
+ * The client treats heartbeat lines as proof-of-life but not as progress.
82
+ *
83
+ * Returns a stop function; caller MUST call it before writeDone to avoid a
84
+ * stray heartbeat racing the done line.
85
+ */
86
+ function startHeartbeat(conn, intervalMs = HEARTBEAT_INTERVAL_MS) {
87
+ const timer = setInterval(() => {
88
+ if (!conn.writable) {
89
+ clearInterval(timer);
90
+ return;
91
+ }
92
+ conn.write(`${JSON.stringify({ type: "heartbeat", ts: Date.now() })}\n`);
93
+ }, intervalMs);
94
+ conn.once("close", () => clearInterval(timer));
95
+ return () => clearInterval(timer);
96
+ }
76
97
  /**
77
98
  * Handle a single IPC command.
78
99
  *
@@ -148,7 +169,7 @@ function handleCommand(daemon, cmd, conn) {
148
169
  limit: limitRaw,
149
170
  filters,
150
171
  pathPrefix: typeof cmd.pathPrefix === "string" ? cmd.pathPrefix : undefined,
151
- rerank: cmd.rerank !== false,
172
+ rerank: cmd.rerank === true,
152
173
  explain: cmd.explain === true,
153
174
  includeSkeletons: cmd.includeSkeletons === true,
154
175
  skeletonLimit: skeletonLimitRaw,
@@ -340,7 +340,11 @@ class Searcher {
340
340
  return __awaiter(this, void 0, void 0, function* () {
341
341
  var _a, _b, _c, _d, _e, _f, _g, _h;
342
342
  const finalLimit = top_k !== null && top_k !== void 0 ? top_k : 10;
343
- const doRerank = (_a = _search_options === null || _search_options === void 0 ? void 0 : _search_options.rerank) !== null && _a !== void 0 ? _a : true;
343
+ // ColBERT rerank is opt-in as of v0.17.1. On the 97-case eval it
344
+ // regresses MRR@10 by ~3% and doubles query latency; sweep across
345
+ // FUSED_WEIGHT ∈ {0,0.1,0.5,1,2} showed rerank scores dominate
346
+ // fused scores ~30:1 so blend tuning can't recover the loss.
347
+ const doRerank = (_a = _search_options === null || _search_options === void 0 ? void 0 : _search_options.rerank) !== null && _a !== void 0 ? _a : false;
344
348
  const explain = (_b = _search_options === null || _search_options === void 0 ? void 0 : _search_options.explain) !== null && _b !== void 0 ? _b : false;
345
349
  const searchIntent = intent || (0, intent_1.detectIntent)(query);
346
350
  const pool = (0, pool_1.getWorkerPool)();
@@ -219,6 +219,11 @@ function sendStreamingCommand(cmd, onProgress, opts) {
219
219
  resetTimer();
220
220
  onProgress(msg);
221
221
  }
222
+ else if (msg.type === "heartbeat") {
223
+ // Proof-of-life from a daemon doing slow non-emitting work
224
+ // (DB flush, compaction). Reset the watchdog; do not surface.
225
+ resetTimer();
226
+ }
222
227
  }
223
228
  catch (_a) {
224
229
  console.warn("[daemon-client] Malformed response line:", line.slice(0, 200));
@@ -43,6 +43,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
43
43
  };
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
45
  exports.WorkerOrchestrator = void 0;
46
+ exports.coerceColbertBytes = coerceColbertBytes;
46
47
  const fs = __importStar(require("node:fs"));
47
48
  const path = __importStar(require("node:path"));
48
49
  const transformers_1 = require("@huggingface/transformers");
@@ -58,6 +59,43 @@ const granite_1 = require("./embeddings/granite");
58
59
  const mlx_client_1 = require("./embeddings/mlx-client");
59
60
  const logger_1 = require("../utils/logger");
60
61
  let mlxFallbackWarned = false;
62
+ /**
63
+ * Normalize a colbert payload received over IPC into an Int8Array.
64
+ *
65
+ * Node `child_process.send` serializes payloads as JSON, which doesn't
66
+ * preserve TypedArrays. An Int8Array sent over IPC arrives as a plain
67
+ * object with numeric keys (`{0: byte, 1: byte, ...}`). Without explicitly
68
+ * handling that shape, the rerank pipeline silently no-ops (empty matrix
69
+ * → maxSim returns 0 → final ranking falls back to fusion-only). Caught
70
+ * 2026-05-25 when the eval harness showed rerank-on producing identical
71
+ * scores to rerank-off across 97 cases.
72
+ */
73
+ function coerceColbertBytes(col) {
74
+ if (col instanceof Int8Array)
75
+ return col;
76
+ if (Buffer.isBuffer(col)) {
77
+ return new Int8Array(col.buffer, col.byteOffset, col.byteLength);
78
+ }
79
+ if (Array.isArray(col))
80
+ return new Int8Array(col);
81
+ if (col && typeof col === "object") {
82
+ // {type:"Buffer", data:[...]} — Node Buffer.toJSON output
83
+ const asUnknown = col;
84
+ if (asUnknown.type === "Buffer" && Array.isArray(asUnknown.data)) {
85
+ return new Int8Array(asUnknown.data);
86
+ }
87
+ // {0: byte, 1: byte, ...} — Int8Array after IPC JSON serialization
88
+ const keys = Object.keys(col);
89
+ if (keys.length > 0 && keys.every((k) => /^\d+$/.test(k))) {
90
+ const arr = new Int8Array(keys.length);
91
+ for (const k of keys) {
92
+ arr[Number(k)] = col[k];
93
+ }
94
+ return arr;
95
+ }
96
+ }
97
+ return new Int8Array(0);
98
+ }
61
99
  const CACHE_DIR = config_1.PATHS.models;
62
100
  const LOG_MODELS = process.env.GMAX_DEBUG_MODELS === "1" ||
63
101
  process.env.GMAX_DEBUG_MODELS === "true";
@@ -330,28 +368,7 @@ class WorkerOrchestrator {
330
368
  yield this.ensureReady();
331
369
  const queryMatrix = input.query.map((row) => row instanceof Float32Array ? row : new Float32Array(row));
332
370
  return input.docs.map((doc) => {
333
- const col = doc.colbert;
334
- let colbert;
335
- if (col instanceof Int8Array) {
336
- colbert = col;
337
- }
338
- else if (Buffer.isBuffer(col)) {
339
- colbert = new Int8Array(col.buffer, col.byteOffset, col.byteLength);
340
- }
341
- else if (col &&
342
- typeof col === "object" &&
343
- "type" in col &&
344
- col.type === "Buffer" &&
345
- Array.isArray(col.data)) {
346
- // IPC serialization fallback (still copies, but unavoidable without SharedArrayBuffer)
347
- colbert = new Int8Array(col.data);
348
- }
349
- else if (Array.isArray(col)) {
350
- colbert = new Int8Array(col);
351
- }
352
- else {
353
- colbert = new Int8Array(0);
354
- }
371
+ const colbert = coerceColbertBytes(doc.colbert);
355
372
  const seqLen = Math.floor(colbert.length / input.colbertDim);
356
373
  const docMatrix = [];
357
374
  for (let i = 0; i < seqLen; i++) {
@@ -83,6 +83,13 @@ const TASK_TIMEOUT_MS = (() => {
83
83
  return 120000;
84
84
  })();
85
85
  const FORCE_KILL_GRACE_MS = 200;
86
+ // Longer grace for idle reaps: the worker isn't urgently in the way, and a
87
+ // graceful SIGTERM lets ONNX free ~1GB of model memory. But if SIGTERM is
88
+ // ignored (a worker burning 100% CPU inside a native ONNX matmul tight loop
89
+ // won't service signals — the 42h zombie we saw in v0.17.0 validation),
90
+ // escalate to SIGKILL. ~5s is well above ONNX teardown time but short
91
+ // enough that the reap loop self-heals within a minute.
92
+ const REAP_FORCE_KILL_GRACE_MS = 5000;
86
93
  class ProcessWorker {
87
94
  constructor(modulePath, execArgv, maxMemoryMb) {
88
95
  this.modulePath = modulePath;
@@ -90,6 +97,10 @@ class ProcessWorker {
90
97
  this.busy = false;
91
98
  this.pendingTaskId = null;
92
99
  this.lastBusyTime = Date.now();
100
+ // Set when the pool has cleaned up after this worker (via exit or error
101
+ // event). Guards against handleWorkerExit running twice when both events
102
+ // fire for the same crash.
103
+ this.cleanedUp = false;
93
104
  const memArgs = maxMemoryMb
94
105
  ? [`--max-old-space-size=${maxMemoryMb}`]
95
106
  : [];
@@ -166,18 +177,26 @@ class WorkerPool {
166
177
  worker.lastBusyTime = Date.now();
167
178
  }
168
179
  }
169
- handleWorkerExit(worker, code, signal) {
180
+ handleWorkerExit(worker, code, signal, reason = "exit", err) {
170
181
  var _a, _b, _c, _d;
182
+ // Crash paths can fire both 'error' and 'exit'. Either is sufficient
183
+ // to clean up; running this twice would double-respawn.
184
+ if (worker.cleanedUp)
185
+ return;
186
+ worker.cleanedUp = true;
171
187
  worker.busy = false;
172
188
  const failedTasks = Array.from(this.tasks.values()).filter((t) => t.worker === worker);
173
189
  for (const task of failedTasks) {
174
190
  this.clearTaskTimeout(task);
175
191
  const filePath = (_d = (_b = (_a = task.payload) === null || _a === void 0 ? void 0 : _a.path) !== null && _b !== void 0 ? _b : (_c = task.payload) === null || _c === void 0 ? void 0 : _c.absolutePath) !== null && _d !== void 0 ? _d : "unknown";
176
- (0, logger_1.debug)("pool", `exit killed task=${task.id} method=${task.method} file=${filePath}`);
177
- task.reject(new Error(`Worker exited unexpectedly${code ? ` (code ${code})` : ""}${signal ? ` signal ${signal}` : ""}`));
192
+ (0, logger_1.debug)("pool", `${reason} killed task=${task.id} method=${task.method} file=${filePath}`);
193
+ const exitDetail = err
194
+ ? `: ${err.message}`
195
+ : `${code ? ` (code ${code})` : ""}${signal ? ` signal ${signal}` : ""}`;
196
+ task.reject(new Error(`Worker ${reason === "error" ? "errored" : "exited unexpectedly"}${exitDetail}`));
178
197
  this.completeTask(task, null);
179
198
  }
180
- (0, logger_1.log)("pool", `Worker PID:${worker.child.pid} exited (code:${code} signal:${signal} pending=${failedTasks.length})`);
199
+ (0, logger_1.log)("pool", `Worker PID:${worker.child.pid} ${reason} (code:${code} signal:${signal}${err ? ` err:${err.message}` : ""} pending=${failedTasks.length})`);
181
200
  this.workers = this.workers.filter((w) => w !== worker);
182
201
  if (!this.destroyed) {
183
202
  // Only respawn if we have no workers left or there are pending tasks
@@ -243,9 +262,14 @@ class WorkerPool {
243
262
  this.consecutiveRespawns = 0;
244
263
  this.dispatch();
245
264
  };
246
- const onExit = (code, signal) => this.handleWorkerExit(worker, code, signal);
265
+ const onExit = (code, signal) => this.handleWorkerExit(worker, code, signal, "exit");
266
+ // 'error' fires when spawn fails, IPC send fails async, or the child
267
+ // can't be killed. Without this handler the worker stays in
268
+ // this.workers as a zombie that the next dispatch tries to send to.
269
+ const onError = (err) => this.handleWorkerExit(worker, null, null, "error", err);
247
270
  worker.child.on("message", onMessage);
248
271
  worker.child.on("exit", onExit);
272
+ worker.child.on("error", onError);
249
273
  this.workers.push(worker);
250
274
  }
251
275
  enqueue(method, payload, signal) {
@@ -326,8 +350,10 @@ class WorkerPool {
326
350
  (0, logger_1.log)("pool", `timeout task=${task.id} method=${task.method} file=${filePath} after ${TASK_TIMEOUT_MS}ms — killing worker PID:${worker.child.pid}`);
327
351
  this.completeTask(task, null);
328
352
  task.reject(new Error(`Worker task ${task.method} timed out after ${TASK_TIMEOUT_MS}ms on ${filePath}`));
353
+ worker.cleanedUp = true;
329
354
  worker.child.removeAllListeners("message");
330
355
  worker.child.removeAllListeners("exit");
356
+ worker.child.removeAllListeners("error");
331
357
  try {
332
358
  worker.child.kill("SIGKILL");
333
359
  }
@@ -421,13 +447,34 @@ class WorkerPool {
421
447
  .slice(0, reapCount)
422
448
  .forEach((w) => {
423
449
  (0, logger_1.log)("pool", `reap idle worker PID:${w.child.pid} (idle ${Math.round((now - w.lastBusyTime) / 1000)}s, ${this.workers.length - 1} remaining)`);
450
+ w.cleanedUp = true;
424
451
  w.child.removeAllListeners("message");
425
452
  w.child.removeAllListeners("exit");
453
+ w.child.removeAllListeners("error");
454
+ const pid = w.child.pid;
426
455
  try {
427
456
  w.child.kill("SIGTERM");
428
457
  }
429
458
  catch (_a) { }
430
459
  this.workers = this.workers.filter((x) => x !== w);
460
+ // SIGTERM is ignored by a worker stuck inside a native ONNX matmul
461
+ // tight loop. Escalate to SIGKILL if the process is still alive after
462
+ // the grace period. Rare; warn-level so it's visible if it fires.
463
+ if (pid !== undefined) {
464
+ setTimeout(() => {
465
+ try {
466
+ process.kill(pid, 0);
467
+ (0, logger_1.log)("pool", `reap escalation: SIGTERM ignored by PID:${pid}, sending SIGKILL`);
468
+ try {
469
+ process.kill(pid, "SIGKILL");
470
+ }
471
+ catch (_a) { }
472
+ }
473
+ catch (_b) {
474
+ // ESRCH — process already gone, nothing to do.
475
+ }
476
+ }, REAP_FORCE_KILL_GRACE_MS);
477
+ }
431
478
  });
432
479
  }
433
480
  destroy() {
@@ -449,8 +496,10 @@ class WorkerPool {
449
496
  this.taskQueue = [];
450
497
  this.priorityQueue = [];
451
498
  const killPromises = this.workers.map((w) => new Promise((resolve) => {
499
+ w.cleanedUp = true;
452
500
  w.child.removeAllListeners("message");
453
501
  w.child.removeAllListeners("exit");
502
+ w.child.removeAllListeners("error");
454
503
  w.child.once("exit", () => resolve());
455
504
  w.child.kill("SIGTERM");
456
505
  const force = setTimeout(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.16.10",
3
+ "version": "0.17.1",
4
4
  "author": "Robert Owens <78518764+reowens@users.noreply.github.com>",
5
5
  "homepage": "https://github.com/reowens/grepmax",
6
6
  "bugs": {
@@ -26,6 +26,8 @@
26
26
  "benchmark:index": "./run-benchmark.sh $HOME/gmax-benchmarks --index",
27
27
  "benchmark:agent": "npx tsx src/bench/benchmark-agent.ts",
28
28
  "benchmark:chart": "npx tsx src/bench/generate-benchmark-chart.ts",
29
+ "bench:recall": "npx tsx src/eval.ts",
30
+ "bench:recall:json": "GMAX_EVAL_JSON=1 npx tsx src/eval.ts",
29
31
  "format": "biome check --write .",
30
32
  "format:check": "biome check .",
31
33
  "lint": "biome lint .",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.16.10",
3
+ "version": "0.17.1",
4
4
  "description": "Semantic code search for Claude Code. Automatically indexes your project and provides intelligent search capabilities.",
5
5
  "author": {
6
6
  "name": "Robert Owens",