sweet-search 0.0.1 → 2.3.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 (161) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +23 -0
  3. package/core/cli.js +51 -0
  4. package/core/config.js +27 -0
  5. package/core/embedding/embedding-cache.js +467 -0
  6. package/core/embedding/embedding-local-model.js +845 -0
  7. package/core/embedding/embedding-remote.js +492 -0
  8. package/core/embedding/embedding-service.js +712 -0
  9. package/core/embedding/embedding-telemetry.js +219 -0
  10. package/core/embedding/index.js +40 -0
  11. package/core/graph/community-detector.js +294 -0
  12. package/core/graph/graph-expansion.js +839 -0
  13. package/core/graph/graph-extractor.js +2304 -0
  14. package/core/graph/graph-search.js +2148 -0
  15. package/core/graph/hcgs-generator.js +666 -0
  16. package/core/graph/index.js +16 -0
  17. package/core/graph/leiden-algorithm.js +547 -0
  18. package/core/graph/relationship-resolver.js +366 -0
  19. package/core/graph/repo-map.js +408 -0
  20. package/core/graph/summary-manager.js +549 -0
  21. package/core/indexing/artifact-builder.js +1054 -0
  22. package/core/indexing/ast-chunker.js +709 -0
  23. package/core/indexing/chunking/chunk-builder.js +170 -0
  24. package/core/indexing/chunking/markdown-chunker.js +503 -0
  25. package/core/indexing/chunking/plaintext-chunker.js +104 -0
  26. package/core/indexing/dedup/dedup-phase.js +159 -0
  27. package/core/indexing/dedup/exemplar-selector.js +65 -0
  28. package/core/indexing/document-chunker.js +56 -0
  29. package/core/indexing/incremental-parser.js +390 -0
  30. package/core/indexing/incremental-tracker.js +761 -0
  31. package/core/indexing/index-codebase-v21.js +472 -0
  32. package/core/indexing/index-maintainer.mjs +1674 -0
  33. package/core/indexing/index.js +90 -0
  34. package/core/indexing/indexer-ann.js +1077 -0
  35. package/core/indexing/indexer-build.js +742 -0
  36. package/core/indexing/indexer-phases.js +800 -0
  37. package/core/indexing/indexer-pool.js +764 -0
  38. package/core/indexing/indexer-sparse-gram.js +98 -0
  39. package/core/indexing/indexer-utils.js +536 -0
  40. package/core/indexing/indexer-worker.js +148 -0
  41. package/core/indexing/li-skip-policy.js +225 -0
  42. package/core/indexing/merkle-tracker.js +244 -0
  43. package/core/indexing/model-pool.js +166 -0
  44. package/core/infrastructure/code-graph-repository.js +120 -0
  45. package/core/infrastructure/codebase-repository.js +131 -0
  46. package/core/infrastructure/config/dedup.js +54 -0
  47. package/core/infrastructure/config/embedding.js +298 -0
  48. package/core/infrastructure/config/graph.js +80 -0
  49. package/core/infrastructure/config/index.js +82 -0
  50. package/core/infrastructure/config/indexing.js +8 -0
  51. package/core/infrastructure/config/platform.js +254 -0
  52. package/core/infrastructure/config/ranking.js +221 -0
  53. package/core/infrastructure/config/search.js +396 -0
  54. package/core/infrastructure/config/translation.js +89 -0
  55. package/core/infrastructure/config/vector-store.js +114 -0
  56. package/core/infrastructure/constants.js +86 -0
  57. package/core/infrastructure/coreml-cascade.js +909 -0
  58. package/core/infrastructure/coreml-cascade.json +46 -0
  59. package/core/infrastructure/coreml-provider.js +81 -0
  60. package/core/infrastructure/db-utils.js +69 -0
  61. package/core/infrastructure/dedup-hashing.js +83 -0
  62. package/core/infrastructure/hardware-capability.js +332 -0
  63. package/core/infrastructure/index.js +104 -0
  64. package/core/infrastructure/language-patterns/maps.js +121 -0
  65. package/core/infrastructure/language-patterns/registry-core.js +323 -0
  66. package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
  67. package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
  68. package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
  69. package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
  70. package/core/infrastructure/language-patterns/registry.js +19 -0
  71. package/core/infrastructure/language-patterns.js +141 -0
  72. package/core/infrastructure/llm-provider.js +733 -0
  73. package/core/infrastructure/manifest.json +46 -0
  74. package/core/infrastructure/maxsim.wasm +0 -0
  75. package/core/infrastructure/model-fetcher.js +423 -0
  76. package/core/infrastructure/model-registry.js +214 -0
  77. package/core/infrastructure/native-inference.js +587 -0
  78. package/core/infrastructure/native-resolver.js +187 -0
  79. package/core/infrastructure/native-sparse-gram.js +257 -0
  80. package/core/infrastructure/native-tokenizer.js +160 -0
  81. package/core/infrastructure/onnx-mutex.js +45 -0
  82. package/core/infrastructure/onnx-session-utils.js +261 -0
  83. package/core/infrastructure/ort-pipeline.js +111 -0
  84. package/core/infrastructure/project-detector.js +102 -0
  85. package/core/infrastructure/quantization.js +410 -0
  86. package/core/infrastructure/simd-distance.js +502 -0
  87. package/core/infrastructure/simd-distance.wasm +0 -0
  88. package/core/infrastructure/tree-sitter-provider.js +665 -0
  89. package/core/infrastructure/webgpu-maxsim.js +222 -0
  90. package/core/query/index.js +35 -0
  91. package/core/query/intent-detector.js +201 -0
  92. package/core/query/intent-router.js +156 -0
  93. package/core/query/query-router-catboost.js +222 -0
  94. package/core/query/query-router-ml.js +266 -0
  95. package/core/query/query-router.js +213 -0
  96. package/core/ranking/cascaded-scorer.js +379 -0
  97. package/core/ranking/flashrank.js +810 -0
  98. package/core/ranking/index.js +49 -0
  99. package/core/ranking/late-interaction-index.js +2383 -0
  100. package/core/ranking/late-interaction-model.js +812 -0
  101. package/core/ranking/local-reranker.js +374 -0
  102. package/core/ranking/mmr.js +379 -0
  103. package/core/ranking/quality-scorer.js +363 -0
  104. package/core/search/context-expander.js +1167 -0
  105. package/core/search/dedup/sibling-expander.js +327 -0
  106. package/core/search/index.js +16 -0
  107. package/core/search/search-boost.js +259 -0
  108. package/core/search/search-cli.js +544 -0
  109. package/core/search/search-format.js +282 -0
  110. package/core/search/search-fusion.js +327 -0
  111. package/core/search/search-hybrid.js +204 -0
  112. package/core/search/search-pattern-chunks.js +337 -0
  113. package/core/search/search-pattern-planner.js +439 -0
  114. package/core/search/search-pattern-prefilter.js +412 -0
  115. package/core/search/search-pattern-ripgrep.js +663 -0
  116. package/core/search/search-pattern.js +463 -0
  117. package/core/search/search-postprocess.js +452 -0
  118. package/core/search/search-semantic.js +706 -0
  119. package/core/search/search-server.js +554 -0
  120. package/core/search/session-daemon-prewarm.mjs +164 -0
  121. package/core/search/session-warmup.js +595 -0
  122. package/core/search/sweet-search.js +632 -0
  123. package/core/search/warmup-metrics.js +532 -0
  124. package/core/start-server.js +6 -0
  125. package/core/training/query-router/features/extractor.js +762 -0
  126. package/core/training/query-router/features/multilingual-patterns.js +431 -0
  127. package/core/training/query-router/features/text-segmenter.js +303 -0
  128. package/core/training/query-router/features/unicode-utils.js +383 -0
  129. package/core/training/query-router/output/v45_router_d4.js +11521 -0
  130. package/core/training/query-router/output/v46_router_d4.js +11498 -0
  131. package/core/vector-store/binary-heap.js +227 -0
  132. package/core/vector-store/binary-hnsw-index.js +1004 -0
  133. package/core/vector-store/float-vector-store.js +234 -0
  134. package/core/vector-store/hnsw-index.js +580 -0
  135. package/core/vector-store/index.js +39 -0
  136. package/core/vector-store/seismic-index.js +498 -0
  137. package/core/vocabulary/index.js +84 -0
  138. package/core/vocabulary/vocab-constants.js +20 -0
  139. package/core/vocabulary/vocab-miner-extractors.js +375 -0
  140. package/core/vocabulary/vocab-miner-nl.js +404 -0
  141. package/core/vocabulary/vocab-miner-utils.js +146 -0
  142. package/core/vocabulary/vocab-miner.js +574 -0
  143. package/core/vocabulary/vocab-prewarm-cli.js +110 -0
  144. package/core/vocabulary/vocab-ranker.js +492 -0
  145. package/core/vocabulary/vocab-warmer.js +523 -0
  146. package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
  147. package/core/vocabulary/vocabulary-utils.js +704 -0
  148. package/crates/wasm-router/pkg/package.json +13 -0
  149. package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
  150. package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
  151. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
  152. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
  153. package/mcp/config-gen.js +121 -0
  154. package/mcp/server.js +335 -0
  155. package/mcp/tool-handlers.js +476 -0
  156. package/package.json +131 -9
  157. package/scripts/benchmark-harness.js +794 -0
  158. package/scripts/init.js +1058 -0
  159. package/scripts/smoke-test.js +435 -0
  160. package/scripts/uninstall.js +478 -0
  161. package/scripts/verify-runtime.js +176 -0
