sweet-search 2.6.9 → 2.6.11

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.
package/README.md CHANGED
@@ -711,6 +711,21 @@ without you ever running a command.
711
711
  - **Resource-polite:** ticks are budgeted (≤50 files / ≤2 s CPU per tick), run CPU-only (the GPU is reserved for cold full indexing), and the interval auto-tunes from load average, churn, and backlog.
712
712
  - `sweet-search reconcile status` / `reconcile inspect <path>` explain exactly what the daemon thinks and why. Opt out any time with `SWEET_SEARCH_RECONCILE_V2=0`.
713
713
 
714
+ **Memory controls.** The resident daemons show up in `ps` / Activity Monitor as
715
+ `sweet-search-maintainer` and `sweet-search-daemon`. A maintainer's steady state is
716
+ roughly 2–3 GB (embedding + late-interaction models stay loaded so ticks are fast),
717
+ and four independent mechanisms keep that bounded:
718
+
719
+ | Mechanism | Default | Override |
720
+ |-----------|---------|----------|
721
+ | Background ORT profile (arena-off + parked threads) in the maintainer | on | `SWEET_SEARCH_ORT_BACKGROUND=0` |
722
+ | Per-process recycle ceiling — the maintainer finishes its tick, exits cleanly, and respawns fresh on the next edit when its RSS crosses the line | clamp(25 % of RAM, 4 GiB, 8 GiB) | `SWEET_SEARCH_MAINTAINER_RSS_MAX_MB` (0 disables) |
723
+ | Idle TTL — unattended daemons shut down and respawn on demand | tier-aware | `SWEET_SEARCH_MAINTAINER_IDLE_TTL_MS` / `SWEET_SEARCH_DAEMON_IDLE_TTL_MS` |
724
+ | Fleet RSS budget — across all repos' daemons, the longest-idle one is evicted when the sum crosses a RAM-scaled budget | tier-aware | `SWEET_SEARCH_RSS_BUDGET_FRACTION` |
725
+
726
+ A recycle or eviction never touches index state: every tick publishes atomically
727
+ before the process exits, and the next edit (or query) respawns a fresh daemon.
728
+
714
729
  </details>
715
730
 
716
731
  ## 🦀 The Native Engine Room
package/core/cli.js CHANGED
@@ -94,6 +94,13 @@ if (args[0] === 'init') {
94
94
  await runIndexer();
95
95
  }
96
96
  } else if (args[0] === '--serve' || args[0] === '--stop') {
97
+ // Name the resident daemon so ps/Activity Monitor shows what it is instead
98
+ // of an anonymous `node`. Entrypoint-scoped: library consumers of the
99
+ // search modules are never retitled. Liveness checks are lock/pid-based,
100
+ // never command-line matching, so this is cosmetic-only.
101
+ if (args[0] === '--serve') {
102
+ try { process.title = 'sweet-search-daemon'; } catch { /* best-effort */ }
103
+ }
97
104
  // Warm search server lifecycle is implemented in JS.
98
105
  const { runCli } = await import('./search/index.js');
99
106
  await runCli(args);
@@ -249,6 +249,18 @@ const TYPE_BOOST = {
249
249
  struct: 1.2,
250
250
  };
251
251
 
252
+ /**
253
+ * Fetch int8 vectors for many ids with ONE stale-bitmap snapshot when the
254
+ * index supports it (BinaryHNSWIndex.getInt8VectorsForIds); falls back to
255
+ * per-id lookups for index doubles that only expose getInt8Vector.
256
+ */
257
+ function batchInt8Vectors(hnswIndex, ids) {
258
+ if (typeof hnswIndex.getInt8VectorsForIds === 'function') {
259
+ return hnswIndex.getInt8VectorsForIds(ids);
260
+ }
261
+ return ids.map((id) => hnswIndex.getInt8Vector(id));
262
+ }
263
+
252
264
  function clampSemanticWeight(value) {
253
265
  if (!Number.isFinite(value)) return 0.4;
254
266
  return Math.max(0, Math.min(1, value));
@@ -614,21 +626,27 @@ export function expandSecondHop(db, seedIds, expanded, edgeTypes, options = {})
614
626
  }
615
627
 
616
628
  const excluded = new Set([...seedIds, ...expanded.keys()]);
617
- const candidates = [];
629
+ const eligible = [];
618
630
  for (const rel of hop2Forward) {
619
631
  if (!edgeTypes.has(rel.type) || excluded.has(rel.target_id)) continue;
620
632
 
621
633
  const hop1Entry = expanded.get(rel.source_id);
622
634
  const hop1Score = hop1Entry?.score ?? 1; // identity: preserves old edgePriority × weight
623
635
  const graphScore = hop1Score * (EDGE_PRIORITY[rel.type] || 1) * (rel.weight || 1.0);
636
+ eligible.push({ rel, graphScore });
637
+ }
638
+ // Batch int8 lookup: one stale-bitmap snapshot for all targets instead of
639
+ // one stat per target.
640
+ const eligibleInt8 = batchInt8Vectors(hnswIndex, eligible.map((e) => e.rel.target_id));
641
+ const candidates = eligible.map((e, i) => {
642
+ const entityInt8 = eligibleInt8[i];
624
643
  let normSim = null;
625
- const entityInt8 = hnswIndex.getInt8Vector(rel.target_id);
626
644
  if (entityInt8) {
627
645
  const cosSim = cosineSimilarity(queryInt8, entityInt8);
628
646
  normSim = (cosSim + 1) / 2;
629
647
  }
630
- candidates.push({ rel, graphScore, normSim });
631
- }
648
+ return { rel: e.rel, graphScore: e.graphScore, normSim };
649
+ });
632
650
 
