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.
- package/LICENSE +190 -0
- package/NOTICE +23 -0
- package/core/cli.js +51 -0
- package/core/config.js +27 -0
- package/core/embedding/embedding-cache.js +467 -0
- package/core/embedding/embedding-local-model.js +845 -0
- package/core/embedding/embedding-remote.js +492 -0
- package/core/embedding/embedding-service.js +712 -0
- package/core/embedding/embedding-telemetry.js +219 -0
- package/core/embedding/index.js +40 -0
- package/core/graph/community-detector.js +294 -0
- package/core/graph/graph-expansion.js +839 -0
- package/core/graph/graph-extractor.js +2304 -0
- package/core/graph/graph-search.js +2148 -0
- package/core/graph/hcgs-generator.js +666 -0
- package/core/graph/index.js +16 -0
- package/core/graph/leiden-algorithm.js +547 -0
- package/core/graph/relationship-resolver.js +366 -0
- package/core/graph/repo-map.js +408 -0
- package/core/graph/summary-manager.js +549 -0
- package/core/indexing/artifact-builder.js +1054 -0
- package/core/indexing/ast-chunker.js +709 -0
- package/core/indexing/chunking/chunk-builder.js +170 -0
- package/core/indexing/chunking/markdown-chunker.js +503 -0
- package/core/indexing/chunking/plaintext-chunker.js +104 -0
- package/core/indexing/dedup/dedup-phase.js +159 -0
- package/core/indexing/dedup/exemplar-selector.js +65 -0
- package/core/indexing/document-chunker.js +56 -0
- package/core/indexing/incremental-parser.js +390 -0
- package/core/indexing/incremental-tracker.js +761 -0
- package/core/indexing/index-codebase-v21.js +472 -0
- package/core/indexing/index-maintainer.mjs +1674 -0
- package/core/indexing/index.js +90 -0
- package/core/indexing/indexer-ann.js +1077 -0
- package/core/indexing/indexer-build.js +742 -0
- package/core/indexing/indexer-phases.js +800 -0
- package/core/indexing/indexer-pool.js +764 -0
- package/core/indexing/indexer-sparse-gram.js +98 -0
- package/core/indexing/indexer-utils.js +536 -0
- package/core/indexing/indexer-worker.js +148 -0
- package/core/indexing/li-skip-policy.js +225 -0
- package/core/indexing/merkle-tracker.js +244 -0
- package/core/indexing/model-pool.js +166 -0
- package/core/infrastructure/code-graph-repository.js +120 -0
- package/core/infrastructure/codebase-repository.js +131 -0
- package/core/infrastructure/config/dedup.js +54 -0
- package/core/infrastructure/config/embedding.js +298 -0
- package/core/infrastructure/config/graph.js +80 -0
- package/core/infrastructure/config/index.js +82 -0
- package/core/infrastructure/config/indexing.js +8 -0
- package/core/infrastructure/config/platform.js +254 -0
- package/core/infrastructure/config/ranking.js +221 -0
- package/core/infrastructure/config/search.js +396 -0
- package/core/infrastructure/config/translation.js +89 -0
- package/core/infrastructure/config/vector-store.js +114 -0
- package/core/infrastructure/constants.js +86 -0
- package/core/infrastructure/coreml-cascade.js +909 -0
- package/core/infrastructure/coreml-cascade.json +46 -0
- package/core/infrastructure/coreml-provider.js +81 -0
- package/core/infrastructure/db-utils.js +69 -0
- package/core/infrastructure/dedup-hashing.js +83 -0
- package/core/infrastructure/hardware-capability.js +332 -0
- package/core/infrastructure/index.js +104 -0
- package/core/infrastructure/language-patterns/maps.js +121 -0
- package/core/infrastructure/language-patterns/registry-core.js +323 -0
- package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
- package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
- package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
- package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
- package/core/infrastructure/language-patterns/registry.js +19 -0
- package/core/infrastructure/language-patterns.js +141 -0
- package/core/infrastructure/llm-provider.js +733 -0
- package/core/infrastructure/manifest.json +46 -0
- package/core/infrastructure/maxsim.wasm +0 -0
- package/core/infrastructure/model-fetcher.js +423 -0
- package/core/infrastructure/model-registry.js +214 -0
- package/core/infrastructure/native-inference.js +587 -0
- package/core/infrastructure/native-resolver.js +187 -0
- package/core/infrastructure/native-sparse-gram.js +257 -0
- package/core/infrastructure/native-tokenizer.js +160 -0
- package/core/infrastructure/onnx-mutex.js +45 -0
- package/core/infrastructure/onnx-session-utils.js +261 -0
- package/core/infrastructure/ort-pipeline.js +111 -0
- package/core/infrastructure/project-detector.js +102 -0
- package/core/infrastructure/quantization.js +410 -0
- package/core/infrastructure/simd-distance.js +502 -0
- package/core/infrastructure/simd-distance.wasm +0 -0
- package/core/infrastructure/tree-sitter-provider.js +665 -0
- package/core/infrastructure/webgpu-maxsim.js +222 -0
- package/core/query/index.js +35 -0
- package/core/query/intent-detector.js +201 -0
- package/core/query/intent-router.js +156 -0
- package/core/query/query-router-catboost.js +222 -0
- package/core/query/query-router-ml.js +266 -0
- package/core/query/query-router.js +213 -0
- package/core/ranking/cascaded-scorer.js +379 -0
- package/core/ranking/flashrank.js +810 -0
- package/core/ranking/index.js +49 -0
- package/core/ranking/late-interaction-index.js +2383 -0
- package/core/ranking/late-interaction-model.js +812 -0
- package/core/ranking/local-reranker.js +374 -0
- package/core/ranking/mmr.js +379 -0
- package/core/ranking/quality-scorer.js +363 -0
- package/core/search/context-expander.js +1167 -0
- package/core/search/dedup/sibling-expander.js +327 -0
- package/core/search/index.js +16 -0
- package/core/search/search-boost.js +259 -0
- package/core/search/search-cli.js +544 -0
- package/core/search/search-format.js +282 -0
- package/core/search/search-fusion.js +327 -0
- package/core/search/search-hybrid.js +204 -0
- package/core/search/search-pattern-chunks.js +337 -0
- package/core/search/search-pattern-planner.js +439 -0
- package/core/search/search-pattern-prefilter.js +412 -0
- package/core/search/search-pattern-ripgrep.js +663 -0
- package/core/search/search-pattern.js +463 -0
- package/core/search/search-postprocess.js +452 -0
- package/core/search/search-semantic.js +706 -0
- package/core/search/search-server.js +554 -0
- package/core/search/session-daemon-prewarm.mjs +164 -0
- package/core/search/session-warmup.js +595 -0
- package/core/search/sweet-search.js +632 -0
- package/core/search/warmup-metrics.js +532 -0
- package/core/start-server.js +6 -0
- package/core/training/query-router/features/extractor.js +762 -0
- package/core/training/query-router/features/multilingual-patterns.js +431 -0
- package/core/training/query-router/features/text-segmenter.js +303 -0
- package/core/training/query-router/features/unicode-utils.js +383 -0
- package/core/training/query-router/output/v45_router_d4.js +11521 -0
- package/core/training/query-router/output/v46_router_d4.js +11498 -0
- package/core/vector-store/binary-heap.js +227 -0
- package/core/vector-store/binary-hnsw-index.js +1004 -0
- package/core/vector-store/float-vector-store.js +234 -0
- package/core/vector-store/hnsw-index.js +580 -0
- package/core/vector-store/index.js +39 -0
- package/core/vector-store/seismic-index.js +498 -0
- package/core/vocabulary/index.js +84 -0
- package/core/vocabulary/vocab-constants.js +20 -0
- package/core/vocabulary/vocab-miner-extractors.js +375 -0
- package/core/vocabulary/vocab-miner-nl.js +404 -0
- package/core/vocabulary/vocab-miner-utils.js +146 -0
- package/core/vocabulary/vocab-miner.js +574 -0
- package/core/vocabulary/vocab-prewarm-cli.js +110 -0
- package/core/vocabulary/vocab-ranker.js +492 -0
- package/core/vocabulary/vocab-warmer.js +523 -0
- package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
- package/core/vocabulary/vocabulary-utils.js +704 -0
- package/crates/wasm-router/pkg/package.json +13 -0
- package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
- package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
- package/mcp/config-gen.js +121 -0
- package/mcp/server.js +335 -0
- package/mcp/tool-handlers.js +476 -0
- package/package.json +131 -9
- package/scripts/benchmark-harness.js +794 -0
- package/scripts/init.js +1058 -0
- package/scripts/smoke-test.js +435 -0
- package/scripts/uninstall.js +478 -0
- 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
|
+
}
|