opencode-rag-plugin 1.19.5 → 1.19.8

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/dist/watcher.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import chokidar from "chokidar";
7
7
  import path from "node:path";
8
- import { writeFileSync, unlinkSync, existsSync } from "node:fs";
8
+ import { writeFileSync, unlinkSync, existsSync, readFileSync, openSync, closeSync } from "node:fs";
9
9
  import { appendDebugLog } from "./core/fileLogger.js";
10
10
  import { isCorruptionError } from "./vectorstore/lancedb.js";
11
11
  import { createWatchPassScheduler, createWatchIgnore, runIndexPass, } from "./indexer.js";
@@ -18,6 +18,81 @@ function writeWatcherStatus(storePath, status) {
18
18
  // silently ignore write errors
19
19
  }
20
20
  }
21
+ // ── Cross-process watcher claim lock ────────────────────────────────────────
22
+ // `index.lock` only serializes individual index passes — it does NOT stop N
23
+ // processes (N OpenCode sessions, or a session + `opencode-rag index --watch`)
24
+ // from each spawning their own chokidar watcher for the same workspace. Every
25
+ // extra watcher fires its own fire-and-forget initial pass (all but one skip
26
+ // the pass lock and then retry every 30s) and burns file-watcher resources.
27
+ // The claim lock below guarantees at most ONE active watcher per workspace:
28
+ // the first process to claim it runs the watcher; the others stay dormant and
29
+ // periodically re-check so they take over if the owning process exits.
30
+ const WATCHER_LOCK_FILE = "watcher.lock";
31
+ /** How often a dormant indexer re-checks whether the watcher lock is free. */
32
+ const WATCHER_RECHECK_MS = 60_000;
33
+ function isPidAlive(pid) {
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ function readWatcherLock(storePath) {
43
+ try {
44
+ const parsed = JSON.parse(readFileSync(path.join(storePath, WATCHER_LOCK_FILE), "utf-8"));
45
+ return typeof parsed.pid === "number" ? parsed : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ /**
52
+ * Atomically claim the watcher lock for a workspace. Returns true only if
53
+ * this process is now the active watcher. A stale lock (dead PID) or an
54
+ * unreadable/corrupt lock file is cleared and re-claimed.
55
+ */
56
+ export function tryAcquireWatcherLock(storePath) {
57
+ const lockPath = path.join(storePath, WATCHER_LOCK_FILE);
58
+ for (let attempt = 0; attempt < 2; attempt++) {
59
+ const existing = readWatcherLock(storePath);
60
+ if (!existing || !isPidAlive(existing.pid)) {
61
+ // Stale, corrupt, or missing lock — clear it and claim atomically
62
+ // (O_EXCL create so two racing processes cannot both win).
63
+ try {
64
+ unlinkSync(lockPath);
65
+ }
66
+ catch { /* may not exist */ }
67
+ try {
68
+ const fd = openSync(lockPath, "wx");
69
+ try {
70
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }), "utf-8");
71
+ }
72
+ finally {
73
+ closeSync(fd);
74
+ }
75
+ return true;
76
+ }
77
+ catch {
78
+ // Someone else claimed it between our unlink and create — retry once.
79
+ continue;
80
+ }
81
+ }
82
+ return false;
83
+ }
84
+ return false;
85
+ }
86
+ /** Release the watcher lock — only if this process owns it. */
87
+ export function releaseWatcherLock(storePath) {
88
+ const lock = readWatcherLock(storePath);
89
+ if (lock?.pid === process.pid) {
90
+ try {
91
+ unlinkSync(path.join(storePath, WATCHER_LOCK_FILE));
92
+ }
93
+ catch { /* ignore */ }
94
+ }
95
+ }
21
96
  /**
22
97
  * Create a background file watcher that automatically re-indexes the
23
98
  * workspace when files change. Uses chokidar for file system events and
@@ -29,110 +104,120 @@ function writeWatcherStatus(storePath, status) {
29
104
  */