633
651
  if (candidates.length === 0) return;
634
652
  const normalizedGraphScores = normalizeMinMax(candidates.map(c => c.graphScore));
@@ -729,6 +747,25 @@ export function expandSecondHopAdaptive(db, seedIds, hop1Expanded, edgeTypes, op
729
747
  const vectorCache = semanticEnabled ? new Map() : null;
730
748
  const scoredCandidates = [];
731
749
 
750
+ // Pre-seed the vector cache with ONE batched int8 fetch (single
751
+ // stale-bitmap snapshot) so the scoring loop below never does per-target
752
+ // lookups.
753
+ if (semanticEnabled) {
754
+ const uniqueTargets = [];
755
+ for (const c of rawCandidates) {
756
+ if (!edgeTypes.has(c.type) || excluded.has(c.target_id)) continue;
757
+ if (vectorCache.has(c.target_id)) continue;
758
+ vectorCache.set(c.target_id, undefined);
759
+ uniqueTargets.push(c.target_id);
760
+ }
761
+ if (uniqueTargets.length > 0) {
762
+ const fetched = batchInt8Vectors(hnswIndex, uniqueTargets);
763
+ for (let i = 0; i < uniqueTargets.length; i++) {
764
+ vectorCache.set(uniqueTargets[i], fetched[i]);
765
+ }
766
+ }
767
+ }
768
+
732
769
  for (const c of rawCandidates) {
733
770
  if (!edgeTypes.has(c.type) || excluded.has(c.target_id)) continue;
734
771
 
@@ -743,6 +780,7 @@ export function expandSecondHopAdaptive(db, seedIds, hop1Expanded, edgeTypes, op
743
780
  let normSim = null;
744
781
  if (semanticEnabled) {
745
782
  if (!vectorCache.has(c.target_id)) {
783
+ // Miss fallback only — the pre-seed pass below batch-fills the cache.
746
784
  vectorCache.set(c.target_id, hnswIndex.getInt8Vector(c.target_id));
747
785
  }
748
786
  const entityInt8 = vectorCache.get(c.target_id);
@@ -929,12 +967,16 @@ export function rerankExpanded(expandedResults, seedResults, options = {}) {
929
967
  }
930
968
  } else {
931
969
  const normalizedGraphScores = normalizeMinMax(baseScores);
970
+ // Batch int8 lookup: one stale-bitmap snapshot for the whole result set.
971
+ const entityInt8s = batchInt8Vectors(
972
+ hnswIndex,
973
+ expandedResults.map((er) => er.entity_id || er.id)
974
+ );
932
975
  for (let i = 0; i < expandedResults.length; i++) {
933
976
  const er = expandedResults[i];
934
977
  const normGraph = normalizedGraphScores[i];
935
978
  let rerankScore = normGraph;
936
- const entityId = er.entity_id || er.id;
937
- const entityInt8 = hnswIndex.getInt8Vector(entityId);
979
+ const entityInt8 = entityInt8s[i];
938
980
  if (entityInt8) {
939
981
  const cosSim = cosineSimilarity(queryInt8, entityInt8);
940
982
  const normSim = (cosSim + 1) / 2;
@@ -117,6 +117,7 @@ export async function applyLateInteractionDelta({
117
117
  liEncoder,
118
118
  pickLiInput,
119
119
  onProgress = null,
120
+ readerCache = null,
120
121
  }) {
121
122
  const progress = typeof onProgress === 'function'
122
123
  ? (phase) => { onProgress(phase); }
@@ -128,11 +129,35 @@ export async function applyLateInteractionDelta({
128
129
  const encode = liEncoder || ((texts) => encodeDocumentsCpu(texts));
129
130
  const existing = fs.existsSync(indexPath);
130
131
  const segmented = existing ? segmentedState(indexPath) : null;
131
- const index = new LateInteractionIndex({ indexPath, loadExisting: true });
132
- await index.init();
133
- progress('li:init');
132
+
133
+ // E.1-LI reader cache: reuse the loaded read view across the tick's files.
134
+ // Only the SEGMENTED path is cacheable — everything the reader serves there
135
+ // (config fields, positions/counts of pre-tick docs) is immutable within a
136
+ // tick: per-file ops only reference the file's own pre-tick docs, appends
137
+ // go through appendGrowingSegment (which re-reads the manifest from disk),
138
+ // and tombstone sidecar state is opened fresh per call. The legacy path
139
+ // MUTATES the loaded index (rewriteLegacyIndex), so it always loads fresh
140
+ // and drops any cached reader.
141
+ const cacheable = !!(readerCache && segmented);
142
+ let index;
143
+ if (cacheable && readerCache.index && readerCache.key === indexPath) {
144
+ index = readerCache.index;
145
+ progress('li:init-cached');
146
+ } else {
147
+ index = new LateInteractionIndex({ indexPath, loadExisting: true });
148
+ await index.init();
149
+ progress('li:init');
150
+ if (cacheable) {
151
+ readerCache.key = indexPath;
152
+ readerCache.index = index;
153
+ }
154
+ }
134
155
 
135
156
  if (existing && !segmented) {
157
+ if (readerCache) {
158
+ readerCache.key = null;
159
+ readerCache.index = null;
160
+ }
136
161
  return rewriteLegacyIndex(index, ops, encode, pickLiInput, progress);
137
162
  }
138
163
 
@@ -60,19 +60,56 @@ export function insertRelationships(db, relationships, liveIdFor, epoch) {
60
60
  }
61
61
  }
62
62
 
63
- export function markBinaryStale(index, id) {
64
- const idx = index.idToIndex.get(id);
65
- if (idx == null) return false;
63
+ /**
64
+ * Batched stale marking. The per-id `markBinaryStale` loads, mutates, and
65
+ * fsyncs the bitmap file once PER RETIRED ID — O(retires × bitmap size) with
66
+ * an fsync each. A batch loads the bitmap once (lazily, on the first mark),
67
+ * applies every mark in memory with the exact per-op semantics (idToIndex /
68
+ * int8Vectors pruned immediately, reader cache invalidated), and persists
69
+ * once in flush(). End state on disk is identical to N sequential
70
+ * markBinaryStale calls.
71
+ *
72
+ * Crash window: marks staged between flush()es are lost on crash, which the
73
+ * reconciler's persist-before-advance gate already handles — an unflushed
74
+ * tick replays its ops.
75
+ */
76
+ export function createStaleBatch(index) {
66
77
  const stalePath = index.stalePath || `${index.indexPath}.stale.bin`;
67
78
  let bitmap = null;
68
- try { bitmap = loadBitmap(stalePath); } catch {}
69
- bitmap = bitmap ? resizeBitmap(bitmap, Math.max(idx + 1, index.vectors.length, 1)) : createBitmap(Math.max(idx + 1, index.vectors.length, 1));
70
- setBit(bitmap, idx);
71
- saveBitmap(stalePath, bitmap);
72
- index.idToIndex.delete(id);
73
- index.int8Vectors.delete(id);
74
- index._staleBitmapCache = null;
75
- return true;
79
+ let loaded = false;
80
+ let dirty = false;
81
+ return {
82
+ markStale(id) {
83
+ const idx = index.idToIndex.get(id);
84
+ if (idx == null) return false;
85
+ if (!loaded) {
86
+ try { bitmap = loadBitmap(stalePath); } catch {}
87
+ loaded = true;
88
+ }
89
+ bitmap = bitmap
90
+ ? resizeBitmap(bitmap, Math.max(idx + 1, index.vectors.length, 1))
91
+ : createBitmap(Math.max(idx + 1, index.vectors.length, 1));
92
+ setBit(bitmap, idx);
93
+ dirty = true;
94
+ index.idToIndex.delete(id);
95
+ index.int8Vectors.delete(id);
96
+ index._staleBitmapCache = null;
97
+ return true;
98
+ },
99
+ flush() {
100
+ if (!dirty) return false;
101
+ saveBitmap(stalePath, bitmap);
102
+ dirty = false;
103
+ return true;
104
+ },
105
+ };
106
+ }
107
+
108
+ export function markBinaryStale(index, id) {
109
+ const batch = createStaleBatch(index);
110
+ const marked = batch.markStale(id);
111
+ batch.flush();
112
+ return marked;
76
113
  }
77
114
 
78
115
  /**
@@ -13,7 +13,7 @@ import { readManifest, writeManifest } from '../infrastructure/manifest.mjs';
13
13
  import { annotateChunksForDelta, snapshotFileRows, diffChunks, applyDiff } from '../infrastructure/vector-delta-writer.mjs';
14
14
  import { appendDeltaRecord, FALLBACK_WEIGHTS_ID, fileIdFor, listDeltaSegments } from '../infrastructure/sparse-gram-delta.mjs';
15
15
  import { fts5Merge, fts5MergeBudgetPages } from '../infrastructure/sqlite-fts5.mjs';
16
- import { insertEntity, insertRelationships, markBinaryStale, maintainFloatStore, flushFloatStore } from './production-reconciler-helpers.mjs';
16
+ import { insertEntity, insertRelationships, markBinaryStale, createStaleBatch, maintainFloatStore, flushFloatStore } from './production-reconciler-helpers.mjs';
17
17
  import {
18
18
  chunkCutoffEnabled,
19
19
  computeCutoffSignature,
@@ -419,7 +419,7 @@ class ProductionReconcileAdapter {
419
419
  applyGraphDelta: (file, hashes, epoch, ctx) => this.applyGraphDelta(file, hashes, epoch, ctx),
420
420
  applyVectorDelta: (file, chunks, hashes, epoch, ctx) => this.applyVectorDelta(file, chunks, hashes, epoch, ctx),
421
421
  applyBinaryHNSWDelta: (file, ops, epoch, ctx) => this.applyBinaryHNSWDelta(file, ops, epoch, ctx),
422
- applyLIDelta: (file, ops, epoch) => this.applyLIDelta(file, ops, epoch),
422
+ applyLIDelta: (file, ops, epoch, ctx) => this.applyLIDelta(file, ops, ctx),
423
423
  applySparseGramDelta: (file, ops, epoch) => this.applySparseGramDelta(file, ops, epoch),
424
424
  readMaintenanceState: () => this.readMaintenanceState(),
425
425
  scheduleMaintenance: (job) => enqueueMaintenanceJob(this.stateDir, job),
@@ -1110,9 +1110,12 @@ class ProductionReconcileAdapter {
1110
1110
  if (ctx?.index) {
1111
1111
  const index = ctx.index;
1112
1112
  let append = 0; let tombstone = 0;
1113
+ // One bitmap load + one save+fsync per file's ops instead of per
1114
+ // retired id; marks apply in op order with identical semantics.
1115
+ const staleBatch = createStaleBatch(index);
1113
1116
  for (const op of ops) {
1114
1117
  if (op.retireId) {
1115
- if (markBinaryStale(index, op.retireId)) tombstone += 1;
1118
+ if (staleBatch.markStale(op.retireId)) tombstone += 1;
1116
1119
  ctx.floatRemoveIds.push(op.retireId);
1117
1120
  }
1118
1121
  if (op.addId && op.embedding) {
@@ -1122,6 +1125,7 @@ class ProductionReconcileAdapter {
1122
1125
  append += 1;
1123
1126
  }
1124
1127
  }
1128
+ staleBatch.flush();
1125
1129
  ctx.tombstone += tombstone;
1126
1130
  // append is committed to ctx.append in finalize after the sorted inserts.
1127
1131
  ctx.append += append;
@@ -1137,9 +1141,13 @@ class ProductionReconcileAdapter {
1137
1141
  let append = 0; let tombstone = 0;
1138
1142
  const floatUpserts = [];
1139
1143
  const floatRemoveIds = [];
1144
+ // One bitmap load + one save+fsync for the whole op list instead of per
1145
+ // retired id. Marks still apply inline in op order (adds never consult
1146
+ // the bitmap), so retire-then-readd sequences behave exactly as before.
1147
+ const staleBatch = createStaleBatch(index);
1140
1148
  for (const op of ops) {
1141
1149
  if (op.retireId) {
1142
- if (markBinaryStale(index, op.retireId)) tombstone += 1;
1150
+ if (staleBatch.markStale(op.retireId)) tombstone += 1;
1143
1151
  floatRemoveIds.push(op.retireId);
1144
1152
  }
1145
1153
  if (op.addId && op.embedding) {
@@ -1150,6 +1158,7 @@ class ProductionReconcileAdapter {
1150
1158
  }
1151
1159
  if ((append + tombstone) > 0 && (append + tombstone) % 100 === 0) this.progress('production:binary-hnsw-loop');
1152
1160
  }
1161
+ staleBatch.flush();
1153
1162
  await index.save(indexPath);
1154
1163
  this.progress('production:binary-hnsw-saved');
1155
1164
  await maintainFloatStore(indexPath, { upserts: floatUpserts, removeIds: floatRemoveIds, binaryVectorsBefore, dimension: this.modelInfo.hnswDimension });
@@ -1157,7 +1166,7 @@ class ProductionReconcileAdapter {
1157
1166
  return { ops: { binary_hnsw_append: append, binary_hnsw_tombstone: tombstone }, manifest: { path: 'codebase-binary-hnsw.idx' } };
1158
1167
  }
1159
1168
 
1160
- async applyLIDelta(file, ops) {
1169
+ async applyLIDelta(file, ops, ctx = null) {
1161
1170
  if (!Array.isArray(ops) || ops.length === 0) return { ops: { li_segment_append: 0, li_tombstone: 0 } };
1162
1171
  // LI generated-content parity: full indexing's buildLateInteractionIndex runs
1163
1172
  // applyIndexingChunkPolicy so @generated / config-excluded files never reach
@@ -1170,12 +1179,21 @@ class ProductionReconcileAdapter {
1170
1179
  ? ops.filter((op) => !(op.addId && op.chunk))
1171
1180
  : ops;
1172
1181
  const { applyLateInteractionDelta } = await import('./production-li-delta.mjs');
1182
+ // E.1-LI: tick-scoped READER cache. The per-file delta previously paid a
1183
+ // full loadExisting init (segment manifest + doc positions over a multi-
1184
+ // hundred-MB index) once per file; within one tick the reader view it
1185
+ // needs is stable (per-file ops only reference the file's own pre-tick
1186
+ // docs, appendGrowingSegment re-reads the manifest from disk, and segment
1187
+ // compaction only runs in maintenance after the tick). Null ctx (per-file
1188
+ // path) ⇒ fresh load per file, exactly as before.
1189
+ const readerCache = ctx ? (ctx._liReaderCache ??= { key: null, index: null }) : null;
1173
1190
  const { appended, tombstone } = await applyLateInteractionDelta({
1174
1191
  indexPath: path.join(this.stateDir, 'codebase-late-interaction.db'),
1175
1192
  ops: filteredOps,
1176
1193
  liEncoder: this.liEncoder,
1177
1194
  pickLiInput,
1178
1195
  onProgress: () => this.progress('production:li-delta'),
1196
+ readerCache,
1179
1197
  });
1180
1198
  return { ops: { li_segment_append: appended, li_tombstone: tombstone }, manifest: { path: 'codebase-late-interaction.db', segments: 'codebase-late-interaction.db.segments/manifest.json' } };
1181
1199
  }
@@ -461,7 +461,7 @@ export class Reconciler {
461
461
  if (bin?.ops?.binary_hnsw_append != null) ops.binary_hnsw_append = bin.ops.binary_hnsw_append;
462
462
  if (bin?.ops?.binary_hnsw_tombstone != null) ops.binary_hnsw_tombstone = bin.ops.binary_hnsw_tombstone;
463
463
  this.progress('reconciler:li:start');
464
- const li = await this.adapters.applyLIDelta?.(file, vec?.tokenOps ?? [], epoch);
464
+ const li = await this.adapters.applyLIDelta?.(file, vec?.tokenOps ?? [], epoch, tickCtx);
465
465
  this.progress('reconciler:li:done');
466
466
  collectManifestTier(manifestTiers, 'lateInteraction', li);
467
467
  if (li?.ops?.li_segment_append != null) ops.li_segment_append = li.ops.li_segment_append;
@@ -52,7 +52,7 @@ export const ARTIFACT_THRESHOLDS = {
52
52
  stateFile: '.sweet-search/artifact-rebuild-state.json',
53
53
  };
54
54
 
55
- import { BinaryHNSWIndex } from '../vector-store/binary-hnsw-index.js';
55
+ import { BinaryHNSWIndex, int8SidecarCount } from '../vector-store/binary-hnsw-index.js';
56
56
  import { truncateForHNSW, fisherYatesShuffle, normalizedFloatToInt8, floatToBinary } from '../infrastructure/quantization.js';
57
57
  import { FloatVectorStore, getFloatStorePath } from '../vector-store/float-vector-store.js';
58
58
 
@@ -828,8 +828,10 @@ export async function getArtifactStats() {
828
828
  if (existsSync(int8SidecarPath)) {
829
829
  try {
830
830
  const fileStats = await fs.stat(int8SidecarPath);
831
- const int8Data = JSON.parse(await fs.readFile(int8SidecarPath, 'utf-8'));
832
- const count = Object.keys(int8Data).length;
831
+ // Header-only count (NDJSON v2 sidecar; v1 back-compat) — never
832
+ // JSON.parse the whole file, which exceeds V8's string ceiling on
833
+ // large indexes.
834
+ const count = await int8SidecarCount(int8SidecarPath);
833
835
 
834
836
  stats.int8Sidecar = {
835
837
  exists: true,
@@ -1267,12 +1267,14 @@ async function runReconcileV2Main({ runOnce, merkleOnce }) {
1267
1267
  // Best-effort — never block startup on the embedding module.
1268
1268
  if (process.env.SWEET_SEARCH_ORT_BACKGROUND !== '0') {
1269
1269
  try {
1270
- const [{ configureLocalModelRuntime }, { backgroundIntraOpThreads }] = await Promise.all([
1271
- import('../embedding/embedding-local-model.js'),
1272
- import('../infrastructure/onnx-session-utils.js'),
1273
- ]);
1274
- configureLocalModelRuntime({ intraOpThreads: backgroundIntraOpThreads(), background: true });
1275
- log('INFO', 'ORT background profile armed for maintainer daemon (force_spinning_stop + arena-off + bg threads)');
1270
+ // Arms BOTH resident CPU sessions (dense + late-interaction) — the LI
1271
+ // session previously stayed on the foreground profile (arena ON +
1272
+ // allow_spinning) inside the daemon; measured consequence: 354 × 128MB
1273
+ // arena extensions (~34GB RSS) after one edit-heavy day plus ~40% idle
1274
+ // CPU from spinning workers.
1275
+ const { armBackgroundOrtProfiles } = await import('./model-pool.js');
1276
+ armBackgroundOrtProfiles();
1277
+ log('INFO', 'ORT background profile armed for maintainer daemon (dense + LI: force_spinning_stop + arena-off + bg threads)');
1276
1278
  } catch (err) {
1277
1279
  log('WARN', `ORT background profile arming failed (continuing on foreground profile): ${err?.message ?? err}`);
1278
1280
  }
@@ -1314,15 +1316,30 @@ async function runReconcileV2Main({ runOnce, merkleOnce }) {
1314
1316
  // system-RAM tier auto-enables a cap (small-RAM hosts). Best-effort, guarded
1315
1317
  // so a missing module is a no-op.
1316
1318
  let rssRegistration = null;
1319
+ // D.5 per-process recycle ceiling: fleet-budget eviction only sheds the
1320
+ // longest-IDLE daemon, so an active maintainer needs its own line. Resolved
1321
+ // once; checked at tick boundaries below. 0 ⇒ disabled.
1322
+ let rssCeiling = { ceilingBytes: 0, minUptimeMs: 0, shouldRecycleForRss: null };
1317
1323
  try {
1318
1324
  const mod = await import('./rss-budget.mjs');
1319
1325
  if (typeof mod.isEnabled === 'function' && mod.isEnabled()
1320
1326
  && typeof mod.registerDaemon === 'function') {
1321
1327
  rssRegistration = await mod.registerDaemon({ pid: process.pid, stateDir: ctx.stateDir, kind: 'maintainer' });
1322
1328
  }
1329
+ if (typeof mod.maintainerRssCeilingBytes === 'function') {
1330
+ rssCeiling = {
1331
+ ceilingBytes: mod.maintainerRssCeilingBytes(),
1332
+ minUptimeMs: mod.maintainerRssMinUptimeMs(),
1333
+ shouldRecycleForRss: mod.shouldRecycleForRss,
1334
+ };
1335
+ if (rssCeiling.ceilingBytes > 0) {
1336
+ log('INFO', `Maintainer RSS recycle ceiling armed: ${(rssCeiling.ceilingBytes / 1048576).toFixed(0)}MB (min uptime ${rssCeiling.minUptimeMs}ms; override SWEET_SEARCH_MAINTAINER_RSS_MAX_MB)`);
1337
+ }
1338
+ }
1323
1339
  } catch (err) {
1324
1340
  log('WARN', `RSS-budget registry unavailable (no soft cap on this daemon): ${err?.message ?? err}`);
1325
1341
  }
1342
+ let completedTicks = 0;
1326
1343
 
1327
1344
  // D.1 idle-TTL: an unattended maintainer self-shuts-down after the configured
1328
1345
  // wall-clock idle budget so N resident model-loaded daemons collapse to 1–2
@@ -1432,6 +1449,23 @@ async function runReconcileV2Main({ runOnce, merkleOnce }) {
1432
1449
  maintenanceBacklog: backlog,
1433
1450
  skipped: tickCounters?.skipped === true,
1434
1451
  });
1452
+ // D.5: per-process RSS recycle check — tick boundary only (the tick
1453
+ // above has published; a recycle here can never tear an artifact).
1454
+ completedTicks += 1;
1455
+ if (rssCeiling.ceilingBytes > 0 && typeof rssCeiling.shouldRecycleForRss === 'function') {
1456
+ const rssNow = process.memoryUsage.rss();
1457
+ const verdict = rssCeiling.shouldRecycleForRss({
1458
+ rssBytes: rssNow,
1459
+ ceilingBytes: rssCeiling.ceilingBytes,
1460
+ uptimeMs: process.uptime() * 1000,
1461
+ minUptimeMs: rssCeiling.minUptimeMs,
1462
+ ticksCompleted: completedTicks,
1463
+ });
1464
+ if (verdict.recycle) {
1465
+ log('WARN', `Maintainer RSS ${(rssNow / 1048576).toFixed(0)}MB exceeds recycle ceiling ${(rssCeiling.ceilingBytes / 1048576).toFixed(0)}MB; requesting clean shutdown for on-demand respawn. If this repeats right after startup, raise SWEET_SEARCH_MAINTAINER_RSS_MAX_MB.`);
1466
+ shutdownRequested = true;
1467
+ }
1468
+ }
1435
1469
  } catch (err) {
1436
1470
  if (err instanceof MaintainerLifecycleAbort) {
1437
1471
  log('WARN', `Reconcile v2 lifecycle abort: ${err.message}. Cleaning up cancellation-orphaned temps and exiting cleanly.`);
@@ -1457,6 +1491,10 @@ async function runReconcileV2Main({ runOnce, merkleOnce }) {
1457
1491
  log('ERROR', `Reconcile v2 tick failed: ${err?.message ?? err}`);
1458
1492
  }
1459
1493
  }
1494
+ // Skip the sleep when a shutdown was requested during this iteration
1495
+ // (idle-TTL, signal, or D.5 RSS recycle) — exit promptly instead of
1496
+ // waiting out one more interval.
1497
+ if (shutdownRequested) break;
1460
1498
  await sleepWithProgress(intervalMs, lock.lockFile, {
1461
1499
  // G6 early-wake: break the sleep the instant the watcher reports new
1462
1500
  // events so a fresh edit is reconciled without waiting out the interval.
@@ -2482,6 +2520,11 @@ async function main() {
2482
2520
  const dryRun = process.argv.includes('--dry-run');
2483
2521
  const merkleOnce = process.argv.includes('--merkle-once');
2484
2522
 
2523
+ // Name the resident daemon so ps/Activity Monitor shows what it is instead
2524
+ // of an anonymous `node`. Cosmetic-only: all liveness/takeover checks are
2525
+ // lock/pid-based, never command-line matching.
2526
+ try { process.title = 'sweet-search-maintainer'; } catch { /* best-effort */ }
2527
+
2485
2528
  // A.1 (Tier-1, UNGATED): demote the maintainer daemon to low OS priority so
2486
2529
  // the foreground (editor / git / shell) never feels the background indexer's
2487
2530
  // CPU. Identical index output — only *when* CPU is granted changes. Covers
@@ -29,12 +29,15 @@ import {
29
29
  getLocalPipeline,
30
30
  callLocalModelCpu,
31
31
  unloadLocalModel,
32
+ configureLocalModelRuntime,
32
33
  } from '../embedding/embedding-local-model.js';
33
34
  import {
34
35
  getLateInteractionPipeline,
35
36
  encodeDocumentsCpu,
36
37
  unloadLateInteractionModel,
38
+ configureLateInteractionRuntime,
37
39
  } from '../ranking/late-interaction-model.js';
40
+ import { backgroundIntraOpThreads } from '../infrastructure/onnx-session-utils.js';
38
41
 
39
42
  /**
40
43
  * Small-changeset threshold. Incremental runs with fewer files than this use
@@ -69,6 +72,29 @@ export function selectAcceleratorDeviceKind(pref) {
69
72
  return null;
70
73
  }
71
74
 
75
+ /**
76
+ * G3: arm the BACKGROUND/maintainer ORT profile for BOTH resident CPU
77
+ * sessions — dense (embedding-local-model) and late-interaction. The profile
78
+ * (force_spinning_stop + arena-off + 2–4 intra-op threads) prevents the two
79
+ * resident-daemon pathologies: idle spinning workers (~a core of idle CPU)
80
+ * and monotonic RSS from ORT arena extension (#25325; measured 354 × 128MB
81
+ * ≈ 34GB after one edit-heavy day when the LI session was left on the
82
+ * foreground profile). MUST run before the first encode — both session
83
+ * singletons are built once; configuring after is a silent no-op.
84
+ *
85
+ * Lives here (model-pool owns indexing-side model lifecycle) so daemon
86
+ * entrypoints don't need their own cross-context imports of the two model
87
+ * modules.
88
+ *
89
+ * @returns {{armed: boolean}}
90
+ */
91
+ export function armBackgroundOrtProfiles() {
92
+ const intraOpThreads = backgroundIntraOpThreads();
93
+ configureLocalModelRuntime({ intraOpThreads, background: true });
94
+ configureLateInteractionRuntime({ intraOpThreads, background: true });
95
+ return { armed: true };
96
+ }
97
+
72
98
  /**
73
99
  * Whether this process can use an inference accelerator (Metal / CoreML
74
100
  * cascade / CUDA) for indexing. Requires both accelerator-capable hardware
@@ -112,6 +112,71 @@ export function isOverBudget(totalRssBytes, budgetBytesValue) {
112
112
  );
113
113
  }
114
114
 
115
+ // =============================================================================
116
+ // D.5: per-process maintainer RSS recycle ceiling
117
+ // =============================================================================
118
+ //
119
+ // The fleet budget above evicts the longest-IDLE daemon, so a single fat
120
+ // ACTIVE maintainer can legitimately ride up to the whole fleet budget
121
+ // (~76 GB on a 128 GB host) — measured in the wild when a session-config
122
+ // regression left the LI ORT session on the foreground arena profile
123
+ // (354 × 128MB arena extensions ≈ 34 GB RSS). This ceiling is the
124
+ // per-process backstop: when the maintainer's OWN rss crosses the line at a
125
+ // tick boundary, it requests the same graceful shutdown the idle-TTL uses
126
+ // (finish tick, publish, release lock, exit). The O_EXCL launch trigger
127
+ // respawns a fresh few-hundred-MB process on the next dirty event, so the
128
+ // index is unchanged — exit-to-reclaim is already the sanctioned way to
129
+ // return ORT memory (#25325).
130
+ //
131
+ // Guards against restart-thrash:
132
+ // - at least one COMPLETED tick (never abort startup work),
133
+ // - minimum uptime (default 10 min ⇒ worst case 6 recycles/hour even when
134
+ // the ceiling is misconfigured below the resident floor — visible via
135
+ // the per-recycle WARN, which names the env knob to raise).
136
+ //
137
+ // Measured anchors (2026-07-03, arena-off profile): steady floor ~2.7-2.9 GB
138
+ // on a mid-size repo, encode-active peaks ~4.7 GB — hence the 4 GiB default
139
+ // floor and 8 GiB cap.
140
+
141
+ const MAINTAINER_RSS_CEILING_FRACTION = 0.25;
142
+ const MAINTAINER_RSS_CEILING_MIN_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB
143
+ const MAINTAINER_RSS_CEILING_MAX_BYTES = 8 * 1024 * 1024 * 1024; // 8 GiB
144
+ const MAINTAINER_RSS_MIN_UPTIME_MS_DEFAULT = 10 * 60 * 1000; // 10 min
145
+
146
+ /**
147
+ * Resolve the per-process maintainer RSS ceiling in bytes.
148
+ * `SWEET_SEARCH_MAINTAINER_RSS_MAX_MB` set: explicit MB value; 0/garbage
149
+ * disables (returns 0). Unset: clamp(25% of RAM, 4 GiB, 8 GiB).
150
+ */
151
+ export function maintainerRssCeilingBytes(env = process.env, totalMem = os.totalmem()) {
152
+ const raw = env.SWEET_SEARCH_MAINTAINER_RSS_MAX_MB;
153
+ if (raw != null && raw !== '') {
154
+ const mb = Number(raw);
155
+ if (!Number.isFinite(mb) || mb <= 0) return 0;
156
+ return Math.floor(mb * 1024 * 1024);
157
+ }
158
+ const scaled = Math.floor(totalMem * MAINTAINER_RSS_CEILING_FRACTION);
159
+ return Math.min(MAINTAINER_RSS_CEILING_MAX_BYTES, Math.max(MAINTAINER_RSS_CEILING_MIN_BYTES, scaled));
160
+ }
161
+
162
+ /** Minimum uptime before an RSS recycle may fire. */
163
+ export function maintainerRssMinUptimeMs(env = process.env) {
164
+ const raw = Number(env.SWEET_SEARCH_MAINTAINER_RSS_MIN_UPTIME_MS);
165
+ return Number.isFinite(raw) && raw >= 0 ? raw : MAINTAINER_RSS_MIN_UPTIME_MS_DEFAULT;
166
+ }
167
+
168
+ /**
169
+ * Pure recycle decision — evaluated at tick boundaries only.
170
+ * @returns {{recycle: boolean, reason: string}}
171
+ */
172
+ export function shouldRecycleForRss({ rssBytes, ceilingBytes, uptimeMs, minUptimeMs, ticksCompleted }) {
173
+ if (!Number.isFinite(ceilingBytes) || ceilingBytes <= 0) return { recycle: false, reason: 'disabled' };
174
+ if (!Number.isFinite(rssBytes) || rssBytes <= ceilingBytes) return { recycle: false, reason: 'under-ceiling' };
175
+ if (!(ticksCompleted >= 1)) return { recycle: false, reason: 'no-completed-tick' };
176
+ if (!(uptimeMs >= minUptimeMs)) return { recycle: false, reason: 'min-uptime' };
177
+ return { recycle: true, reason: 'over-ceiling' };
178
+ }
179
+
115
180
  /**
116
181
  * Read the resident-set size (bytes) of an arbitrary pid, best-effort and
117
182
  * cross-platform. Returns a non-negative integer, or 0 when unknown (dead pid,