sweet-search 2.5.13 → 2.6.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.
- package/README.md +36 -9
- package/core/cli.js +41 -3
- package/core/embedding/embedding-local-model.js +106 -10
- package/core/embedding/embedding-service.js +59 -1
- package/core/embedding/model-client.mjs +257 -0
- package/core/embedding/model-server.mjs +217 -0
- package/core/incremental-indexing/application/maintenance-handlers.mjs +19 -98
- package/core/incremental-indexing/application/maintenance-worker.mjs +46 -9
- package/core/incremental-indexing/application/operator-cli.mjs +14 -5
- package/core/incremental-indexing/application/production-reconciler-helpers.mjs +40 -0
- package/core/incremental-indexing/application/production-reconciler.mjs +718 -54
- package/core/incremental-indexing/application/reconciler.mjs +87 -15
- package/core/incremental-indexing/domain/cutoff-cache.mjs +191 -0
- package/core/incremental-indexing/domain/interval-autotune.mjs +84 -1
- package/core/incremental-indexing/domain/reconcile-counters.mjs +0 -4
- package/core/incremental-indexing/domain/watermark-scheduler.mjs +0 -24
- package/core/incremental-indexing/infrastructure/maintenance-state-reader.mjs +2 -26
- package/core/incremental-indexing/infrastructure/manifest.mjs +1 -9
- package/core/incremental-indexing/infrastructure/sqlite-fts5.mjs +72 -0
- package/core/indexing/artifact-builder.js +1 -1
- package/core/indexing/dedup/dedup-phase.js +36 -17
- package/core/indexing/dedup/exemplar-selector.js +5 -0
- package/core/indexing/index-codebase-v21.js +37 -14
- package/core/indexing/index-maintainer.mjs +337 -6
- package/core/indexing/indexer-ann.js +27 -434
- package/core/indexing/indexer-build.js +30 -14
- package/core/indexing/indexer-manifest.js +0 -3
- package/core/indexing/indexer-phases.js +101 -25
- package/core/indexing/maintainer-launcher.mjs +22 -0
- package/core/indexing/maintainer-watcher.mjs +397 -0
- package/core/indexing/os-priority.mjs +160 -0
- package/core/indexing/rss-budget.mjs +425 -0
- package/core/indexing/streaming-vectors.js +450 -0
- package/core/infrastructure/config/platform.js +14 -10
- package/core/infrastructure/onnx-session-utils.js +37 -0
- package/core/infrastructure/sparse-gram-delta-reader.js +11 -1
- package/core/ranking/late-interaction-index.js +58 -7
- package/core/search/daemon-registry.js +199 -0
- package/core/search/search-read-semantic.js +9 -3
- package/core/search/search-semantic.js +6 -29
- package/core/search/search-server.js +527 -27
- package/core/search/session-daemon-prewarm.mjs +110 -1
- package/core/search/sweet-search.js +0 -38
- package/core/vector-store/binary-hnsw-index.js +692 -78
- package/core/vector-store/index.js +1 -4
- package/eval/agent-read-workflows/bin/_ss-argparse.mjs +51 -5
- package/eval/agent-read-workflows/bin/_ss-helpers.mjs +95 -44
- package/eval/agent-read-workflows/bin/ss-read +2 -0
- package/mcp/tool-handlers.js +1 -2
- package/package.json +11 -8
- package/scripts/uninstall.js +2 -0
- package/core/vector-store/hnsw-index.js +0 -751
|
@@ -43,7 +43,8 @@ import { dirname, join, relative, isAbsolute, resolve } from 'node:path';
|
|
|
43
43
|
import { fileURLToPath } from 'node:url';
|
|
44
44
|
import { spawn } from 'node:child_process';
|
|
45
45
|
import { randomUUID } from 'node:crypto';
|
|
46
|
-
import
|
|
46
|
+
import os from 'node:os';
|
|
47
|
+
import { startupInterval, tierForHardware, reconcileEnablement, nextInterval, backstopWalkIntervalMs, resolveMaintainerMemoryProfile } from '../incremental-indexing/domain/interval-autotune.mjs';
|
|
47
48
|
import { detectHardwareCapability } from '../infrastructure/hardware-capability.js';
|
|
48
49
|
import { sweepStaleArtifactTemps, DEFAULT_TMP_SWEEP_MAX_AGE_MS } from '../incremental-indexing/infrastructure/artifact-temp-sweep.mjs';
|
|
49
50
|
import { hasCompleteBaseIndex, WAITING_FOR_INITIAL_INDEX } from '../incremental-indexing/infrastructure/baseline-readiness.mjs';
|
|
@@ -909,9 +910,16 @@ export async function runReconcileV2Tick(ctx) {
|
|
|
909
910
|
// `sweet-search index --add` or an editor hook (release-gate finding C1). Runs
|
|
910
911
|
// before the consume step below; best-effort so a scan failure never blocks
|
|
911
912
|
// reconcile of already-queued work.
|
|
913
|
+
// G6 backstop demotion: when the watcher is the primary producer the daemon
|
|
914
|
+
// sets `ctx.skipFullWalk` on the ticks between backstop walks (it leaves it
|
|
915
|
+
// unset/false on the first tick, on the periodic backstop, and on overflow).
|
|
916
|
+
// The watcher has already fed the queue from filesystem events, so the
|
|
917
|
+
// expensive full stat-walk is redundant on those ticks. When the watcher is
|
|
918
|
+
// inactive `skipFullWalk` is never set ⇒ the walk runs every tick (today's
|
|
919
|
+
// behavior, unchanged).
|
|
912
920
|
try {
|
|
913
921
|
const { dirtyScanEnabled, scanDirtyAndEnqueue } = await import('../incremental-indexing/application/dirty-scan.mjs');
|
|
914
|
-
if (dirtyScanEnabled()) {
|
|
922
|
+
if (dirtyScanEnabled() && !ctx.skipFullWalk) {
|
|
915
923
|
const { createAdmissionPolicy } = await import('../indexing/admission-policy.js');
|
|
916
924
|
const admissionPolicy = createAdmissionPolicy({ projectRoot: ctx.projectRoot });
|
|
917
925
|
progress('dirty-scan:start');
|
|
@@ -1059,12 +1067,18 @@ export async function drainMaintenanceInline(ctx) {
|
|
|
1059
1067
|
}
|
|
1060
1068
|
}
|
|
1061
1069
|
|
|
1062
|
-
async function sleepWithProgress(totalMs, lockFile) {
|
|
1070
|
+
async function sleepWithProgress(totalMs, lockFile, opts = {}) {
|
|
1063
1071
|
const deadline = Date.now() + totalMs;
|
|
1072
|
+
// G6 watcher early-wake: when the watcher feeds the queue mid-sleep it sets a
|
|
1073
|
+
// `pendingEvents` flag; the daemon breaks out of the sleep so the next tick
|
|
1074
|
+
// reconciles fresh edits without waiting out the full interval. Off by default
|
|
1075
|
+
// (no watcher → `wokenByWatcher` is never callable → behavior is today's).
|
|
1076
|
+
const wokenByWatcher = typeof opts.wokenByWatcher === 'function' ? opts.wokenByWatcher : null;
|
|
1064
1077
|
while (!shutdownRequested) {
|
|
1065
1078
|
if (!stillOwnsLock(lockFile)) {
|
|
1066
1079
|
throw new MaintainerLifecycleAbort('lock ownership lost during sleep');
|
|
1067
1080
|
}
|
|
1081
|
+
if (wokenByWatcher && wokenByWatcher()) return;
|
|
1068
1082
|
const remaining = deadline - Date.now();
|
|
1069
1083
|
if (remaining <= 0) return;
|
|
1070
1084
|
await new Promise((resolveSleep) => setTimeout(resolveSleep, Math.min(LOCK_REFRESH_INTERVAL, remaining)));
|
|
@@ -1072,6 +1086,131 @@ async function sleepWithProgress(totalMs, lockFile) {
|
|
|
1072
1086
|
}
|
|
1073
1087
|
}
|
|
1074
1088
|
|
|
1089
|
+
// ---- G4 lever gates (each default OFF unless noted; off ⇒ exact prior behavior).
|
|
1090
|
+
// Mirror the G2 reconciler convention: strict `'1'` opt-in.
|
|
1091
|
+
const flagOn = (name) => process.env[name] === '1';
|
|
1092
|
+
// SWEET_SEARCH_RECONCILE_AUTOTUNE is DEFAULT-ON (disable with =0): the daemon's
|
|
1093
|
+
// consume-half mirrors the reconciler config-half so the tuned interval is
|
|
1094
|
+
// actually applied to the sleep loop. Verified recall-neutral + soak == baseline.
|
|
1095
|
+
// Set to '0' to restore the fixed-interval sleep loop.
|
|
1096
|
+
export const reconcileAutotuneEnabled = (env = process.env) => env.SWEET_SEARCH_RECONCILE_AUTOTUNE !== '0';
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* A.4-consume: recompute the daemon's sleep interval from the just-finished
|
|
1100
|
+
* tick. The reconciler config-half (G2) only re-tunes an ephemeral per-tick
|
|
1101
|
+
* Reconciler instance, so the daemon MUST recompute the interval in its OWN
|
|
1102
|
+
* loop or the autotune is "doubly dead" (flag on but output disconnected).
|
|
1103
|
+
*
|
|
1104
|
+
* Pure + testable: takes the tick counters snapshot + a measured maintenance
|
|
1105
|
+
* backlog and returns the next interval. When the flag is off, returns
|
|
1106
|
+
* `currentMs` unchanged (today's behavior). When an explicit interval is pinned
|
|
1107
|
+
* via the startup env, the daemon never calls this (the resolver marked it
|
|
1108
|
+
* pinned). Windows: `os.loadavg()` returns `[0,0,0]`, so the per-core load is 0
|
|
1109
|
+
* and the loosen-under-load signal is naturally skipped (flat interval) — no
|
|
1110
|
+
* special-casing needed beyond not treating 0 as "idle headroom" spuriously
|
|
1111
|
+
* (0 < 0.2 only nudges down by 0.95, well within the rate-limit, harmless).
|
|
1112
|
+
*
|
|
1113
|
+
* @param {object} args
|
|
1114
|
+
* @param {number} args.currentMs interval the just-finished tick used
|
|
1115
|
+
* @param {object|null} args.counters runReconcileV2Tick return (snapshot)
|
|
1116
|
+
* @param {number} args.maintenanceBacklog pending maintenance jobs
|
|
1117
|
+
* @param {NodeJS.ProcessEnv} [args.env]
|
|
1118
|
+
* @param {() => number[]} [args.loadavg] injectable for tests
|
|
1119
|
+
* @param {() => number} [args.cpuCount] injectable for tests
|
|
1120
|
+
* @returns {{ nextMs:number, tuned:boolean, reasons:string[] }}
|
|
1121
|
+
*/
|
|
1122
|
+
export function computeNextIntervalMs({
|
|
1123
|
+
currentMs,
|
|
1124
|
+
counters,
|
|
1125
|
+
maintenanceBacklog,
|
|
1126
|
+
env = process.env,
|
|
1127
|
+
loadavg = () => os.loadavg(),
|
|
1128
|
+
cpuCount = () => os.cpus().length,
|
|
1129
|
+
}) {
|
|
1130
|
+
if (!reconcileAutotuneEnabled(env)) {
|
|
1131
|
+
return { nextMs: currentMs, tuned: false, reasons: [] };
|
|
1132
|
+
}
|
|
1133
|
+
// A skipped tick (dormant baseline / paused) has no usable signal — leave the
|
|
1134
|
+
// interval untouched rather than feed `nextInterval` zeros that would creep up.
|
|
1135
|
+
if (!counters || counters.skipped) {
|
|
1136
|
+
return { nextMs: currentMs, tuned: false, reasons: [] };
|
|
1137
|
+
}
|
|
1138
|
+
let cores = 1;
|
|
1139
|
+
try { cores = Math.max(1, cpuCount() || 1); } catch { cores = 1; }
|
|
1140
|
+
let load1 = 0;
|
|
1141
|
+
try { load1 = (loadavg()?.[0] ?? 0); } catch { load1 = 0; }
|
|
1142
|
+
const cpuLoadAvg = load1 / cores; // Windows → 0/N = 0 → loosen-under-load skipped.
|
|
1143
|
+
const tuned = nextInterval({
|
|
1144
|
+
currentMs,
|
|
1145
|
+
lastTickMs: Number(counters.tick_ms) || 0,
|
|
1146
|
+
dirtyAtTickStart: Number(counters.dirty_paths_seen) || 0,
|
|
1147
|
+
cpuLoadAvg,
|
|
1148
|
+
maintenanceBacklog: Number(maintenanceBacklog) || 0,
|
|
1149
|
+
});
|
|
1150
|
+
return { nextMs: tuned.nextMs, tuned: tuned.nextMs !== currentMs, reasons: tuned.reasons };
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* D.1: idle-TTL. Default 0/disabled. Returns the configured wall-clock idle
|
|
1155
|
+
* budget (ms) after which an unattended maintainer self-shuts-down so N resident
|
|
1156
|
+
* model-loaded daemons collapse to 1–2 (the ~16 GB cross-repo footprint fix).
|
|
1157
|
+
* 0 (or invalid / negative) ⇒ disabled (today's "run forever" behavior).
|
|
1158
|
+
*/
|
|
1159
|
+
export function maintainerIdleTtlMs(env = process.env, totalMemBytes = os.totalmem()) {
|
|
1160
|
+
const rawStr = env.SWEET_SEARCH_MAINTAINER_IDLE_TTL_MS;
|
|
1161
|
+
if (rawStr != null) {
|
|
1162
|
+
// Explicitly set (incl. '' / invalid / 0 / negative → disabled): the env
|
|
1163
|
+
// always wins over the tier default.
|
|
1164
|
+
const raw = Number.parseInt(rawStr, 10);
|
|
1165
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 0;
|
|
1166
|
+
}
|
|
1167
|
+
// Unset → auto from the system-RAM tier (resolveMaintainerMemoryProfile):
|
|
1168
|
+
// small-RAM hosts (laptops, the OOM case) get idle-TTL ON; roomy hosts → 0/off.
|
|
1169
|
+
return resolveMaintainerMemoryProfile({ totalMemBytes }).idleTtlMs;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* D.1 idle bookkeeping (pure + testable). The maintainer has NO query route, so
|
|
1174
|
+
* idle is keyed on consecutive ticks that found NOTHING to do — `dirtyAtTickStart
|
|
1175
|
+
* === 0` AND an empty maintenance queue — NOT on the self-heartbeats
|
|
1176
|
+
* (recordProgress/writeStateLock always tick). Any indexed change resets the
|
|
1177
|
+
* counter and the wall-clock idle anchor.
|
|
1178
|
+
*
|
|
1179
|
+
* @param {object} state mutable { idleTicks:number, idleSinceMs:number|null }
|
|
1180
|
+
* @param {object} args
|
|
1181
|
+
* @param {number} args.dirtyAtTickStart
|
|
1182
|
+
* @param {number} args.maintenanceBacklog
|
|
1183
|
+
* @param {boolean} [args.skipped] a dormant/paused tick is NOT activity, but is
|
|
1184
|
+
* also not "idle work done" — treat as idle.
|
|
1185
|
+
* @param {number} [args.nowMs]
|
|
1186
|
+
* @returns {{ idle:boolean }}
|
|
1187
|
+
*/
|
|
1188
|
+
export function recordIdleTick(state, { dirtyAtTickStart, maintenanceBacklog, skipped = false, nowMs = Date.now() }) {
|
|
1189
|
+
const didWork = !skipped && ((Number(dirtyAtTickStart) || 0) > 0 || (Number(maintenanceBacklog) || 0) > 0);
|
|
1190
|
+
if (didWork) {
|
|
1191
|
+
state.idleTicks = 0;
|
|
1192
|
+
state.idleSinceMs = null;
|
|
1193
|
+
return { idle: false };
|
|
1194
|
+
}
|
|
1195
|
+
state.idleTicks = (state.idleTicks || 0) + 1;
|
|
1196
|
+
if (state.idleSinceMs == null) state.idleSinceMs = nowMs;
|
|
1197
|
+
return { idle: true };
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* D.1: has the maintainer been continuously idle (no indexed change, empty
|
|
1202
|
+
* maintenance queue) for at least `ttlMs` wall-clock across consecutive ticks?
|
|
1203
|
+
* Requires BOTH at least one consecutive idle tick AND the wall-clock budget to
|
|
1204
|
+
* have elapsed since idleness began. ttlMs<=0 ⇒ never (disabled).
|
|
1205
|
+
*/
|
|
1206
|
+
export function idleTtlExceeded(state, ttlMs, nowMs = Date.now()) {
|
|
1207
|
+
if (!(ttlMs > 0)) return false;
|
|
1208
|
+
// Use a null check, NOT truthiness: an idle anchor of exactly 0ms is valid.
|
|
1209
|
+
if (state.idleSinceMs == null) return false;
|
|
1210
|
+
if ((state.idleTicks || 0) < 1) return false;
|
|
1211
|
+
return (nowMs - state.idleSinceMs) >= ttlMs;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1075
1214
|
async function runReconcileV2Main({ runOnce, merkleOnce }) {
|
|
1076
1215
|
const ctx = reconcileV2Context();
|
|
1077
1216
|
mkdirSync(ctx.stateDir, { recursive: true });
|
|
@@ -1104,8 +1243,110 @@ async function runReconcileV2Main({ runOnce, merkleOnce }) {
|
|
|
1104
1243
|
}
|
|
1105
1244
|
|
|
1106
1245
|
const resolved = resolveReconcileV2Interval();
|
|
1107
|
-
|
|
1246
|
+
// A.4-consume: the sleep interval is now mutable so the autotune can move it
|
|
1247
|
+
// each iteration. When the operator pins an explicit interval, the resolver
|
|
1248
|
+
// marks it `pinned` and the autotune never fires (parity with the reconciler
|
|
1249
|
+
// config's `pinnedIntervalMs` short-circuit).
|
|
1250
|
+
let intervalMs = resolved.intervalMs;
|
|
1251
|
+
const autotunePinned = resolved.pinned === true;
|
|
1108
1252
|
log('INFO', `Reconcile v2 interval ${intervalMs}ms (source=${resolved.source}${resolved.tier ? `, tier=${resolved.tier}` : ''})`);
|
|
1253
|
+
|
|
1254
|
+
// G3 arming: configure the BACKGROUND/maintainer ORT profile BEFORE the first
|
|
1255
|
+
// tick embeds. The ONNX session singleton is built once on first encode;
|
|
1256
|
+
// configuring after is a silent no-op (mirrors indexer-phases.js:491).
|
|
1257
|
+
//
|
|
1258
|
+
// MAINTAINER-SCOPED DEFAULT-ON (disable with SWEET_SEARCH_ORT_BACKGROUND=0):
|
|
1259
|
+
// arm the background ORT profile (force_spinning_stop + arena-off + 2–4
|
|
1260
|
+
// intra-op threads) by default, but ONLY in this maintainer daemon process. We
|
|
1261
|
+
// arm it explicitly here via configureLocalModelRuntime({ background: true }),
|
|
1262
|
+
// which sets the process-local runtime config — it does NOT touch
|
|
1263
|
+
// buildLocalSessionOptions / isBackgroundOrtProfile's env fallback, so every
|
|
1264
|
+
// OTHER process (the latency-critical search/query embedding path) stays on the
|
|
1265
|
+
// FOREGROUND profile. Verified ORT embeddings byte-identical. Set
|
|
1266
|
+
// SWEET_SEARCH_ORT_BACKGROUND=0 to keep even the maintainer on foreground.
|
|
1267
|
+
// Best-effort — never block startup on the embedding module.
|
|
1268
|
+
if (process.env.SWEET_SEARCH_ORT_BACKGROUND !== '0') {
|
|
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)');
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
log('WARN', `ORT background profile arming failed (continuing on foreground profile): ${err?.message ?? err}`);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// G6 watcher hook (integration seam; module + behavior owned by G6). When the
|
|
1282
|
+
// module is absent or the flag is off, behavior is EXACTLY today: the per-tick
|
|
1283
|
+
// full stat-walk in runReconcileV2Tick stays the sole dirty-set producer. The
|
|
1284
|
+
// watcher (when active) feeds the queue from filesystem events, sets
|
|
1285
|
+
// `watcherState.pendingEvents` for early-wake, and the producer block demotes
|
|
1286
|
+
// the full walk to a periodic backstop.
|
|
1287
|
+
const watcherState = { active: false, pendingEvents: false, forceBackstopWalk: false, lastBackstopWalkMs: 0, handle: null };
|
|
1288
|
+
const backstopMs = backstopWalkIntervalMs().intervalMs;
|
|
1289
|
+
if (process.env.SWEET_SEARCH_MAINTAINER_WATCH === '1') {
|
|
1290
|
+
try {
|
|
1291
|
+
const mod = await import('./maintainer-watcher.mjs');
|
|
1292
|
+
if (typeof mod.startWatcher === 'function') {
|
|
1293
|
+
const { createAdmissionPolicy } = await import('../indexing/admission-policy.js');
|
|
1294
|
+
const admissionPolicy = createAdmissionPolicy({ projectRoot: ctx.projectRoot });
|
|
1295
|
+
watcherState.handle = await mod.startWatcher({
|
|
1296
|
+
stateDir: ctx.stateDir,
|
|
1297
|
+
projectRoot: ctx.projectRoot,
|
|
1298
|
+
admissionPolicy,
|
|
1299
|
+
onEvent: () => { watcherState.pendingEvents = true; },
|
|
1300
|
+
onOverflow: () => { watcherState.forceBackstopWalk = true; watcherState.pendingEvents = true; },
|
|
1301
|
+
});
|
|
1302
|
+
watcherState.active = !!watcherState.handle;
|
|
1303
|
+
if (watcherState.active) log('INFO', `Maintainer file watcher active (backstop walk every ${backstopMs}ms)`);
|
|
1304
|
+
}
|
|
1305
|
+
} catch (err) {
|
|
1306
|
+
// Module absent or failed → no-op; the full per-tick walk remains primary.
|
|
1307
|
+
log('WARN', `Maintainer watcher unavailable (full per-tick walk remains primary): ${err?.message ?? err}`);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// G7 RSS-budget registry hook (integration seam; module owned by G7).
|
|
1312
|
+
// Registers when the soft cap is enabled — which is now tier-aware:
|
|
1313
|
+
// `isEnabled()` is true when SWEET_SEARCH_RSS_BUDGET_FRACTION is set OR the
|
|
1314
|
+
// system-RAM tier auto-enables a cap (small-RAM hosts). Best-effort, guarded
|
|
1315
|
+
// so a missing module is a no-op.
|
|
1316
|
+
let rssRegistration = null;
|
|
1317
|
+
try {
|
|
1318
|
+
const mod = await import('./rss-budget.mjs');
|
|
1319
|
+
if (typeof mod.isEnabled === 'function' && mod.isEnabled()
|
|
1320
|
+
&& typeof mod.registerDaemon === 'function') {
|
|
1321
|
+
rssRegistration = await mod.registerDaemon({ pid: process.pid, stateDir: ctx.stateDir, kind: 'maintainer' });
|
|
1322
|
+
}
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
log('WARN', `RSS-budget registry unavailable (no soft cap on this daemon): ${err?.message ?? err}`);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// D.1 idle-TTL: an unattended maintainer self-shuts-down after the configured
|
|
1328
|
+
// wall-clock idle budget so N resident model-loaded daemons collapse to 1–2
|
|
1329
|
+
// (the ~16 GB cross-repo footprint fix). Default 0/disabled ⇒ run forever
|
|
1330
|
+
// (today's behavior). Idle is keyed on consecutive ticks that found nothing to
|
|
1331
|
+
// do (dirtyAtTickStart===0 AND empty maintenance queue), NEVER on the
|
|
1332
|
+
// self-heartbeats. NOTE: the search-server daemon has its own idle-TTL+LRU,
|
|
1333
|
+
// but it never enumerates/registers the maintainer (search-server.js:918), so
|
|
1334
|
+
// there is no double-mechanism here.
|
|
1335
|
+
const idleTtl = maintainerIdleTtlMs();
|
|
1336
|
+
const idleState = { idleTicks: 0, idleSinceMs: null };
|
|
1337
|
+
// Unref'd idle timer mirrors search-server.js:907–913 — it never keeps the
|
|
1338
|
+
// event loop alive on its own and only REQUESTS shutdown (it does NOT
|
|
1339
|
+
// process.exit); the main loop drains the current tick to `finally`.
|
|
1340
|
+
const idleTimer = idleTtl > 0
|
|
1341
|
+
? setInterval(() => {
|
|
1342
|
+
if (idleTtlExceeded(idleState, idleTtl)) {
|
|
1343
|
+
log('INFO', `Maintainer idle for ≥${idleTtl}ms with no indexed change; requesting clean shutdown for on-demand respawn`);
|
|
1344
|
+
shutdownRequested = true;
|
|
1345
|
+
}
|
|
1346
|
+
}, Number(process.env.SWEET_SEARCH_MAINTAINER_IDLE_CHECK_MS ?? 60_000))
|
|
1347
|
+
: null;
|
|
1348
|
+
if (idleTimer?.unref) idleTimer.unref();
|
|
1349
|
+
|
|
1109
1350
|
// Lifecycle fix: only refresh the heartbeat if we still own the lock. If a
|
|
1110
1351
|
// wedged-backstop takeover stole it, the lockfile now names another pid —
|
|
1111
1352
|
// we must NOT clobber that successor with our pid. The main loop's
|
|
@@ -1137,16 +1378,60 @@ async function runReconcileV2Main({ runOnce, merkleOnce }) {
|
|
|
1137
1378
|
// acquireStateLock distinguish a busy-but-progressing daemon from one
|
|
1138
1379
|
// hung on a never-resolving await — see the WEDGED_KILL_GRACE_MS block.
|
|
1139
1380
|
recordProgress(lock.lockFile);
|
|
1381
|
+
// G6: the watcher already fed the queue from filesystem events, so the
|
|
1382
|
+
// expensive full stat-walk producer is demoted to a periodic backstop.
|
|
1383
|
+
// We pass the demotion decision into the tick via ctx so the producer
|
|
1384
|
+
// block (index-maintainer.mjs ~:907) can skip the walk when appropriate.
|
|
1385
|
+
// When the watcher is inactive this is always `false` ⇒ today's behavior.
|
|
1386
|
+
let runFullWalk = true;
|
|
1387
|
+
if (watcherState.active) {
|
|
1388
|
+
const nowMs = Date.now();
|
|
1389
|
+
const dueForBackstop = (nowMs - (watcherState.lastBackstopWalkMs || 0)) >= backstopMs;
|
|
1390
|
+
const firstTick = !watcherState.lastBackstopWalkMs;
|
|
1391
|
+
runFullWalk = firstTick || dueForBackstop || watcherState.forceBackstopWalk;
|
|
1392
|
+
if (runFullWalk) {
|
|
1393
|
+
watcherState.lastBackstopWalkMs = nowMs;
|
|
1394
|
+
watcherState.forceBackstopWalk = false;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
// Consume any pending watcher events for THIS tick (the queue already
|
|
1398
|
+
// holds them); clear so early-wake doesn't immediately re-fire post-sleep.
|
|
1399
|
+
watcherState.pendingEvents = false;
|
|
1140
1400
|
const pause = isReconcilePaused(ctx.stateDir);
|
|
1141
1401
|
if (pause.paused) {
|
|
1142
1402
|
log('INFO', `Automatic reconcile v2 work paused${pause.pausedAt ? ` since ${pause.pausedAt}` : ''}`);
|
|
1143
1403
|
} else {
|
|
1144
1404
|
try {
|
|
1145
1405
|
const onProgress = createLifecycleProgress(lock.lockFile);
|
|
1146
|
-
await runReconcileV2Tick({ ...ctx, onProgress });
|
|
1406
|
+
const tickCounters = await runReconcileV2Tick({ ...ctx, onProgress, skipFullWalk: watcherState.active && !runFullWalk });
|
|
1147
1407
|
onProgress('tick:post'); // post-tick checkpoint
|
|
1148
1408
|
await drainMaintenanceInline({ ...ctx, onProgress });
|
|
1149
1409
|
onProgress('drain:post'); // post-drain checkpoint
|
|
1410
|
+
|
|
1411
|
+
// A.4-consume + D.1 idle bookkeeping. Read the maintenance backlog
|
|
1412
|
+
// AFTER the drain (it reflects what remains, not what was queued).
|
|
1413
|
+
let backlog = 0;
|
|
1414
|
+
try {
|
|
1415
|
+
const { readMaintenanceQueue } = await import('../incremental-indexing/application/maintenance-worker.mjs');
|
|
1416
|
+
backlog = readMaintenanceQueue(ctx.stateDir).length;
|
|
1417
|
+
} catch { backlog = 0; }
|
|
1418
|
+
const dirtySeen = Number(tickCounters?.dirty_paths_seen) || 0;
|
|
1419
|
+
if (!autotunePinned) {
|
|
1420
|
+
const tuned = computeNextIntervalMs({
|
|
1421
|
+
currentMs: intervalMs,
|
|
1422
|
+
counters: tickCounters,
|
|
1423
|
+
maintenanceBacklog: backlog,
|
|
1424
|
+
});
|
|
1425
|
+
if (tuned.tuned) {
|
|
1426
|
+
log('INFO', `Reconcile v2 interval ${intervalMs}ms → ${tuned.nextMs}ms (${tuned.reasons.join(',')})`);
|
|
1427
|
+
intervalMs = tuned.nextMs;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
recordIdleTick(idleState, {
|
|
1431
|
+
dirtyAtTickStart: dirtySeen,
|
|
1432
|
+
maintenanceBacklog: backlog,
|
|
1433
|
+
skipped: tickCounters?.skipped === true,
|
|
1434
|
+
});
|
|
1150
1435
|
} catch (err) {
|
|
1151
1436
|
if (err instanceof MaintainerLifecycleAbort) {
|
|
1152
1437
|
log('WARN', `Reconcile v2 lifecycle abort: ${err.message}. Cleaning up cancellation-orphaned temps and exiting cleanly.`);
|
|
@@ -1172,10 +1457,33 @@ async function runReconcileV2Main({ runOnce, merkleOnce }) {
|
|
|
1172
1457
|
log('ERROR', `Reconcile v2 tick failed: ${err?.message ?? err}`);
|
|
1173
1458
|
}
|
|
1174
1459
|
}
|
|
1175
|
-
await sleepWithProgress(intervalMs, lock.lockFile
|
|
1460
|
+
await sleepWithProgress(intervalMs, lock.lockFile, {
|
|
1461
|
+
// G6 early-wake: break the sleep the instant the watcher reports new
|
|
1462
|
+
// events so a fresh edit is reconciled without waiting out the interval.
|
|
1463
|
+
// No watcher ⇒ this is never truthy ⇒ today's full-interval sleep.
|
|
1464
|
+
wokenByWatcher: watcherState.active ? () => watcherState.pendingEvents : null,
|
|
1465
|
+
});
|
|
1176
1466
|
}
|
|
1177
1467
|
} finally {
|
|
1178
1468
|
clearInterval(refresh);
|
|
1469
|
+
if (idleTimer) clearInterval(idleTimer);
|
|
1470
|
+
// G6 watcher teardown — best-effort, never throw from finally.
|
|
1471
|
+
if (watcherState.handle && typeof watcherState.handle.close === 'function') {
|
|
1472
|
+
try { await watcherState.handle.close(); } catch { /* best-effort */ }
|
|
1473
|
+
}
|
|
1474
|
+
// G7 RSS registry teardown — best-effort.
|
|
1475
|
+
if (rssRegistration && typeof rssRegistration.unregister === 'function') {
|
|
1476
|
+
try { await rssRegistration.unregister(); } catch { /* best-effort */ }
|
|
1477
|
+
}
|
|
1478
|
+
// D.1: release the ORT session in order on a clean (idle-TTL or signal)
|
|
1479
|
+
// shutdown so a respawned daemon starts from a clean slate. This canNOT go
|
|
1480
|
+
// in the process.on('exit') handler (synchronous, no async). releaseStateLock
|
|
1481
|
+
// below unlinks the O_EXCL lock so the next launchMaintainer respawns cleanly
|
|
1482
|
+
// (reconcile-before-serve via the new daemon's t=0 tick).
|
|
1483
|
+
try {
|
|
1484
|
+
const { unloadLocalModel } = await import('../embedding/embedding-local-model.js');
|
|
1485
|
+
await unloadLocalModel();
|
|
1486
|
+
} catch { /* best-effort: never block clean shutdown on model release */ }
|
|
1179
1487
|
releaseStateLock(lock.lockFile);
|
|
1180
1488
|
log('INFO', 'Reconcile v2 shutdown complete');
|
|
1181
1489
|
}
|
|
@@ -2174,6 +2482,29 @@ async function main() {
|
|
|
2174
2482
|
const dryRun = process.argv.includes('--dry-run');
|
|
2175
2483
|
const merkleOnce = process.argv.includes('--merkle-once');
|
|
2176
2484
|
|
|
2485
|
+
// A.1 (Tier-1, UNGATED): demote the maintainer daemon to low OS priority so
|
|
2486
|
+
// the foreground (editor / git / shell) never feels the background indexer's
|
|
2487
|
+
// CPU. Identical index output — only *when* CPU is granted changes. Covers
|
|
2488
|
+
// BOTH the reconcile-v2 and the legacy queue/merkle paths (set before either
|
|
2489
|
+
// branch). Best-effort: a platform that rejects it must not crash the daemon.
|
|
2490
|
+
try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { /* best-effort */ }
|
|
2491
|
+
|
|
2492
|
+
// E.4 (global half): set the process-global SQLite `soft_heap_limit` ONCE,
|
|
2493
|
+
// before any tier connection opens. SQLite's `soft_heap_limit` is a
|
|
2494
|
+
// PROCESS-WIDE setting (sqlite3_soft_heap_limit64), not per-connection — so
|
|
2495
|
+
// running the pragma on a single throwaway in-memory connection at startup
|
|
2496
|
+
// caps the page-cache heap for EVERY subsequent better-sqlite3 connection in
|
|
2497
|
+
// this process (128 MiB). This is the process-wide lever that pairs with G2's
|
|
2498
|
+
// per-connection `cache_size` / `shrink_memory`. (better-sqlite3 exposes no
|
|
2499
|
+
// static global setter, so the throwaway-conn pragma is the documented path.)
|
|
2500
|
+
// Guarded + best-effort: a packaging without better-sqlite3 degrades to a
|
|
2501
|
+
// no-op and never crashes the daemon.
|
|
2502
|
+
try {
|
|
2503
|
+
const { default: BetterSqlite3 } = await import('better-sqlite3');
|
|
2504
|
+
const probe = new BetterSqlite3(':memory:');
|
|
2505
|
+
try { probe.pragma('soft_heap_limit = 134217728'); } finally { probe.close(); }
|
|
2506
|
+
} catch { /* best-effort: soft_heap_limit is an optimisation, never required */ }
|
|
2507
|
+
|
|
2177
2508
|
// L1 FIX: Updated version to v3
|
|
2178
2509
|
log('INFO', 'Starting index maintainer daemon v3...');
|
|
2179
2510
|
const v2 = reconcileV2Status();
|