30
105
  export function createBackgroundIndexer(options) {
31
106
  const { cwd, storePath, config, store, embedder, logFilePath, logLevel, keywordIndex, descriptionProvider, dimension } = options;
32
- writeWatcherStatus(storePath, { running: false, lastRunAt: undefined });
33
- const ac = new AbortController();
34
- const updateStatus = (partial) => {
35
- writeWatcherStatus(storePath, { running: false, lastRunAt: undefined, ...partial });
36
- };
37
- const runPass = async (filterPaths) => {
38
- updateStatus({ running: true, lastRunAt: Date.now() });
39
- try {
40
- const stats = await runIndexPass({
41
- cwd,
42
- storePath,
43
- config,
44
- store,
45
- embedder,
46
- keywordIndex,
47
- descriptionProvider,
48
- dimension,
49
- filterPaths,
50
- abortSignal: ac.signal,
51
- logger: {
52
- info: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message }, logLevel),
53
- warn: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message }, logLevel),
54
- debug: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message: `DEBUG: ${message}`, severity: "debug" }, logLevel),
55
- },
56
- });
57
- // A lock-skipped pass did NO work — retry shortly so the workspace
58
- // does not stay unindexed until the next file event.
59
- if (stats.skipped) {
107
+ const autoIndexCfg = config.openCode.autoIndex ?? { enabled: false, debounceMs: 5000, intervalMs: 300000 };
108
+ let active = false;
109
+ let recheckTimer;
110
+ let stopActive;
111
+ /** Start the chokidar watcher, debounced scheduler, and initial pass. */
112
+ const startActive = () => {
113
+ if (active)
114
+ return;
115
+ active = true;
116
+ writeWatcherStatus(storePath, { running: false, lastRunAt: undefined });
117
+ const ac = new AbortController();
118
+ const updateStatus = (partial) => {
119
+ writeWatcherStatus(storePath, { running: false, lastRunAt: undefined, ...partial });
120
+ };
121
+ const runPass = async (filterPaths) => {
122
+ updateStatus({ running: true, lastRunAt: Date.now() });
123
+ try {
124
+ const stats = await runIndexPass({
125
+ cwd,
126
+ storePath,
127
+ config,
128
+ store,
129
+ embedder,
130
+ keywordIndex,
131
+ descriptionProvider,
132
+ dimension,
133
+ filterPaths,
134
+ abortSignal: ac.signal,
135
+ logger: {
136
+ info: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message }, logLevel),
137
+ warn: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message }, logLevel),
138
+ debug: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message: `DEBUG: ${message}`, severity: "debug" }, logLevel),
139
+ },
140
+ });
141
+ // A lock-skipped pass did NO work — retry shortly so the workspace
142
+ // does not stay unindexed until the next file event.
143
+ if (stats.skipped) {
144
+ appendDebugLog(logFilePath, {
145
+ scope: "autoIndex",
146
+ message: "Index pass skipped (another pass holds the lock) — retrying in 30s",
147
+ }, logLevel);
148
+ if (!ac.signal.aborted) {
149
+ setTimeout(() => {
150
+ if (!ac.signal.aborted)
151
+ scheduler.notifyChange(filterPaths);
152
+ }, 30_000).unref();
153
+ }
154
+ }
155
+ updateStatus({ running: false, lastRunAt: Date.now() });
156
+ }
157
+ catch (err) {
60
158
  appendDebugLog(logFilePath, {
61
159
  scope: "autoIndex",
62
- message: "Index pass skipped (another pass holds the lock) — retrying in 30s",
160
+ message: "Watch reindex pass failed",
161
+ error: err,
63
162
  }, logLevel);
64
- if (!ac.signal.aborted) {
65
- setTimeout(() => {
66
- if (!ac.signal.aborted)
67
- scheduler.notifyChange(filterPaths);
68
- }, 30_000).unref();
163
+ if (isCorruptionError(err)) {
164
+ appendDebugLog(logFilePath, {
165
+ scope: "autoIndex",
166
+ message: "Corruption detected — run 'opencode-rag index --force' to rebuild manually",
167
+ }, logLevel);
69
168
  }
169
+ updateStatus({ running: false, lastRunAt: Date.now() });
70
170
  }
71
- updateStatus({ running: false, lastRunAt: Date.now() });
72
- }
73
- catch (err) {
171
+ };
172
+ // Fire-and-forget initial index pass
173
+ runPass().catch((err) => {
74
174
  appendDebugLog(logFilePath, {
75
175
  scope: "autoIndex",
76
- message: "Watch reindex pass failed",
176
+ message: "Initial index pass failed",
77
177
  error: err,
78
178
  }, logLevel);
79
- if (isCorruptionError(err)) {
80
- appendDebugLog(logFilePath, {
81
- scope: "autoIndex",
82
- message: "Corruption detected — run 'opencode-rag index --force' to rebuild manually",
83
- }, logLevel);
84
- }
85
- updateStatus({ running: false, lastRunAt: Date.now() });
86
- }
87
- };
88
- // Fire-and-forget initial index pass
89
- runPass().catch((err) => {
90
- appendDebugLog(logFilePath, {
91
- scope: "autoIndex",
92
- message: "Initial index pass failed",
93
- error: err,
94
- }, logLevel);
95
- });
96
- const autoIndexCfg = config.openCode.autoIndex ?? { enabled: false, debounceMs: 5000, intervalMs: 300000 };
97
- const scheduler = createWatchPassScheduler(runPass, (error) => {
98
- const message = error.message || String(error);
99
- appendDebugLog(logFilePath, {
100
- scope: "autoIndex",
101
- message: `Watch reindex failed: ${message}`,
102
- error,
103
- }, logLevel);
104
- }, autoIndexCfg.debounceMs);
105
- const watcher = chokidar.watch(cwd, {
106
- ignored: createWatchIgnore(cwd, config, storePath),
107
- ignoreInitial: true,
108
- persistent: true,
109
- });
110
- const handleChange = (filePath) => scheduler.notifyChange(filePath ? [filePath] : undefined);
111
- watcher.on("add", handleChange);
112
- watcher.on("change", handleChange);
113
- watcher.on("unlink", handleChange);
114
- watcher.on("unlinkDir", handleChange);
115
- watcher.on("addDir", handleChange);
116
- watcher.on("error", (error) => {
117
- appendDebugLog(logFilePath, {
118
- scope: "autoIndex",
119
- message: `Watcher error: ${error.message}`,
120
- error,
121
- }, logLevel);
122
- });
123
- // Periodic timer: only needed for git backend (chokidar gets real FS events).
124
- // Note: with git mode BOTH backends run — the scheduler coalesces redundant
125
- // passes into one, so this only adds a safety net for missed events.
126
- const watcherBackend = autoIndexCfg.watcher ?? "chokidar";
127
- const periodicTimer = watcherBackend === "git"
128
- ? setInterval(() => {
129
- scheduler.notifyChange();
130
- }, autoIndexCfg.intervalMs)
131
- : undefined;
132
- // Never keep the process alive just for the periodic scan
133
- periodicTimer?.unref();
134
- return {
135
- async close() {
179
+ });
180
+ const scheduler = createWatchPassScheduler(runPass, (error) => {
181
+ const message = error.message || String(error);
182
+ appendDebugLog(logFilePath, {
183
+ scope: "autoIndex",
184
+ message: `Watch reindex failed: ${message}`,
185
+ error,
186
+ }, logLevel);
187
+ }, autoIndexCfg.debounceMs);
188
+ const watcher = chokidar.watch(cwd, {
189
+ ignored: createWatchIgnore(cwd, config, storePath),
190
+ ignoreInitial: true,
191
+ persistent: true,
192
+ });
193
+ const handleChange = (filePath) => scheduler.notifyChange(filePath ? [filePath] : undefined);
194
+ watcher.on("add", handleChange);
195
+ watcher.on("change", handleChange);
196
+ watcher.on("unlink", handleChange);
197
+ watcher.on("unlinkDir", handleChange);
198
+ watcher.on("addDir", handleChange);
199
+ watcher.on("error", (error) => {
200
+ appendDebugLog(logFilePath, {
201
+ scope: "autoIndex",
202
+ message: `Watcher error: ${error.message}`,
203
+ error,
204
+ }, logLevel);
205
+ });
206
+ // Periodic timer: only needed for git backend (chokidar gets real FS events).
207
+ // Note: with git mode BOTH backends run — the scheduler coalesces redundant
208
+ // passes into one, so this only adds a safety net for missed events.
209
+ const watcherBackend = autoIndexCfg.watcher ?? "chokidar";
210
+ const periodicTimer = watcherBackend === "git"
211
+ ? setInterval(() => {
212
+ scheduler.notifyChange();
213
+ }, autoIndexCfg.intervalMs)
214
+ : undefined;
215
+ // Never keep the process alive just for the periodic scan
216
+ periodicTimer?.unref();
217
+ stopActive = async () => {
218
+ if (!active)
219
+ return;
220
+ active = false;
136
221
  if (periodicTimer)
137
222
  clearInterval(periodicTimer);
138
223
  ac.abort();
@@ -154,10 +239,51 @@ export function createBackgroundIndexer(options) {
154
239
  }
155
240
  catch { /* ignore */ }
156
241
  }