@@ -0,0 +1,1674 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Index Maintainer Daemon v3 - Self-maintaining codebase index
4
+ *
5
+ * Detects ALL file changes (Claude edits + external IDE edits) and runs
6
+ * full incremental indexing every 45 seconds.
7
+ *
8
+ * Features:
9
+ * - Deferred merkle check (7s delay, ZERO startup latency)
10
+ * - 45-second periodic merkle check (mtime/size fast-path)
11
+ * - Full incremental index: FTS5, HNSW, Binary HNSW, Code Graph (full), HCGS
12
+ * - Global lock file prevents race with manual /index-codebase
13
+ * - Soft delete for removed files (handles branch switches, prune after 30d)
14
+ * - HCGS fallback chain: Cerebras → Ollama → Transformers.js → Static
15
+ * - Single-instance via lockfile
16
+ * - Graceful shutdown
17
+ *
18
+ * v3 Fixes (2026-01-02):
19
+ * - C2: Atomic O_EXCL lock acquisition (prevents TOCTOU race)
20
+ * - C3: Non-recursive global lock retry (prevents race between processes)
21
+ * - H1: Reduced global lock stale threshold to 2min (faster SIGKILL recovery)
22
+ * - H5: Atomic queue check+process (prevents peek/acquire race)
23
+ * - M4: Track skipped merkle cycles (no file changes lost on lock contention)
24
+ * - M5: Adjusted lock refresh/stale ratio (30s/3min for better margin)
25
+ * - M8: Cancellable startup timeout (clean shutdown)
26
+ * - L1: Version bump to v3
27
+ * - L3: Structured logging with timestamps and levels
28
+ *
29
+ * Queue file format (JSONL - legacy, still supported for Claude edits):
30
+ * {"file_path": "/path/to/file.java", "timestamp": 1735670400000}
31
+ *
32
+ * Usage:
33
+ * node index-maintainer.mjs # Run as daemon
34
+ * node index-maintainer.mjs --once # Process queue once and exit
35
+ * node index-maintainer.mjs --dry-run # Show what would be indexed
36
+ *
37
+ * Started by: session-preheat.sh (alongside search infrastructure)
38
+ */
39
+
40
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, appendFileSync, mkdirSync, openSync, closeSync, constants } from 'node:fs';
41
+ import fs from 'node:fs/promises';
42
+ import { dirname, join, relative, isAbsolute } from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
44
+ import { spawn } from 'node:child_process';
45
+
46
+ const __filename = fileURLToPath(import.meta.url);
47
+ const __dirname = dirname(__filename);
48
+
49
+ // =============================================================================
50
+ // ENOSPC (DISK FULL) SAFE WRITE UTILITIES
51
+ // =============================================================================
52
+ // H3 FIX: Handle disk full errors gracefully with atomic temp+rename pattern
53
+
54
+ /**
55
+ * Safe write with ENOSPC detection and atomic temp+rename pattern.
56
+ * @param {string} filePath - Target file path
57
+ * @param {string} content - Content to write
58
+ * @throws {Error} With clear message on disk full
59
+ */
60
+ async function safeWriteFile(filePath, content) {
61
+ const tempPath = `${filePath}.tmp.${process.pid}`;
62
+
63
+ try {
64
+ await fs.writeFile(tempPath, content);
65
+ await fs.rename(tempPath, filePath);
66
+ } catch (err) {
67
+ // Always try to clean up temp file
68
+ try { await fs.unlink(tempPath); } catch {}
69
+
70
+ if (err.code === 'ENOSPC') {
71
+ throw new Error(`CRITICAL: Disk full, cannot write to ${filePath}. Free up space and retry.`);
72
+ }
73
+ throw err;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Sync version for lock files (must be synchronous)
79
+ * H3 FIX: Uses atomic temp+rename pattern with ENOSPC detection
80
+ * @param {string} filePath - Target file path
81
+ * @param {string} content - Content to write
82
+ * @throws {Error} With clear message on disk full
83
+ */
84
+ function safeWriteFileSync(filePath, content) {
85
+ const tempPath = `${filePath}.tmp.${process.pid}`;
86
+
87
+ try {
88
+ writeFileSync(tempPath, content);
89
+ renameSync(tempPath, filePath);
90
+ } catch (err) {
91
+ try { unlinkSync(tempPath); } catch {}
92
+
93
+ if (err.code === 'ENOSPC') {
94
+ throw new Error(`CRITICAL: Disk full, cannot write to ${filePath}`);
95
+ }
96
+ throw err;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Safe append with ENOSPC detection
102
+ * H3 FIX: Wraps appendFileSync with ENOSPC error handling
103
+ * @param {string} filePath - Target file path
104
+ * @param {string} content - Content to append
105
+ * @throws {Error} With clear message on disk full
106
+ */
107
+ function safeAppendFileSync(filePath, content) {
108
+ try {
109
+ appendFileSync(filePath, content);
110
+ } catch (err) {
111
+ if (err.code === 'ENOSPC') {
112
+ throw new Error(`CRITICAL: Disk full, cannot append to ${filePath}. Free up space and retry.`);
113
+ }
114
+ throw err;
115
+ }
116
+ }
117
+
118
+ // === Cross-Platform Path Normalization ===
119
+
120
+ /**
121
+ * Normalize path separators to forward slashes for cross-platform compatibility.
122
+ * Handles Windows paths (C:\Users\...), UNC paths (\\server\share), and WSL paths.
123
+ * @param {string} filePath - Raw file path from queue entry
124
+ * @returns {string} - Normalized path with forward slashes
125
+ */
126
+ export function normalizePathSeparators(filePath) {
127
+ if (!filePath) return filePath;
128
+
129
+ // Convert all backslashes to forward slashes
130
+ let normalized = filePath.replace(/\\/g, '/');
131
+
132
+ // Handle UNC paths: \\server\share -> //server/share
133
+ // (Already handled by the replace above)
134
+
135
+ // Handle Windows drive letters: C:/ -> /c/ (lowercase for consistency)
136
+ // This makes C:/Users/foo match patterns like /Users/
137
+ const driveMatch = normalized.match(/^([A-Za-z]):\//);
138
+ if (driveMatch) {
139
+ normalized = '/' + driveMatch[1].toLowerCase() + normalized.slice(2);
140
+ }
141
+
142
+ return normalized;
143
+ }
144
+
145
+ // === Configuration ===
146
+ const PROJECT_ROOT = join(__dirname, '../..');
147
+ const DATA_DIR = join(PROJECT_ROOT, '.sweet-search');
148
+ const QUEUE_FILE = join(DATA_DIR, 'index-maintainer-queue.jsonl');
149
+ const PROCESSING_FILE = join(DATA_DIR, 'index-maintainer-queue.processing.jsonl');
150
+ const LOCK_FILE = join(DATA_DIR, 'index-maintainer.lock');
151
+ const DEADLETTER_FILE = join(DATA_DIR, 'index-maintainer-deadletter.jsonl');
152
+
153
+ // Export configuration for testing
154
+ export const CONFIG = {
155
+ PROJECT_ROOT,
156
+ DATA_DIR,
157
+ QUEUE_FILE,
158
+ PROCESSING_FILE,
159
+ LOCK_FILE,
160
+ DEADLETTER_FILE,
161
+ };
162
+
163
+ // Indexer paths
164
+ const INDEXER_PATH = join(PROJECT_ROOT, 'core', 'indexing', 'index-codebase-v21.js');
165
+
166
+ // === Dynamic Module Loaders (A2 FIX: Improved resilience with multiple strategies) ===
167
+
168
+ /**
169
+ * Load fast-glob with fallback paths
170
+ * A2 FIX: Try multiple resolution strategies with logging for diagnostics
171
+ */
172
+ async function loadFastGlob() {
173
+ const strategies = [
174
+ // 1. Standard require resolution (preferred - works when npm installed globally or locally)
175
+ { name: 'standard import', fn: async () => {
176
+ const fg = await import('fast-glob');
177
+ return fg.default || fg;
178
+ }},
179
+ // 2. Relative to sweet-search (has its own node_modules)
180
+ { name: 'sweet-search node_modules', fn: async () => {
181
+ const fg = await import(join(PROJECT_ROOT, 'node_modules/fast-glob/out/index.js'));
182
+ return fg.default || fg;
183
+ }},
184
+ // 3. Project root node_modules
185
+ { name: 'project root node_modules', fn: async () => {
186
+ const fg = await import(join(PROJECT_ROOT, 'node_modules/fast-glob/out/index.js'));
187
+ return fg.default || fg;
188
+ }},
189
+ // 4. Absolute path from __dirname
190
+ { name: 'absolute from hooks dir', fn: async () => {
191
+ const fg = await import(join(PROJECT_ROOT, 'node_modules/fast-glob/out/index.js'));
192
+ return fg.default || fg;
193
+ }},
194
+ ];
195
+
196
+ for (const { name, fn } of strategies) {
197
+ try {
198
+ const mod = await fn();
199
+ log('DEBUG', `Loaded fast-glob via: ${name}`);
200
+ return mod;
201
+ } catch (err) {
202
+ log('DEBUG', `fast-glob strategy failed (${name}): ${err.message}`);
203
+ }
204
+ }
205
+
206
+ throw new Error('fast-glob not found after trying all strategies. Run: npm install fast-glob');
207
+ }
208
+
209
+ /**
210
+ * Load better-sqlite3 with fallback paths
211
+ * A2 FIX: Try multiple resolution strategies with logging for diagnostics
212
+ */
213
+ async function loadBetterSqlite3() {
214
+ const strategies = [
215
+ // 1. Standard require resolution (preferred - works when npm installed globally or locally)
216
+ { name: 'standard import', fn: async () => {
217
+ const db = await import('better-sqlite3');
218
+ return db.default || db;
219
+ }},
220
+ // 2. Relative to sweet-search (has its own node_modules)
221
+ { name: 'sweet-search node_modules', fn: async () => {
222
+ const db = await import(join(PROJECT_ROOT, 'node_modules/better-sqlite3/lib/index.js'));
223
+ return db.default || db;
224
+ }},
225
+ // 3. Project root node_modules
226
+ { name: 'project root node_modules', fn: async () => {
227
+ const db = await import(join(PROJECT_ROOT, 'node_modules/better-sqlite3/lib/index.js'));
228
+ return db.default || db;
229
+ }},
230
+ // 4. Absolute path from __dirname
231
+ { name: 'absolute from hooks dir', fn: async () => {
232
+ const db = await import(join(PROJECT_ROOT, 'node_modules/better-sqlite3/lib/index.js'));
233
+ return db.default || db;
234
+ }},
235
+ ];
236
+
237
+ for (const { name, fn } of strategies) {
238
+ try {
239
+ const mod = await fn();
240
+ log('DEBUG', `Loaded better-sqlite3 via: ${name}`);
241
+ return mod;
242
+ } catch (err) {
243
+ log('DEBUG', `better-sqlite3 strategy failed (${name}): ${err.message}`);
244
+ }
245
+ }
246
+
247
+ throw new Error('better-sqlite3 not found after trying all strategies. Run: npm install better-sqlite3');
248
+ }
249
+
250
+ // Timing configuration
251
+ const POLL_INTERVAL = 30000; // 30 seconds between queue checks
252
+ const LOCK_REFRESH_INTERVAL = 30000; // 30 seconds between lock refreshes (M5: was 60s)
253
+ const LOCK_STALE_THRESHOLD = 180000; // 3 minutes (M5: was 5 min, ratio 6:1 with refresh)
254
+
255
+ // Retry configuration
256
+ const MAX_RETRIES = 3;
257
+ const BASE_DELAY_MS = 1000; // 1 second base delay
258
+ const MAX_DELAY_MS = 30000; // 30 seconds max delay
259
+
260
+ // Merkle check configuration (v2)
261
+ const MERKLE_CHECK_INTERVAL = 45000; // 45 seconds between merkle checks
262
+ const STARTUP_DELAY = 7000; // 7 seconds deferred first check (zero startup latency)
263
+ const INDEXING_TIMEOUT = 5 * 60 * 1000; // 5 minutes timeout
264
+
265
+ const DEFAULT_INDEXABLE_EXTENSIONS = [
266
+ '**/*.java', '**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.mjs',
267
+ '**/*.py', '**/*.go', '**/*.rs', '**/*.sql', '**/*.md',
268
+ '**/*.yml', '**/*.yaml', '**/*.json', '**/*.xml', '**/*.properties',
269
+ '**/*.html', '**/*.css', '**/*.scss', '**/*.proto'
270
+ ];
271
+
272
+ const DEFAULT_EXCLUDED_DIRS = [
273
+ '**/node_modules/**', '**/target/**', '**/build/**',
274
+ '**/dist/**', '**/.git/**', '**/.sweet-search/**'
275
+ ];
276
+
277
+ let INDEXABLE_EXTENSIONS = DEFAULT_INDEXABLE_EXTENSIONS;
278
+ let EXCLUDED_DIRS = DEFAULT_EXCLUDED_DIRS;
279
+
280
+ // Global indexing lock (prevents race with manual /index-codebase)
281
+ const GLOBAL_INDEX_LOCK = join(DATA_DIR, 'indexing.lock');
282
+
283
+ // State
284
+ let shutdownRequested = false;
285
+ let currentBatch = null; // Track current batch for graceful shutdown
286
+ let startupTimeout = null; // M8: Store reference for cancellation
287
+
288
+ // M4: Track files that need checking when lock was contended
289
+ const pendingFromSkippedCycle = new Set();
290
+
291
+ // === Logging Helper (L3) ===
292
+
293
+ /**
294
+ * Structured logging with timestamp and level prefix
295
+ * L3 FIX: Proper log levels instead of console.error for everything
296
+ * @param {'INFO'|'WARN'|'ERROR'|'DEBUG'} level - Log level
297
+ * @param {string} message - Log message
298
+ */
299
+ function log(level, message) {
300
+ const timestamp = new Date().toISOString().slice(11, 19);
301
+ console.error(`[${timestamp}] [${level}] [index-maintainer] ${message}`);
302
+ }
303
+
304
+ /**
305
+ * Load include/exclude patterns from .sweet-search.config.json via shared config loader.
306
+ * Falls back to built-in defaults if config cannot be loaded.
307
+ */
308
+ async function loadIndexingPatterns() {
309
+ try {
310
+ const { loadProjectConfig } = await import('../../core/config.js');
311
+ const projectConfig = loadProjectConfig(PROJECT_ROOT);
312
+
313
+ if (Array.isArray(projectConfig.include) && projectConfig.include.length > 0) {
314
+ INDEXABLE_EXTENSIONS = projectConfig.include;
315
+ }
316
+ if (Array.isArray(projectConfig.exclude) && projectConfig.exclude.length > 0) {
317
+ EXCLUDED_DIRS = projectConfig.exclude;
318
+ }
319
+
320
+ log('INFO', `Loaded indexing config: include=${INDEXABLE_EXTENSIONS.length}, exclude=${EXCLUDED_DIRS.length}`);
321
+ } catch (err) {
322
+ log('WARN', `Failed to load .sweet-search.config.json (using defaults): ${err.message}`);
323
+ }
324
+ }
325
+
326
+ await loadIndexingPatterns();
327
+
328
+ // === Lock File Management ===
329
+ // SECURITY INVARIANT: All lock release functions MUST verify ownership before releasing.
330
+ // Pattern: read lock file → verify PID matches process.pid → then release
331
+ // This prevents Process A from accidentally releasing Process B's lock.
332
+
333
+ /**
334
+ * Check if a PID is running
335
+ * S3 FIX: Optionally validate process start time to prevent PID reuse attacks
336
+ * @param {number} pid - Process ID to check
337
+ * @param {string|null} expectedStartTime - Expected /proc start time (Linux only)
338
+ * @returns {boolean} true if process is running (and matches start time if provided)
339
+ */
340
+ function isPidRunning(pid, expectedStartTime = null) {
341
+ try {
342
+ process.kill(pid, 0);
343
+
344
+ // S3 FIX: Linux-specific validation of process start time
345
+ if (expectedStartTime && process.platform === 'linux') {
346
+ try {
347
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
348
+ const fields = stat.split(' ');
349
+ const actualStartTime = fields[21]; // Field 22 (0-indexed: 21) = starttime
350
+ if (actualStartTime !== expectedStartTime) {
351
+ return false; // PID reused by different process
352
+ }
353
+ } catch {
354
+ // /proc not available or unreadable, skip check
355
+ }
356
+ }
357
+ return true;
358
+ } catch {
359
+ return false;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Read and parse lock file
365
+ * S3 FIX: Now returns { pid, timestamp, startTime } to validate against PID reuse
366
+ * Returns { pid, timestamp, startTime? } or null if invalid/missing
367
+ */
368
+ function readLockFile() {
369
+ try {
370
+ if (!existsSync(LOCK_FILE)) return null;
371
+ const content = readFileSync(LOCK_FILE, 'utf-8').trim();
372
+
373
+ // Try JSON format first (new format with startTime)
374
+ try {
375
+ const parsed = JSON.parse(content);
376
+ if (parsed.pid && parsed.timestamp) {
377
+ return {
378
+ pid: parsed.pid,
379
+ timestamp: parsed.timestamp,
380
+ startTime: parsed.startTime || null // S3: Optional start time
381
+ };
382
+ }
383
+ } catch {
384
+ // Fall back to legacy newline format
385
+ const [pidStr, timestampStr] = content.split('\n');
386
+ const pid = parseInt(pidStr, 10);
387
+ const timestamp = parseInt(timestampStr, 10);
388
+ if (isNaN(pid) || isNaN(timestamp)) return null;
389
+ return { pid, timestamp, startTime: null };
390
+ }
391
+ return null;
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Get process start time for PID reuse detection (Linux only)
399
+ * S3 FIX: Returns /proc starttime for current process
400
+ * @returns {string|null} Start time string or null if unavailable
401
+ */
402
+ function getProcessStartTime() {
403
+ if (process.platform !== 'linux') return null;
404
+ try {
405
+ const stat = readFileSync(`/proc/${process.pid}/stat`, 'utf-8');
406
+ const fields = stat.split(' ');
407
+ return fields[21]; // Field 22 (0-indexed: 21) = starttime
408
+ } catch {
409
+ return null;
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Acquire lock - returns true if lock acquired, false if another instance owns it
415
+ * C2 FIX: Uses atomic O_EXCL pattern to prevent TOCTOU race condition
416
+ * S2 FIX: Sets explicit 0o600 permissions (owner read/write only)
417
+ * S3 FIX: Includes process start time to prevent PID reuse attacks
418
+ * P3 FIX: Made async with proper setTimeout sleep instead of busy-wait
419
+ */
420
+ async function acquireLock() {
421
+ const MAX_LOCK_RETRIES = 3;
422
+ const RETRY_DELAY = 100; // ms
423
+
424
+ for (let attempt = 0; attempt < MAX_LOCK_RETRIES; attempt++) {
425
+ try {
426
+ // S2 FIX: Atomic create with explicit 0o600 permissions (owner read/write only)
427
+ const fd = openSync(LOCK_FILE, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
428
+ // S3 FIX: Include process start time for PID reuse detection
429
+ const lockData = JSON.stringify({
430
+ pid: process.pid,
431
+ timestamp: Date.now(),
432
+ startTime: getProcessStartTime() // S3: Linux-specific start time
433
+ });
434
+ writeFileSync(fd, lockData);
435
+ closeSync(fd);
436
+ return true;
437
+ } catch (err) {
438
+ if (err.code === 'EEXIST') {
439
+ // Lock exists - check if stale
440
+ const existing = readLockFile();
441
+ if (existing) {
442
+ // S3 FIX: Pass startTime to isPidRunning for PID reuse validation
443
+ const isRunning = isPidRunning(existing.pid, existing.startTime);
444
+ const isStale = (Date.now() - existing.timestamp) > LOCK_STALE_THRESHOLD;
445
+
446
+ if (!isRunning || isStale) {
447
+ // Try to remove stale lock
448
+ log('INFO', `Taking over lock (pid=${existing.pid}, running=${isRunning}, stale=${isStale})`);
449
+ try {
450
+ unlinkSync(LOCK_FILE);
451
+ // Don't recurse - continue loop for retry
452
+ continue;
453
+ } catch {
454
+ // Another process removed it, retry
455
+ continue;
456
+ }
457
+ }
458
+ }
459
+ // Lock held by active process
460
+ if (attempt < MAX_LOCK_RETRIES - 1) {
461
+ // P3 FIX: Use async sleep instead of busy-wait
462
+ await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
463
+ continue;
464
+ }
465
+ return false;
466
+ }
467
+ throw err; // Unexpected error
468
+ }
469
+ }
470
+ return false;
471
+ }
472
+
473
+ /**
474
+ * Write lock file with current PID and timestamp
475
+ * H3 FIX: Uses safeWriteFileSync for ENOSPC detection
476
+ */
477
+ function writeLock() {
478
+ safeWriteFileSync(LOCK_FILE, `${process.pid}\n${Date.now()}\n`);
479
+ }
480
+
481
+ /**
482
+ * Release lock on shutdown
483
+ */
484
+ function releaseLock() {
485
+ try {
486
+ const existing = readLockFile();
487
+ if (existing && existing.pid === process.pid) {
488
+ unlinkSync(LOCK_FILE);
489
+ }
490
+ } catch {
491
+ // Ignore errors during cleanup
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Refresh lock timestamp periodically to prevent stale detection.
497
+ * SECURITY: Verifies ownership before refresh to prevent lock theft.
498
+ *
499
+ * @returns {boolean} True if lock was refreshed, false if we lost ownership
500
+ */
501
+ function refreshLock() {
502
+ try {
503
+ const existing = readLockFile();
504
+ if (existing && existing.pid === process.pid) {
505
+ writeLock();
506
+ return true;
507
+ } else {
508
+ // Lost lock ownership - another process took over
509
+ console.error('[index-maintainer] WARNING: Lost lock ownership, stopping refresh');
510
+ return false;
511
+ }
512
+ } catch (err) {
513
+ console.error(`[index-maintainer] refreshLock error: ${err.message}`);
514
+ return false;
515
+ }
516
+ }
517
+
518
+ // === Queue Management (Exported for testing) ===
519
+
520
+ /**
521
+ * Ensure the .sweet-search directory exists
522
+ */
523
+ export function ensureDataDir() {
524
+ if (!existsSync(DATA_DIR)) {
525
+ mkdirSync(DATA_DIR, { recursive: true });
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Normalize file path to project-relative format with cross-platform support.
531
+ * Handles Windows paths (C:\Users\...), UNC paths (\\server\share), and converts
532
+ * to forward slashes for consistent pattern matching and storage.
533
+ *
534
+ * @param {string} filePath - Raw file path (any platform format)
535
+ * @param {string} projectRoot - Project root directory
536
+ * @returns {string} - Normalized, project-relative path with forward slashes
537
+ */
538
+ export function normalizePath(filePath, projectRoot = PROJECT_ROOT) {
539
+ if (!filePath) return filePath;
540
+
541
+ // First normalize separators (backslash -> forward slash, handle drive letters)
542
+ const normalizedPath = normalizePathSeparators(filePath);
543
+ const normalizedRoot = normalizePathSeparators(projectRoot);
544
+
545
+ // Check if path is within project root (using normalized paths)
546
+ if (normalizedPath.startsWith(normalizedRoot + '/')) {
547
+ return normalizedPath.slice(normalizedRoot.length + 1);
548
+ }
549
+
550
+ // Also try Node's native handling for POSIX-style absolute paths
551
+ if (isAbsolute(filePath) && filePath.startsWith(projectRoot)) {
552
+ const rel = relative(projectRoot, filePath);
553
+ // Normalize the result too (relative() may return backslashes on Windows)
554
+ return normalizePathSeparators(rel);
555
+ }
556
+
557
+ // Return normalized path if outside project (or already relative)
558
+ return normalizedPath;
559
+ }
560
+
561
+ /**
562
+ * Parse and deduplicate queue entries from content string
563
+ * Pure function for testability.
564
+ * @param {string} content - Raw JSONL content
565
+ * @param {string} projectRoot - Project root for path normalization
566
+ * @returns {{ files: Map<string, object>, count: number, malformedCount: number }}
567
+ */
568
+ export function parseQueueContent(content, projectRoot = PROJECT_ROOT) {
569
+ const lines = content.split('\n').filter(l => l.trim());
570
+ const files = new Map();
571
+ let malformedCount = 0;
572
+
573
+ for (const line of lines) {
574
+ try {
575
+ const entry = JSON.parse(line);
576
+ if (!entry.file_path) {
577
+ malformedCount++;
578
+ continue;
579
+ }
580
+
581
+ // Normalize to project-relative path
582
+ const normalizedPath = normalizePath(entry.file_path, projectRoot);
583
+
584
+ // Deduplicate: keep entry with highest retry count or most recent timestamp
585
+ const existing = files.get(normalizedPath);
586
+ if (!existing) {
587
+ files.set(normalizedPath, { ...entry, file_path: normalizedPath });
588
+ } else {
589
+ // Merge: take max retry count and most recent timestamp
590
+ files.set(normalizedPath, {
591
+ file_path: normalizedPath,
592
+ retry: Math.max(existing.retry || 0, entry.retry || 0),
593
+ timestamp: Math.max(existing.timestamp || 0, entry.timestamp || 0),
594
+ queued_at: entry.queued_at || existing.queued_at,
595
+ });
596
+ }
597
+ } catch (err) {
598
+ console.error(`[index-maintainer] Malformed queue line: ${line.substring(0, 100)}`);
599
+ malformedCount++;
600
+ }
601
+ }
602
+
603
+ return { files, count: files.size, malformedCount };
604
+ }
605
+
606
+ /**
607
+ * Atomically acquire queue for processing using rename-before-read pattern.
608
+ * This prevents race conditions where entries appended between read and rename are lost.
609
+ *
610
+ * CRITICAL: This is the ONLY safe way to process the queue.
611
+ * Pattern: rename(queue -> processing) THEN read(processing)
612
+ *
613
+ * @param {string} queueFile - Path to queue file
614
+ * @param {string} processingFile - Path to processing file
615
+ * @returns {{ success: boolean, content: string | null, error?: string }}
616
+ */
617
+ export function atomicAcquireQueue(queueFile = QUEUE_FILE, processingFile = PROCESSING_FILE) {
618
+ // Step 1: Check if queue exists
619
+ if (!existsSync(queueFile)) {
620
+ return { success: false, content: null, error: 'queue_empty' };
621
+ }
622
+
623
+ // Step 2: Atomically rename queue -> processing FIRST
624
+ // This prevents race: any new appends go to a fresh queue file
625
+ try {
626
+ renameSync(queueFile, processingFile);
627
+ } catch (err) {
628
+ // ENOENT: file was removed/renamed by another process
629
+ if (err.code === 'ENOENT') {
630
+ return { success: false, content: null, error: 'queue_empty' };
631
+ }
632
+ console.error(`[index-maintainer] Failed to rename queue to processing: ${err.message}`);
633
+ return { success: false, content: null, error: err.message };
634
+ }
635
+
636
+ // Step 3: Now read the processing file (safe from race)
637
+ // E8 FIX: Restore queue if read fails after rename
638
+ try {
639
+ const content = readFileSync(processingFile, 'utf-8');
640
+ return { success: true, content };
641
+ } catch (err) {
642
+ // E8 FIX: Failed to read - try to restore queue file
643
+ log('ERROR', `Failed to read processing file: ${err.message}`);
644
+ try {
645
+ renameSync(processingFile, queueFile);
646
+ log('INFO', 'Restored queue file after read failure');
647
+ } catch (restoreErr) {
648
+ // If restore fails, log what we lost
649
+ log('ERROR', `Queue read failed AND could not restore: ${restoreErr.message}`);
650
+ }
651
+ return { success: false, content: null, error: err.message };
652
+ }
653
+ }
654
+
655
+ /**
656
+ * @deprecated Use atomicCheckAndProcessQueue() instead for atomic operations.
657
+ * D6 FIX: Added deprecation notice
658
+ *
659
+ * Legacy readQueue for backwards compatibility with main loop empty-check.
660
+ * WARNING: Do NOT use this for processing - use atomicAcquireQueue instead.
661
+ *
662
+ * @returns {{ files: Map<string, object>, count: number }} Queue contents (read-only peek)
663
+ */
664
+ function peekQueue() {
665
+ if (!existsSync(QUEUE_FILE)) {
666
+ return { files: new Map(), count: 0 };
667
+ }
668
+
669
+ try {
670
+ const content = readFileSync(QUEUE_FILE, 'utf-8');
671
+ const { files, count } = parseQueueContent(content);
672
+ return { files, count };
673
+ } catch (err) {
674
+ console.error(`[index-maintainer] Failed to peek queue: ${err.message}`);
675
+ return { files: new Map(), count: 0 };
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Remove processing file after successful processing
681
+ */
682
+ export function cleanupProcessingFile(processingFile = PROCESSING_FILE) {
683
+ try {
684
+ if (existsSync(processingFile)) {
685
+ unlinkSync(processingFile);
686
+ }
687
+ } catch {
688
+ // Ignore cleanup errors
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Restore unprocessed entries to queue (on shutdown or partial failure)
694
+ * H3 FIX: Uses safeAppendFileSync for ENOSPC detection
695
+ */
696
+ export function requeueEntries(entries, queueFile = QUEUE_FILE) {
697
+ if (!entries || entries.length === 0) return;
698
+
699
+ ensureDataDir();
700
+
701
+ for (const entry of entries) {
702
+ try {
703
+ safeAppendFileSync(queueFile, JSON.stringify(entry) + '\n');
704
+ } catch (err) {
705
+ console.error(`[index-maintainer] Failed to requeue: ${err.message}`);
706
+ // H3 FIX: If disk is full, stop trying to requeue more entries
707
+ if (err.message.includes('CRITICAL: Disk full')) {
708
+ log('ERROR', 'Disk full - stopping requeue operations');
709
+ break;
710
+ }
711
+ }
712
+ }
713
+
714
+ console.error(`[index-maintainer] Requeued ${entries.length} entries for later processing`);
715
+ }
716
+
717
+ /**
718
+ * Add entry to dead letter queue
719
+ * H3 FIX: Uses safeAppendFileSync for ENOSPC detection
720
+ */
721
+ function writeToDeadletter(entry, error) {
722
+ ensureDataDir();
723
+
724
+ try {
725
+ const deadletterEntry = {
726
+ ...entry,
727
+ error: error.message || String(error),
728
+ dead_at: new Date().toISOString(),
729
+ pid: process.pid,
730
+ };
731
+ safeAppendFileSync(DEADLETTER_FILE, JSON.stringify(deadletterEntry) + '\n');
732
+ console.error(`[index-maintainer] Entry moved to deadletter: ${entry.file_path}`);
733
+ } catch (writeErr) {
734
+ console.error(`[index-maintainer] Failed to write to deadletter: ${writeErr.message}`);
735
+ }
736
+ }
737
+
738
+ // === Indexing ===
739
+
740
+ /**
741
+ * Calculate exponential backoff delay
742
+ */
743
+ function getRetryDelay(retryCount) {
744
+ const delay = BASE_DELAY_MS * Math.pow(2, retryCount);
745
+ return Math.min(delay, MAX_DELAY_MS);
746
+ }
747
+
748
+ /**
749
+ * Spawn indexer process with given files.
750
+ * Used by both queue processing and merkle-based indexing.
751
+ * A3 FIX: DRY extraction of common indexer invocation logic (originally M6)
752
+ *
753
+ * @param {string[]} files - Files to index
754
+ * @param {Object} options - { quiet?: boolean, timeout?: number, onProgress?: Function }
755
+ * @returns {Promise<{success: boolean, stdout: string, stderr: string, exitCode?: number, error?: string}>}
756
+ */
757
+ async function spawnIndexer(files, options = {}) {
758
+ const { quiet = true, timeout = INDEXING_TIMEOUT } = options;
759
+
760
+ if (!existsSync(INDEXER_PATH)) {
761
+ return {
762
+ success: false,
763
+ stdout: '',
764
+ stderr: '',
765
+ error: 'Indexer not found at ' + INDEXER_PATH,
766
+ };
767
+ }
768
+
769
+ const args = [INDEXER_PATH, '--files-from-stdin'];
770
+ if (quiet) args.push('--quiet');
771
+
772
+ return new Promise((resolve) => {
773
+ const child = spawn('node', args, {
774
+ cwd: PROJECT_ROOT,
775
+ stdio: ['pipe', 'pipe', 'pipe'],
776
+ env: process.env,
777
+ });
778
+
779
+ let stdout = '';
780
+ let stderr = '';
781
+
782
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
783
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
784
+
785
+ if (files.length > 0) {
786
+ child.stdin.write(files.join('\n'));
787
+ }
788
+ child.stdin.end();
789
+
790
+ const timer = setTimeout(() => {
791
+ if (!child.killed) {
792
+ child.kill('SIGTERM');
793
+ resolve({ success: false, stdout, stderr, error: 'Timeout' });
794
+ }
795
+ }, timeout);
796
+
797
+ child.on('close', (code) => {
798
+ clearTimeout(timer);
799
+ resolve({ success: code === 0, stdout, stderr, exitCode: code });
800
+ });
801
+
802
+ child.on('error', (err) => {
803
+ clearTimeout(timer);
804
+ resolve({ success: false, stdout, stderr, error: err.message });
805
+ });
806
+ });
807
+ }
808
+
809
+ /**
810
+ * Run the indexer with specific files
811
+ * Returns { success: boolean, failedFiles: string[], error?: string }
812
+ */
813
+ async function runIndexer(filePaths, options = {}) {
814
+ const { dryRun = false } = options;
815
+
816
+ if (dryRun) {
817
+ log('INFO', `DRY RUN: Would index ${filePaths.length} files:`);
818
+ for (const fp of filePaths) {
819
+ console.error(` - ${fp}`);
820
+ }
821
+ return { success: true, failedFiles: [] };
822
+ }
823
+
824
+ log('INFO', `Starting incremental index for ${filePaths.length} files...`);
825
+ const startTime = Date.now();
826
+
827
+ // P7 FIX: Log small batch hint - spawn overhead may dominate for tiny batches
828
+ if (filePaths.length <= 3) {
829
+ log('DEBUG', `Small batch (${filePaths.length} files) - consider accumulating if frequent`);
830
+ }
831
+
832
+ // M6 FIX: Use shared spawnIndexer
833
+ const result = await spawnIndexer(filePaths, { quiet: true });
834
+ const duration = Date.now() - startTime;
835
+
836
+ if (result.success) {
837
+ log('INFO', `Indexing complete (${duration}ms, ${filePaths.length} files)`);
838
+ return { success: true, failedFiles: [] };
839
+ } else {
840
+ log('ERROR', `Indexing failed (exit code ${result.exitCode}, ${duration}ms)`);
841
+ if (result.stderr) {
842
+ log('ERROR', `stderr: ${result.stderr.substring(0, 500)}`);
843
+ }
844
+ return {
845
+ success: false,
846
+ failedFiles: filePaths,
847
+ error: result.error || `Exit code ${result.exitCode}: ${result.stderr?.substring(0, 200)}`,
848
+ };
849
+ }
850
+ }
851
+
852
+ // =============================================================================
853
+ // MERKLE CHECK AND INCREMENTAL INDEXING (v2)
854
+ // =============================================================================
855
+
856
+ // H1: Reduced from 10 min to 2 min for faster recovery after SIGKILL
857
+ const GLOBAL_LOCK_STALE_THRESHOLD = 120000; // 2 minutes
858
+
859
+ /**
860
+ * Cleanup stale global lock on startup
861
+ * H1 FIX: Check and remove stale locks before daemon starts
862
+ */
863
+ function cleanupStaleGlobalLock() {
864
+ try {
865
+ if (existsSync(GLOBAL_INDEX_LOCK)) {
866
+ const content = readFileSync(GLOBAL_INDEX_LOCK, 'utf-8');
867
+ const [pidStr, timestampStr] = content.split('\n');
868
+ const pid = parseInt(pidStr, 10);
869
+ const age = Date.now() - parseInt(timestampStr, 10);
870
+
871
+ // Check if process is dead OR lock is very old (2 min)
872
+ let isDead = false;
873
+ try { process.kill(pid, 0); } catch { isDead = true; }
874
+
875
+ if (isDead || age > GLOBAL_LOCK_STALE_THRESHOLD) {
876
+ log('INFO', `Removing stale global lock (age: ${age}ms, dead: ${isDead})`);
877
+ unlinkSync(GLOBAL_INDEX_LOCK);
878
+ }
879
+ }
880
+ } catch (err) {
881
+ log('ERROR', `Error checking global lock: ${err.message}`);
882
+ }
883
+ }
884
+
885
+ /**
886
+ * Acquire global index lock (prevents race with manual /index-codebase)
887
+ * C3 FIX: Uses loop pattern instead of recursive calls to prevent race
888
+ * S2 FIX: Sets explicit 0o600 permissions (owner read/write only)
889
+ * @returns {boolean} true if lock acquired
890
+ */
891
+ function acquireGlobalIndexLock() {
892
+ const MAX_GLOBAL_LOCK_RETRIES = 3;
893
+
894
+ for (let attempt = 0; attempt < MAX_GLOBAL_LOCK_RETRIES; attempt++) {
895
+ try {
896
+ // S2 FIX: Atomic create with O_EXCL and explicit 0o600 permissions
897
+ const fd = openSync(GLOBAL_INDEX_LOCK, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
898
+ writeFileSync(fd, `${process.pid}\n${Date.now()}`);
899
+ closeSync(fd);
900
+ return true;
901
+ } catch (err) {
902
+ if (err.code === 'EEXIST') {
903
+ // Check if lock is stale
904
+ try {
905
+ const content = readFileSync(GLOBAL_INDEX_LOCK, 'utf-8');
906
+ const [pidStr, timestampStr] = content.split('\n');
907
+ const pid = parseInt(pidStr, 10);
908
+ const timestamp = parseInt(timestampStr, 10);
909
+ const age = Date.now() - timestamp;
910
+
911
+ // H1: Stale after 2 minutes (was 10 min)
912
+ const isStale = age > GLOBAL_LOCK_STALE_THRESHOLD;
913
+ let isDead = false;
914
+ try {
915
+ process.kill(pid, 0);
916
+ } catch {
917
+ isDead = true;
918
+ }
919
+
920
+ if (isStale || isDead) {
921
+ try {
922
+ unlinkSync(GLOBAL_INDEX_LOCK);
923
+ continue; // Retry in loop, not recursive
924
+ } catch {
925
+ continue; // Another process removed it
926
+ }
927
+ }
928
+ } catch {
929
+ // Can't read lock file, try to remove
930
+ try { unlinkSync(GLOBAL_INDEX_LOCK); } catch {}
931
+ continue;
932
+ }
933
+ return false; // Lock held by active process
934
+ }
935
+ return false; // Other error
936
+ }
937
+ }
938
+ return false;
939
+ }
940
+
941
+ /**
942
+ * Release global index lock with ownership verification.
943
+ * SECURITY: Only releases if we own the lock (matching PID).
944
+ */
945
+ function releaseGlobalIndexLock() {
946
+ try {
947
+ const content = readFileSync(GLOBAL_INDEX_LOCK, 'utf-8');
948
+ const [pidStr] = content.trim().split('\n');
949
+ const lockPid = parseInt(pidStr, 10);
950
+
951
+ if (lockPid === process.pid) {
952
+ unlinkSync(GLOBAL_INDEX_LOCK);
953
+ } else {
954
+ console.error(`[index-maintainer] Not releasing global lock - owned by PID ${lockPid}, we are ${process.pid}`);
955
+ }
956
+ } catch (err) {
957
+ if (err.code !== 'ENOENT') {
958
+ console.error(`[index-maintainer] Error releasing global lock: ${err.message}`);
959
+ }
960
+ }
961
+ }
962
+
963
+ /**
964
+ * Perform merkle-state check for ALL file changes (internal + external).
965
+ * Uses mtime/size fast-path for efficiency (~0.1ms per unchanged file).
966
+ *
967
+ * @returns {Promise<{checked: boolean, toIndex: string[], toRemove: string[], stats: Object}>}
968
+ */
969
+ async function performMerkleCheck() {
970
+ const startTime = Date.now();
971
+ console.error('[index-maintainer] Running merkle check for file changes...');
972
+
973
+ try {
974
+ // Dynamically import incremental tracker
975
+ const { getChangedFiles, updateState } = await import('../../core/incremental-tracker.js');
976
+
977
+ // H3 FIX: Use dynamic loader with fallback paths
978
+ const fg = await loadFastGlob();
979
+ const allFiles = await fg(INDEXABLE_EXTENSIONS, {
980
+ cwd: PROJECT_ROOT,
981
+ ignore: EXCLUDED_DIRS,
982
+ onlyFiles: true,
983
+ absolute: false,
984
+ });
985
+
986
+ if (allFiles.length === 0) {
987
+ return { checked: true, toIndex: [], toRemove: [], stats: { totalFiles: 0 } };
988
+ }
989
+
990
+ // Use incremental tracker to detect changes (mtime/size fast-path)
991
+ const { toIndex, toRemove, currentHashes, fastPathStats } = await getChangedFiles(allFiles, PROJECT_ROOT);
992
+
993
+ const duration = Date.now() - startTime;
994
+ const fastPathRatio = allFiles.length > 0 ? ((fastPathStats.hits / allFiles.length) * 100).toFixed(1) : 0;
995
+
996
+ console.error(
997
+ `[index-maintainer] Merkle check: ${allFiles.length} files, ` +
998
+ `${fastPathStats.hits} fast-path hits (${fastPathRatio}%), ` +
999
+ `${toIndex.length} changed, ${toRemove.length} removed, ${duration}ms`
1000
+ );
1001
+
1002
+ return {
1003
+ checked: true,
1004
+ toIndex,
1005
+ toRemove,
1006
+ currentHashes,
1007
+ updateState, // Pass through for state update after indexing
1008
+ stats: {
1009
+ totalFiles: allFiles.length,
1010
+ fastPathHits: fastPathStats.hits,
1011
+ fastPathMisses: fastPathStats.misses,
1012
+ contentReads: fastPathStats.contentReads,
1013
+ duration,
1014
+ },
1015
+ };
1016
+ } catch (err) {
1017
+ console.error(`[index-maintainer] Merkle check failed: ${err.message}`);
1018
+ return { checked: false, toIndex: [], toRemove: [], stats: { error: err.message } };
1019
+ }
1020
+ }
1021
+
1022
+ /**
1023
+ * Run full incremental indexing for changed files.
1024
+ * Updates: FTS5, HNSW, Binary HNSW, Code Graph (full rebuild), HCGS
1025
+ *
1026
+ * NOTE: Code graph uses FULL rebuild (safe for cross-file relationships, FREE - no API, ~10s)
1027
+ * Vectors/HCGS use incremental update (changed files only)
1028
+ *
1029
+ * M6 FIX: Uses shared spawnIndexer() function
1030
+ * M2 FIX: Logs HCGS regeneration stats
1031
+ *
1032
+ * @param {string[]} toIndex - Files to index (new or changed)
1033
+ * @param {Object} currentHashes - Current file hashes for state update
1034
+ * @param {Function} updateStateFn - Function to update merkle state
1035
+ * @returns {Promise<{success: boolean, stats: Object}>}
1036
+ */
1037
+ async function runFullIncrementalIndex(toIndex, currentHashes, updateStateFn) {
1038
+ const startTime = Date.now();
1039
+
1040
+ if (toIndex.length === 0) {
1041
+ return { success: true, stats: { filesProcessed: 0, duration: 0 } };
1042
+ }
1043
+
1044
+ log('INFO', `Running full incremental index for ${toIndex.length} files...`);
1045
+
1046
+ try {
1047
+ // M6 FIX: Use shared spawnIndexer function
1048
+ const result = await spawnIndexer(toIndex, { quiet: true });
1049
+
1050
+ const duration = Date.now() - startTime;
1051
+
1052
+ if (result.success) {
1053
+ // Update merkle state
1054
+ if (updateStateFn && currentHashes) {
1055
+ await updateStateFn(currentHashes, { totalChunks: toIndex.length });
1056
+ }
1057
+
1058
+ // M2 FIX: Parse stdout for HCGS stats and include in log
1059
+ const hcgsMatch = result.stdout.match(/HCGS: (\d+) summaries/);
1060
+ const hcgsCount = hcgsMatch ? parseInt(hcgsMatch[1], 10) : 0;
1061
+
1062
+ log('INFO',
1063
+ `Indexed ${toIndex.length} files: FTS5 updated, HNSW vectors updated, ` +
1064
+ `Code graph rebuilt, HCGS: ${hcgsCount} summaries regenerated (${duration}ms)`
1065
+ );
1066
+
1067
+ return {
1068
+ success: true,
1069
+ stats: {
1070
+ filesIndexed: toIndex.length,
1071
+ hcgsSummaries: hcgsCount,
1072
+ duration,
1073
+ },
1074
+ };
1075
+ } else {
1076
+ log('ERROR', `Full incremental index failed: ${result.error || result.stderr}`);
1077
+ return {
1078
+ success: false,
1079
+ stats: { error: result.error || result.stderr, duration },
1080
+ };
1081
+ }
1082
+ } catch (err) {
1083
+ log('ERROR', `Full incremental index error: ${err.message}`);
1084
+ return { success: false, stats: { error: err.message } };
1085
+ }
1086
+ }
1087
+
1088
+ /**
1089
+ * Mark files as stale (soft delete - don't remove from DB).
1090
+ * Branch switches will restore them without HCGS regeneration.
1091
+ *
1092
+ * NOTE: Requires stale_since column in code-graph.db entities table.
1093
+ * The column should be added to createGraphSchema() in graph-extractor.js.
1094
+ */
1095
+ async function markFilesAsStale(files) {
1096
+ if (files.length === 0) return;
1097
+
1098
+ console.error(`[index-maintainer] Marking ${files.length} files as stale (soft delete)`);
1099
+
1100
+ try {
1101
+ const { DB_PATHS } = await import('../../core/config.js');
1102
+ // H3 FIX: Use dynamic loader with fallback paths
1103
+ const Database = await loadBetterSqlite3();
1104
+
1105
+ if (!existsSync(DB_PATHS.codeGraph)) {
1106
+ log('WARN', 'Code graph not found, skipping stale marking');
1107
+ return;
1108
+ }
1109
+
1110
+ const db = new Database(DB_PATHS.codeGraph);
1111
+ const now = Math.floor(Date.now() / 1000);
1112
+
1113
+ // Check if stale_since column exists
1114
+ const columns = db.prepare("PRAGMA table_info(entities)").all();
1115
+ const hasStaleColumn = columns.some(col => col.name === 'stale_since');
1116
+
1117
+ if (!hasStaleColumn) {
1118
+ log('WARN', 'stale_since column not found, skipping soft delete');
1119
+ db.close();
1120
+ return;
1121
+ }
1122
+
1123
+ const updateStmt = db.prepare('UPDATE entities SET stale_since = ? WHERE file_path = ?');
1124
+ const updateMany = db.transaction(() => {
1125
+ for (const file of files) {
1126
+ updateStmt.run(now, file);
1127
+ }
1128
+ });
1129
+
1130
+ updateMany();
1131
+ db.close();
1132
+
1133
+ log('INFO', `Marked ${files.length} files as stale`);
1134
+ } catch (err) {
1135
+ log('ERROR', `Failed to mark files as stale: ${err.message}`);
1136
+ }
1137
+ }
1138
+
1139
+ /**
1140
+ * Prune entries that have been stale for more than N days.
1141
+ */
1142
+ async function pruneStaleEntries(maxAgeDays = 30) {
1143
+ try {
1144
+ const { DB_PATHS } = await import('../../core/config.js');
1145
+ // H3 FIX: Use dynamic loader with fallback paths
1146
+ const Database = await loadBetterSqlite3();
1147
+
1148
+ if (!existsSync(DB_PATHS.codeGraph)) {
1149
+ return;
1150
+ }
1151
+
1152
+ const db = new Database(DB_PATHS.codeGraph);
1153
+
1154
+ // Check if stale_since column exists
1155
+ const columns = db.prepare("PRAGMA table_info(entities)").all();
1156
+ const hasStaleColumn = columns.some(col => col.name === 'stale_since');
1157
+
1158
+ if (!hasStaleColumn) {
1159
+ db.close();
1160
+ return;
1161
+ }
1162
+
1163
+ const cutoff = Math.floor(Date.now() / 1000) - (maxAgeDays * 24 * 60 * 60);
1164
+
1165
+ // Count before pruning
1166
+ const countResult = db.prepare(
1167
+ 'SELECT COUNT(*) as count FROM entities WHERE stale_since IS NOT NULL AND stale_since < ?'
1168
+ ).get(cutoff);
1169
+
1170
+ if (countResult.count > 0) {
1171
+ db.prepare('DELETE FROM entities WHERE stale_since IS NOT NULL AND stale_since < ?').run(cutoff);
1172
+ log('INFO', `Pruned ${countResult.count} stale entries (> ${maxAgeDays} days)`);
1173
+ }
1174
+
1175
+ db.close();
1176
+ } catch (err) {
1177
+ // Non-fatal: pruning failure shouldn't break indexing
1178
+ log('ERROR', `Pruning failed: ${err.message}`);
1179
+ }
1180
+ }
1181
+
1182
+
1183
+ /**
1184
+ * Run merkle check and full incremental index if changes detected.
1185
+ * Acquires global index lock to prevent race with manual /index-codebase.
1186
+ * M4 FIX: Tracks skipped cycles to ensure files are not missed when lock contended.
1187
+ */
1188
+ async function runMerkleCheckAndIndex() {
1189
+ // Acquire global lock (prevents race with manual /index-codebase)
1190
+ if (!acquireGlobalIndexLock()) {
1191
+ // M4 FIX: Queue for next cycle instead of silently skipping
1192
+ log('WARN', 'Lock held, queueing merkle check for next cycle');
1193
+ pendingFromSkippedCycle.add('merkle-check-pending');
1194
+ return;
1195
+ }
1196
+
1197
+ try {
1198
+ // M4 FIX: Force full check if we skipped last time
1199
+ const forceFullCheck = pendingFromSkippedCycle.has('merkle-check-pending');
1200
+ if (forceFullCheck) {
1201
+ pendingFromSkippedCycle.delete('merkle-check-pending');
1202
+ log('INFO', 'Running deferred merkle check from skipped cycle');
1203
+ }
1204
+
1205
+ const merkleResult = await performMerkleCheck();
1206
+
1207
+ if (merkleResult.checked && (merkleResult.toIndex.length > 0 || merkleResult.toRemove.length > 0)) {
1208
+ // PHASE 1: Cleanup pass (soft delete stale files)
1209
+ if (merkleResult.toRemove.length > 0) {
1210
+ await markFilesAsStale(merkleResult.toRemove);
1211
+ }
1212
+ await pruneStaleEntries(30); // Prune entries stale > 30 days
1213
+
1214
+ // PHASE 2: Index changed files
1215
+ const indexResult = await runFullIncrementalIndex(
1216
+ merkleResult.toIndex,
1217
+ merkleResult.currentHashes,
1218
+ merkleResult.updateState
1219
+ );
1220
+
1221
+ }
1222
+ } finally {
1223
+ releaseGlobalIndexLock();
1224
+ }
1225
+ }
1226
+
1227
+ // =============================================================================
1228
+ // LEGACY QUEUE PROCESSING (for Claude edits via event-queue-drainer)
1229
+ // =============================================================================
1230
+
1231
+ /**
1232
+ * Process the queue and run indexing
1233
+ * Uses atomic rename-before-read pattern to prevent race conditions.
1234
+ */
1235
+ async function processQueue(options = {}) {
1236
+ const { dryRun = false } = options;
1237
+
1238
+ // CRITICAL: Atomic acquire - rename THEN read (prevents race condition)
1239
+ const acquired = atomicAcquireQueue();
1240
+
1241
+ if (!acquired.success) {
1242
+ // Queue empty or already being processed
1243
+ return { processed: 0, failed: 0, requeued: 0 };
1244
+ }
1245
+
1246
+ // Parse and deduplicate the acquired content
1247
+ const { files, count, malformedCount } = parseQueueContent(acquired.content);
1248
+
1249
+ if (malformedCount > 0) {
1250
+ console.error(`[index-maintainer] Skipped ${malformedCount} malformed queue entries`);
1251
+ }
1252
+
1253
+ if (count === 0) {
1254
+ // All entries were malformed, clean up
1255
+ cleanupProcessingFile();
1256
+ return { processed: 0, failed: 0, requeued: 0 };
1257
+ }
1258
+
1259
+ console.error(`[index-maintainer] Processing ${count} queued files...`);
1260
+
1261
+ // Separate files by retry status
1262
+ const toProcess = [];
1263
+ const toRequeue = [];
1264
+ const toDead = [];
1265
+
1266
+ for (const [filePath, entry] of files) {
1267
+ if (shutdownRequested) {
1268
+ toRequeue.push(entry);
1269
+ continue;
1270
+ }
1271
+
1272
+ const retryCount = entry.retry || 0;
1273
+
1274
+ if (retryCount >= MAX_RETRIES) {
1275
+ // Max retries exceeded - move to dead letter
1276
+ toDead.push(entry);
1277
+ } else {
1278
+ toProcess.push(entry);
1279
+ }
1280
+ }
1281
+
1282
+ // Move exceeded retries to dead letter
1283
+ for (const entry of toDead) {
1284
+ writeToDeadletter(entry, new Error(`Max retries (${MAX_RETRIES}) exceeded`));
1285
+ }
1286
+
1287
+ // Track current batch for graceful shutdown
1288
+ currentBatch = toProcess;
1289
+
1290
+ if (toProcess.length === 0) {
1291
+ cleanupProcessingFile();
1292
+ return { processed: 0, failed: toDead.length, requeued: toRequeue.length };
1293
+ }
1294
+
1295
+ // Extract file paths for indexing
1296
+ const filePaths = toProcess.map(e => e.file_path);
1297
+
1298
+ // Run indexer
1299
+ const result = await runIndexer(filePaths, { dryRun });
1300
+
1301
+ currentBatch = null;
1302
+
1303
+ if (result.success) {
1304
+ // All files processed successfully
1305
+ cleanupProcessingFile();
1306
+
1307
+ // Handle any files that need requeuing (from shutdown)
1308
+ if (toRequeue.length > 0) {
1309
+ requeueEntries(toRequeue);
1310
+ }
1311
+
1312
+ return {
1313
+ processed: filePaths.length,
1314
+ failed: toDead.length,
1315
+ requeued: toRequeue.length
1316
+ };
1317
+ } else {
1318
+ // Indexing failed - requeue with incremented retry count and backoff
1319
+ const failedEntries = toProcess.map(entry => ({
1320
+ ...entry,
1321
+ retry: (entry.retry || 0) + 1,
1322
+ last_error: result.error?.substring(0, 200),
1323
+ last_attempt: new Date().toISOString(),
1324
+ }));
1325
+
1326
+ // Calculate backoff delay for logging
1327
+ const nextRetry = failedEntries[0]?.retry || 1;
1328
+ const delay = getRetryDelay(nextRetry - 1);
1329
+
1330
+ console.error(`[index-maintainer] Requeuing ${failedEntries.length} files for retry #${nextRetry} (next attempt in ${delay}ms)`);
1331
+
1332
+ // Clean up processing file first
1333
+ cleanupProcessingFile();
1334
+
1335
+ // Requeue failed entries
1336
+ requeueEntries([...failedEntries, ...toRequeue]);
1337
+
1338
+ return {
1339
+ processed: 0,
1340
+ failed: toDead.length,
1341
+ requeued: failedEntries.length + toRequeue.length
1342
+ };
1343
+ }
1344
+ }
1345
+
1346
+ /**
1347
+ * Atomic check and process queue - combines peek and process in one atomic operation
1348
+ * H5 FIX: Prevents race where entries are added between peek and process
1349
+ * @param {Object} options - { dryRun: boolean }
1350
+ * @returns {Promise<{ processed: number, failed: number, requeued: number, empty: boolean }>}
1351
+ */
1352
+ async function atomicCheckAndProcessQueue(options = {}) {
1353
+ const { dryRun = false } = options;
1354
+
1355
+ // Atomic acquire returns entries or null if empty
1356
+ const acquired = atomicAcquireQueue();
1357
+
1358
+ if (!acquired.success) {
1359
+ // Queue empty or already being processed
1360
+ return { processed: 0, failed: 0, requeued: 0, empty: true };
1361
+ }
1362
+
1363
+ // Parse and deduplicate the acquired content
1364
+ const { files, count, malformedCount } = parseQueueContent(acquired.content);
1365
+
1366
+ if (malformedCount > 0) {
1367
+ log('WARN', `Skipped ${malformedCount} malformed queue entries`);
1368
+ }
1369
+
1370
+ if (count === 0) {
1371
+ // All entries were malformed, clean up
1372
+ cleanupProcessingFile();
1373
+ return { processed: 0, failed: 0, requeued: 0, empty: true };
1374
+ }
1375
+
1376
+ log('INFO', `Processing ${count} queued files...`);
1377
+
1378
+ // Separate files by retry status
1379
+ const toProcess = [];
1380
+ const toRequeue = [];
1381
+ const toDead = [];
1382
+
1383
+ for (const [filePath, entry] of files) {
1384
+ if (shutdownRequested) {
1385
+ toRequeue.push(entry);
1386
+ continue;
1387
+ }
1388
+
1389
+ const retryCount = entry.retry || 0;
1390
+
1391
+ if (retryCount >= MAX_RETRIES) {
1392
+ // Max retries exceeded - move to dead letter
1393
+ toDead.push(entry);
1394
+ } else {
1395
+ toProcess.push(entry);
1396
+ }
1397
+ }
1398
+
1399
+ // Move exceeded retries to dead letter
1400
+ for (const entry of toDead) {
1401
+ writeToDeadletter(entry, new Error(`Max retries (${MAX_RETRIES}) exceeded`));
1402
+ }
1403
+
1404
+ // Track current batch for graceful shutdown
1405
+ currentBatch = toProcess;
1406
+
1407
+ if (toProcess.length === 0) {
1408
+ cleanupProcessingFile();
1409
+ return { processed: 0, failed: toDead.length, requeued: toRequeue.length, empty: false };
1410
+ }
1411
+
1412
+ // Extract file paths for indexing
1413
+ const filePaths = toProcess.map(e => e.file_path);
1414
+
1415
+ // Run indexer
1416
+ const result = await runIndexer(filePaths, { dryRun });
1417
+
1418
+ currentBatch = null;
1419
+
1420
+ if (result.success) {
1421
+ // All files processed successfully
1422
+ cleanupProcessingFile();
1423
+
1424
+ // Handle any files that need requeuing (from shutdown)
1425
+ if (toRequeue.length > 0) {
1426
+ requeueEntries(toRequeue);
1427
+ }
1428
+
1429
+ return {
1430
+ processed: filePaths.length,
1431
+ failed: toDead.length,
1432
+ requeued: toRequeue.length,
1433
+ empty: false
1434
+ };
1435
+ } else {
1436
+ // Indexing failed - requeue with incremented retry count and backoff
1437
+ const failedEntries = toProcess.map(entry => ({
1438
+ ...entry,
1439
+ retry: (entry.retry || 0) + 1,
1440
+ last_error: result.error?.substring(0, 200),
1441
+ last_attempt: new Date().toISOString(),
1442
+ }));
1443
+
1444
+ // Calculate backoff delay for logging
1445
+ const nextRetry = failedEntries[0]?.retry || 1;
1446
+ const delay = getRetryDelay(nextRetry - 1);
1447
+
1448
+ log('WARN', `Requeuing ${failedEntries.length} files for retry #${nextRetry} (next attempt in ${delay}ms)`);
1449
+
1450
+ // Clean up processing file first
1451
+ cleanupProcessingFile();
1452
+
1453
+ // Requeue failed entries
1454
+ requeueEntries([...failedEntries, ...toRequeue]);
1455
+
1456
+ return {
1457
+ processed: 0,
1458
+ failed: toDead.length,
1459
+ requeued: failedEntries.length + toRequeue.length,
1460
+ empty: false
1461
+ };
1462
+ }
1463
+ }
1464
+
1465
+ /**
1466
+ * Recover from a previous crash (processing file left behind)
1467
+ * E5 FIX: Differentiate recovery success from failure with structured return
1468
+ * @returns {{ recovered: boolean, count?: number, reason: string }}
1469
+ */
1470
+ async function recoverProcessingFile() {
1471
+ if (!existsSync(PROCESSING_FILE)) {
1472
+ return { recovered: false, reason: 'no_processing_file' };
1473
+ }
1474
+
1475
+ log('INFO', 'Found .processing file from previous crash, recovering...');
1476
+
1477
+ try {
1478
+ const content = readFileSync(PROCESSING_FILE, 'utf-8');
1479
+ const lines = content.split('\n').filter(l => l.trim());
1480
+ const entries = [];
1481
+
1482
+ for (const line of lines) {
1483
+ try {
1484
+ const entry = JSON.parse(line);
1485
+ if (entry.file_path) {
1486
+ entries.push(entry);
1487
+ }
1488
+ } catch {
1489
+ // Skip malformed lines
1490
+ }
1491
+ }
1492
+
1493
+ if (!Array.isArray(entries) || entries.length === 0) {
1494
+ // Clean up empty/invalid processing file
1495
+ try { unlinkSync(PROCESSING_FILE); } catch {}
1496
+ return { recovered: false, reason: 'empty_processing_file' };
1497
+ }
1498
+
1499
+ // Requeue recovered entries (they may have been partially processed)
1500
+ requeueEntries(entries);
1501
+
1502
+ // Clean up processing file
1503
+ unlinkSync(PROCESSING_FILE);
1504
+
1505
+ log('INFO', `Recovery complete: ${entries.length} entries requeued`);
1506
+ return { recovered: true, count: entries.length, reason: 'success' };
1507
+ } catch (err) {
1508
+ log('ERROR', `Recovery failed: ${err.message}`);
1509
+ // Try to clean up the corrupt processing file
1510
+ try { unlinkSync(PROCESSING_FILE); } catch {}
1511
+ return { recovered: false, reason: err.message };
1512
+ }
1513
+ }
1514
+
1515
+ // === Main Loop ===
1516
+
1517
+ async function main() {
1518
+ const runOnce = process.argv.includes('--once');
1519
+ const dryRun = process.argv.includes('--dry-run');
1520
+ const merkleOnce = process.argv.includes('--merkle-once');
1521
+
1522
+ // L1 FIX: Updated version to v3
1523
+ log('INFO', 'Starting index maintainer daemon v3...');
1524
+
1525
+ // Ensure .sweet-search directory exists
1526
+ ensureDataDir();
1527
+
1528
+ // H1 FIX: Clean up potentially stale global lock on startup
1529
+ cleanupStaleGlobalLock();
1530
+
1531
+ // Acquire lock (skip for --once mode)
1532
+ // P3 FIX: acquireLock is now async
1533
+ if (!runOnce && !merkleOnce) {
1534
+ if (!(await acquireLock())) {
1535
+ log('INFO', 'Another instance is running, exiting.');
1536
+ process.exit(0); // Exit cleanly, not an error
1537
+ }
1538
+ log('INFO', `Lock acquired (PID: ${process.pid})`);
1539
+ }
1540
+
1541
+ // Recover from previous crash
1542
+ // E5 FIX: Use structured return to differentiate success from failure
1543
+ const recovery = await recoverProcessingFile();
1544
+ if (recovery.recovered) {
1545
+ log('INFO', `Recovered ${recovery.count} entries from previous crash`);
1546
+ }
1547
+
1548
+ // --merkle-once: Run a single merkle check and exit (for testing)
1549
+ if (merkleOnce) {
1550
+ log('INFO', 'Running single merkle check (--merkle-once)...');
1551
+ await runMerkleCheckAndIndex();
1552
+ log('INFO', 'Merkle check complete');
1553
+ process.exit(0);
1554
+ }
1555
+
1556
+ if (runOnce) {
1557
+ const result = await processQueue({ dryRun });
1558
+ log('INFO', `Processed: ${result.processed}, Failed: ${result.failed}, Requeued: ${result.requeued} (--once mode)`);
1559
+ process.exit(0);
1560
+ }
1561
+
1562
+ // Graceful shutdown handlers
1563
+ // M8 FIX: Clear startupTimeout on shutdown
1564
+ const shutdown = () => {
1565
+ log('INFO', 'Shutdown requested...');
1566
+ shutdownRequested = true;
1567
+ // M8 FIX: Cancel deferred startup timeout if pending
1568
+ if (startupTimeout) {
1569
+ clearTimeout(startupTimeout);
1570
+ startupTimeout = null;
1571
+ }
1572
+ };
1573
+
1574
+ process.on('SIGTERM', shutdown);
1575
+ process.on('SIGINT', shutdown);
1576
+
1577
+ // Clean up lock on exit
1578
+ process.on('exit', () => {
1579
+ releaseLock();
1580
+ log('INFO', 'Lock released, goodbye.');
1581
+ });
1582
+
1583
+ // Refresh lock periodically to prevent stale detection
1584
+ const lockRefreshInterval = setInterval(() => {
1585
+ if (!shutdownRequested) {
1586
+ if (!refreshLock()) {
1587
+ // Lost lock ownership - initiate graceful shutdown
1588
+ console.error('[index-maintainer] Lost lock, initiating shutdown');
1589
+ process.exit(1);
1590
+ }
1591
+ }
1592
+ }, LOCK_REFRESH_INTERVAL);
1593
+
1594
+ // === DEFERRED FIRST MERKLE CHECK (v3) ===
1595
+ // Wait STARTUP_DELAY before first merkle check to avoid blocking session startup
1596
+ // This ensures zero latency for Claude Code operations during warm-up
1597
+ // M8 FIX: Store timeout reference for cancellation on shutdown
1598
+ // E1 FIX: Wrap async callback in try-catch to prevent unhandled promise rejections
1599
+ startupTimeout = setTimeout(async () => {
1600
+ try {
1601
+ if (shutdownRequested) return;
1602
+ startupTimeout = null; // Clear reference after execution
1603
+ log('INFO', `Running deferred first merkle check (after ${STARTUP_DELAY}ms delay)...`);
1604
+ await runMerkleCheckAndIndex();
1605
+ } catch (err) {
1606
+ log('ERROR', `Deferred merkle check failed: ${err.message}`);
1607
+ }
1608
+ }, STARTUP_DELAY);
1609
+
1610
+ // === PERIODIC MERKLE CHECK (v3) ===
1611
+ // Every MERKLE_CHECK_INTERVAL, scan filesystem for external changes (IDE edits, git operations)
1612
+ // This is separate from the queue-based processing of Claude Code edits
1613
+ // E1 FIX: Wrap async callback in try-catch to prevent unhandled promise rejections
1614
+ const merkleCheckInterval = setInterval(async () => {
1615
+ try {
1616
+ if (shutdownRequested) return;
1617
+ log('INFO', 'Running periodic merkle check...');
1618
+ await runMerkleCheckAndIndex();
1619
+ } catch (err) {
1620
+ log('ERROR', `Periodic merkle check failed: ${err.message}`);
1621
+ }
1622
+ }, MERKLE_CHECK_INTERVAL);
1623
+
1624
+ // Main loop: poll queue every POLL_INTERVAL (for Claude Code edits via hook)
1625
+ // H5 FIX: Use atomicCheckAndProcessQueue instead of separate peek+process
1626
+ let consecutiveEmptyPolls = 0;
1627
+
1628
+ while (!shutdownRequested) {
1629
+ // H5 FIX: Atomic queue check and process (prevents race between peek and acquire)
1630
+ const result = await atomicCheckAndProcessQueue({ dryRun });
1631
+
1632
+ if (result.empty) {
1633
+ consecutiveEmptyPolls++;
1634
+
1635
+ // Log occasionally when idle
1636
+ if (consecutiveEmptyPolls === 1 || consecutiveEmptyPolls % 10 === 0) {
1637
+ log('DEBUG', `Queue empty, sleeping (poll #${consecutiveEmptyPolls})`);
1638
+ }
1639
+ } else {
1640
+ consecutiveEmptyPolls = 0;
1641
+
1642
+ if (result.processed > 0 || result.failed > 0 || result.requeued > 0) {
1643
+ log('INFO', `Batch complete - Processed: ${result.processed}, Failed: ${result.failed}, Requeued: ${result.requeued}`);
1644
+ }
1645
+ }
1646
+
1647
+ // Wait before next poll
1648
+ await new Promise(r => setTimeout(r, POLL_INTERVAL));
1649
+ }
1650
+
1651
+ // Graceful shutdown: preserve any in-flight batch
1652
+ if (currentBatch && currentBatch.length > 0) {
1653
+ log('WARN', `Preserving ${currentBatch.length} in-flight entries`);
1654
+ requeueEntries(currentBatch);
1655
+ }
1656
+
1657
+ // Cleanup
1658
+ clearInterval(lockRefreshInterval);
1659
+ clearInterval(merkleCheckInterval);
1660
+ log('INFO', 'Shutdown complete');
1661
+ }
1662
+
1663
+ // Only run main() when executed directly (not when imported for testing)
1664
+ // This allows the exported functions to be tested without starting the daemon
1665
+ const isMainModule = import.meta.url === `file://${process.argv[1]}` ||
1666
+ process.argv[1]?.endsWith('index-maintainer.mjs');
1667
+
1668
+ if (isMainModule) {
1669
+ main().catch(err => {
1670
+ console.error(`[index-maintainer] Fatal error: ${err.message}`);
1671
+ releaseLock();
1672
+ process.exit(1);
1673
+ });
1674
+ }