242
+ releaseWatcherLock(storePath);
157
243
  appendDebugLog(logFilePath, {
158
244
  scope: "autoIndex",
159
245
  message: "Background indexer shut down",
160
246
  });
247
+ };
248
+ };
249
+ if (tryAcquireWatcherLock(storePath)) {
250
+ startActive();
251
+ }
252
+ else {
253
+ // Another process already runs the watcher for this workspace (a second
254
+ // OpenCode session, `opencode-rag index --watch`, …). Stay dormant and
255
+ // re-check periodically so this process takes over once the owner exits.
256
+ const owner = readWatcherLock(storePath);
257
+ appendDebugLog(logFilePath, {
258
+ scope: "autoIndex",
259
+ message: owner?.pid
260
+ ? `Watcher already running for this workspace (PID ${owner.pid}) — skipping duplicate watcher`
261
+ : "Watcher already running for this workspace — skipping duplicate watcher",
262
+ }, logLevel);
263
+ recheckTimer = setInterval(() => {
264
+ if (!active && tryAcquireWatcherLock(storePath)) {
265
+ appendDebugLog(logFilePath, {
266
+ scope: "autoIndex",
267
+ message: "Previous watcher released this workspace — taking over",
268
+ }, logLevel);
269
+ if (recheckTimer) {
270
+ clearInterval(recheckTimer);
271
+ recheckTimer = undefined;
272
+ }
273
+ startActive();
274
+ }
275
+ }, WATCHER_RECHECK_MS);
276
+ recheckTimer.unref();
277
+ }
278
+ return {
279
+ async close() {
280
+ if (recheckTimer) {
281
+ clearInterval(recheckTimer);
282
+ recheckTimer = undefined;
283
+ }
284
+ if (stopActive) {
285
+ await stopActive();
286
+ }
161
287
  },
162
288
  };
163
289
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-rag-plugin",
3
- "version": "1.19.5",
3
+ "version": "1.19.8",
4
4
  "description": "OpenCode plugin for local-first RAG-based semantic code search",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin-entry.js",