rag-memory-epf-mcp 6.3.0 → 6.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -154,6 +154,46 @@ storeDocument(id, content, metadata)
154
154
 
155
155
  ## Changelog
156
156
 
157
+ ### v6.3.2 (2026-09-25)
158
+
159
+ - **Changed — on macOS the engine takes `-wal` and `-shm` out of cloud sync.** It sets
160
+ `com.apple.fileprovider.ignore#P = 1` on both files after every open (SQLite recreates them after the
161
+ last connection closes, so once is not enough). Measured with Google Drive for desktop: the client
162
+ treated `-wal` as a synced item and put a new file at the path while the engine held the old one open
163
+ (`lsof -p` showed the descriptor inside `FileProvider/…/wharf/delete/`); from then on every write
164
+ failed with `SQLITE_IOERR` ("disk I/O error") until the engine restarted — 10 s to 3 min after start,
165
+ with one engine attached. With the attribute set, the same live database kept the same `-wal` for
166
+ 25 min of use. The main database file still syncs. Other platforms: no-op.
167
+ - **Changed — a swapped sidecar is detected and the connection reopened.** The idle tick compares the
168
+ `-wal`/`-shm` inodes with the ones recorded at open (two consecutive mismatches) and reopens with the
169
+ same configuration; an `SQLITE_IOERR*` from a checkpoint or a tool call reopens too. A tool that hit
170
+ the I/O error is retried once only if repeating it changes nothing (reads, `createEntities`,
171
+ `createRelations`, `addObservations`, `deleteEntities`, `deleteRelations`); other tools get the
172
+ error back after the reopen. A reopen waits for running tool calls, is not done while shutting
173
+ down, and is braked after 3 in 10 s. A failed reopen retries with backoff and, after 10 failures,
174
+ shuts the server down so the host shows it as failed. A reconciliation cut by a reopen reruns.
175
+ - Known limit: writes acknowledged in the last ~1 s before a swap can be lost if the old file is no
176
+ longer readable (the engine tries one checkpoint on the old handle first and logs `walsz-before=`).
177
+ - The 6.3.1 `wal_checkpoint(TRUNCATE)` is unchanged. `RESTART` (± `journal_size_limit`) was measured and
178
+ rejected: after SIGKILL its WAL replayed onto a foreign main file 3/3 times.
179
+ - No tool, argument, return shape or schema changed. Spec: `specs/changes/wal-sidecar-guard/`.
180
+
181
+ ### v6.3.1
182
+
183
+ - **Changed — the engine empties the WAL while it is idle.** It runs `wal_checkpoint(TRUNCATE)` itself:
184
+ on a 1 s tick whenever the `-wal` file has bytes in it, ~200 ms after a tool call, first thing in the
185
+ signal handler, and before closing. SQLite folds the WAL only when the *last* connection closes and
186
+ only if that close gets to run; with several engines on one file, or a host that kills the engine
187
+ (measured: codex-cli 0.155.1 sends SIGTERM and SIGKILLs ~185 ms later; Windows terminates children
188
+ outright), frames stayed in the WAL — on 6.3.0 a fresh database sat at 4 KB main + 663 KB WAL for as
189
+ long as the engine ran. In a cloud-synced folder such a WAL can be replayed onto a main file written
190
+ by another machine. A write made in the last ~1 s before a hard kill can still be in the WAL; a 0-byte
191
+ `-wal` and a `-shm` left behind by a hard kill are harmless. Running engines on two machines against
192
+ the same synced file at the same time is still unsafe.
193
+ - **Changed — SIGHUP and SIGBREAK shut the engine down cleanly.** Closing the terminal or multiplexer
194
+ pane used to kill the process by default signal action, before the database was closed.
195
+ - No tool, argument, return shape or schema changed.
196
+
157
197
  ### v6.3.0 (2026-09-16)
158
198
 
159
199
  - **Changed — a relative `DB_FILE_PATH` now resolves against the server's working directory.** It used
package/dist/index.d.ts CHANGED
@@ -51,6 +51,7 @@ interface DetailedContext {
51
51
  export declare class SyncCasConflictError extends Error {
52
52
  constructor(documentId: string);
53
53
  }
54
+ export declare function isIoError(e: unknown): boolean;
54
55
  export declare function setSyncFaultPoint(point: string | null, fn: (() => void) | null): void;
55
56
  export interface ReuseCandidate {
56
57
  rowid: number;
@@ -82,6 +83,7 @@ export declare class RAGKnowledgeGraphManager {
82
83
  gate?: EmbeddingGate;
83
84
  __testForceFkOff?: boolean;
84
85
  }): Promise<void>;
86
+ private openConnection;
85
87
  private ensureCurrentProfile;
86
88
  private buildRealLoader;
87
89
  startReconciliation(): Promise<void>;
@@ -101,6 +103,31 @@ export declare class RAGKnowledgeGraphManager {
101
103
  description: string;
102
104
  }>;
103
105
  }>;
106
+ checkpointWal(): boolean;
107
+ walIno: bigint | null;
108
+ shmIno: bigint | null;
109
+ reopenCount: number;
110
+ private reopenTimes;
111
+ private inFlight;
112
+ private pendingReopen;
113
+ private walMismatchTicks;
114
+ private walWatchPausedUntil;
115
+ private walWatchOff;
116
+ private lastWalSize;
117
+ private dbDown;
118
+ shuttingDown: boolean;
119
+ onFatal: (() => void) | null;
120
+ guardSidecars(): void;
121
+ walTick(): boolean;
122
+ private stormCount;
123
+ reopenDb(reason: string, force?: boolean): boolean;
124
+ private openAfterClose;
125
+ private retryOpen;
126
+ runWithIoRecovery<T>(tool: string, fn: () => Promise<T>): Promise<T>;
127
+ private walTimer;
128
+ private walSoon;
129
+ startWalKeeper(tickMs?: number): void;
130
+ noteActivity(delayMs?: number): void;
104
131
  cleanup(): void;
105
132
  private _timestampObservation;
106
133
  createEntities(entities: Array<Entity & {
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import Database from 'better-sqlite3';
6
6
  import * as sqliteVec from 'sqlite-vec';
7
7
  import { get_encoding } from 'tiktoken';
8
8
  import fsSync from 'fs';
9
+ import { execFileSync } from 'child_process';
9
10
  import path from 'path';
10
11
  import { fileURLToPath } from 'url';
11
12
  import { pipeline, env } from '@huggingface/transformers';
@@ -136,6 +137,30 @@ const TEXT_BUILDER_VERSION = 'tb1';
136
137
  export class SyncCasConflictError extends Error {
137
138
  constructor(documentId) { super(`sync CAS conflict on ${documentId}`); this.name = 'SyncCasConflictError'; }
138
139
  }
140
+ // Sidecar guard (specs/changes/wal-sidecar-guard). macOS File Provider attribute that takes an
141
+ // item out of sync; Google Drive for desktop honours it (measured: isExcludedFromSync = 1).
142
+ const SYNC_EXCLUDE_ATTR = 'com.apple.fileprovider.ignore#P';
143
+ const REOPEN_STORM_WINDOW_MS = 10_000;
144
+ const REOPEN_STORM_MAX = 3;
145
+ const REOPEN_MAX_TRIES = 10;
146
+ const WAL_WATCH_PAUSE_MS = 60_000;
147
+ // Tools that may be retried once after an I/O error reopened the connection: reads, and writes
148
+ // whose repetition changes nothing (INSERT OR IGNORE / content dedup / deletes). Everything else
149
+ // gets the error back so the caller decides — e.g. syncDocumentFromFile spans several
150
+ // transactions and its repeat-safety is not proven.
151
+ const IDEMPOTENT_TOOLS = new Set([
152
+ 'readGraph', 'searchNodes', 'openNodes', 'getNeighbors', 'hybridSearch', 'getDetailedContext',
153
+ 'getKnowledgeGraphStats', 'listDocuments', 'exportGraph', 'getMigrationStatus', 'getGraphMetrics',
154
+ 'detectCommunities', 'analyzeGraphStructure', 'getObservationHistory',
155
+ 'createEntities', 'createRelations', 'addObservations',
156
+ // deleteObservations / deleteDocuments are left out on purpose: a retry after a committed
157
+ // delete reports 0 deleted, which a caller reads as "nothing was deleted".
158
+ 'deleteEntities', 'deleteRelations',
159
+ ]);
160
+ export function isIoError(e) {
161
+ const code = e?.code;
162
+ return typeof code === 'string' && code.startsWith('SQLITE_IOERR');
163
+ }
139
164
  // Test-only fault hook (v13 setMigrationFaultPoint 선례 — 환경변수 금지: 상시 스위치는
140
165
  // 오설정 한 줄로 sync 를 깬다).
141
166
  let __syncFaultHook = null;
@@ -219,38 +244,7 @@ export class RAGKnowledgeGraphManager {
219
244
  // 호출부에서 읽히게 하기 위해서다.
220
245
  async initialize(opts = {}) {
221
246
  console.error('🚀 Initializing RAG Knowledge Graph MCP Server...');
222
- this.db = new Database(DB_FILE_PATH);
223
- sqliteVec.load(this.db);
224
- this.db.pragma('journal_mode = WAL');
225
- this.db.pragma('synchronous = NORMAL');
226
- this.db.pragma('busy_timeout = 5000');
227
- this.db.pragma('cache_size = -32000');
228
- this.db.pragma('temp_store = MEMORY');
229
- // Opt-in mmap (see MMAP_SIZE above). Read the applied value back so the banner reports
230
- // what SQLite did, not what we asked for (a compile-time cap can lower it silently).
231
- this.db.pragma(`mmap_size = ${MMAP_SIZE}`);
232
- this.mmapApplied = Number(this.db.pragma('mmap_size', { simple: true }));
233
- this.db.pragma('foreign_keys = ON');
234
- // spec §5.2: 관찰 lifecycle 의 무결성은 전부 FK CASCADE 를 전제한다 — root 를 지우면
235
- // revision 이, revision 을 지우면 source 가 따라가야 history 가 고아로 남지 않는다.
236
- // FK 가 꺼진 채 돌면 그 계약이 조용히 무효가 되고, 그게 최악이다. 스키마를 건드리기
237
- // 전에(= runMigrations 앞에서) 멈춘다.
238
- // 트랜잭션 내부에서는 이 pragma 가 no-op 이므로(실측 before=1·during=1·after=1)
239
- // 부팅 시점 확인이 유일한 방어 지점이다.
240
- //
241
- // 음성 대조군 주입은 **인자로만** 받는다. 환경변수로 두면 프로덕션 경로에
242
- // "부팅을 막는 스위치"가 상시 존재하게 되고, 오설정 한 줄로 서버가 안 뜬다
243
- // (advisor beta 자기의심 2 = "더 나쁘다"). 테스트는 manager 를 직접 만들므로
244
- // 인자 주입으로 충분하다.
245
- if (opts.__testForceFkOff)
246
- this.db.pragma('foreign_keys = OFF');
247
- {
248
- const fk = this.db.pragma('foreign_keys', { simple: true });
249
- if (Number(fk) !== 1) {
250
- throw new Error(`foreign_keys is ${fk}, expected 1. The observation lifecycle relies on FK CASCADE ` +
251
- `for history integrity; refusing to run migrations without it.`);
252
- }
253
- }
247
+ this.openConnection(opts.__testForceFkOff === true);
254
248
  this.encoding = get_encoding("cl100k_base");
255
249
  await this.runMigrations();
256
250
  this.currentProfileId = this.ensureCurrentProfile();
@@ -259,6 +253,8 @@ export class RAGKnowledgeGraphManager {
259
253
  this.db.prepare(`INSERT INTO server_meta (key, value) VALUES ('current_default_chunker', ?)
260
254
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
261
255
  .run(effectiveSignature(DEFAULT_MAX_TOKENS));
256
+ // The writes above created the sidecars; mark them before a sync client can take them.
257
+ this.guardSidecars();
262
258
  this.embeddingsMode = opts.skipModel
263
259
  ? 'off'
264
260
  : (process.env.RAG_MEMORY_EMBEDDINGS || 'lazy');
@@ -287,6 +283,42 @@ export class RAGKnowledgeGraphManager {
287
283
  const systemInfo = getSystemInfo();
288
284
  console.error(`📊 System Info: ${systemInfo.toolCounts.total} tools available (${systemInfo.toolCounts.knowledgeGraph} knowledge graph, ${systemInfo.toolCounts.rag} RAG, ${systemInfo.toolCounts.graphQuery} query)`);
289
285
  }
286
+ // Opens the connection and applies everything the engine attaches to it. Shared by
287
+ // initialize() and reopenDb() so a reopened connection is configured exactly like the first.
288
+ openConnection(forceFkOff = false) {
289
+ this.db = new Database(DB_FILE_PATH);
290
+ sqliteVec.load(this.db);
291
+ this.db.pragma('journal_mode = WAL');
292
+ this.db.pragma('synchronous = NORMAL');
293
+ this.db.pragma('busy_timeout = 5000');
294
+ this.db.pragma('cache_size = -32000');
295
+ this.db.pragma('temp_store = MEMORY');
296
+ // Opt-in mmap (see MMAP_SIZE above). Read the applied value back so the banner reports
297
+ // what SQLite did, not what we asked for (a compile-time cap can lower it silently).
298
+ this.db.pragma(`mmap_size = ${MMAP_SIZE}`);
299
+ this.mmapApplied = Number(this.db.pragma('mmap_size', { simple: true }));
300
+ this.db.pragma('foreign_keys = ON');
301
+ // spec §5.2: 관찰 lifecycle 의 무결성은 전부 FK CASCADE 를 전제한다 — root 를 지우면
302
+ // revision 이, revision 을 지우면 source 가 따라가야 history 가 고아로 남지 않는다.
303
+ // FK 가 꺼진 채 돌면 그 계약이 조용히 무효가 되고, 그게 최악이다. 스키마를 건드리기
304
+ // 전에(= runMigrations 앞에서) 멈춘다.
305
+ // 트랜잭션 내부에서는 이 pragma 가 no-op 이므로(실측 before=1·during=1·after=1)
306
+ // 부팅 시점 확인이 유일한 방어 지점이다.
307
+ //
308
+ // 음성 대조군 주입은 **인자로만** 받는다. 환경변수로 두면 프로덕션 경로에
309
+ // "부팅을 막는 스위치"가 상시 존재하게 되고, 오설정 한 줄로 서버가 안 뜬다
310
+ // (advisor beta 자기의심 2 = "더 나쁘다"). 테스트는 manager 를 직접 만들므로
311
+ // 인자 주입으로 충분하다.
312
+ if (forceFkOff)
313
+ this.db.pragma('foreign_keys = OFF');
314
+ {
315
+ const fk = this.db.pragma('foreign_keys', { simple: true });
316
+ if (Number(fk) !== 1) {
317
+ throw new Error(`foreign_keys is ${fk}, expected 1. The observation lifecycle relies on FK CASCADE ` +
318
+ `for history integrity; refusing to run migrations without it.`);
319
+ }
320
+ }
321
+ }
290
322
  // Upsert the stored-vector compatibility profile (spec §6c layer 2) and record
291
323
  // retrieval config in server_meta (layer 3 — never a backfill trigger).
292
324
  ensureCurrentProfile() {
@@ -544,6 +576,7 @@ export class RAGKnowledgeGraphManager {
544
576
  // session construction at exit — risks an ugly abort message, never data
545
577
  // loss. Hanging forever is the alternative and is worse.
546
578
  async shutdownAll() {
579
+ this.shuttingDown = true;
547
580
  console.error('\n🧹 Cleaning up...');
548
581
  try {
549
582
  await this.coordinator?.shutdown(5000);
@@ -593,13 +626,283 @@ export class RAGKnowledgeGraphManager {
593
626
  }))
594
627
  };
595
628
  }
629
+ // Fold the WAL into the main file and empty it. Returns true when the WAL holds no
630
+ // frames afterwards.
631
+ //
632
+ // Why the engine does this itself instead of leaving it to SQLite: SQLite folds the WAL
633
+ // only when the LAST connection closes, and only if that close gets to run. Neither holds
634
+ // here — several engines keep the same file open (one per CLI), and some hosts end the
635
+ // engine without letting it finish (codex-cli 0.155.1, measured: SIGTERM, then SIGKILL
636
+ // ~185 ms later; on Windows a child is simply terminated). A WAL with frames that outlives
637
+ // its process is replayed at the next open against whatever main file is there by then, and
638
+ // in a cloud-synced folder that can be a main file written by another machine. SQLite does
639
+ // not check that a WAL belongs to the main file next to it. That corrupted a live database
640
+ // twice. So the invariant is kept continuously: while idle, the WAL is empty.
641
+ //
642
+ // TRUNCATE, not PASSIVE: PASSIVE copies the frames into the main file but leaves them in
643
+ // the WAL, still valid, still replayable onto a foreign main file.
644
+ // busy_timeout is dropped to 0 around the call: TRUNCATE runs the busy handler, and a 5 s
645
+ // stall inside a signal handler or a timer tick is worse than retrying on the next tick.
646
+ checkpointWal() {
647
+ if (!this.db)
648
+ return true;
649
+ try {
650
+ const st = fsSync.statSync(DB_FILE_PATH + '-wal', { throwIfNoEntry: false });
651
+ if (!st || st.size === 0)
652
+ return true;
653
+ this.db.pragma('busy_timeout = 0');
654
+ try {
655
+ const r = this.db.pragma('wal_checkpoint(TRUNCATE)');
656
+ return r?.[0]?.busy === 0;
657
+ }
658
+ finally {
659
+ this.db.pragma('busy_timeout = 5000');
660
+ }
661
+ }
662
+ catch (e) {
663
+ // An I/O error here means the connection is pointing at a file that is no longer the one
664
+ // at the path (see guardSidecars). Retrying on the same connection cannot succeed.
665
+ if (isIoError(e))
666
+ this.reopenDb(`checkpoint-${e.code}`, true);
667
+ return false; // inside a transaction, or the file is locked — the next tick retries
668
+ }
669
+ }
670
+ // --- Sidecar guard (specs/changes/wal-sidecar-guard) -------------------------------------
671
+ // Measured 2026-09-25 (macOS + Google Drive): the sync client treated `-wal` as a synced item
672
+ // and swapped a new file in at the path while the engine held the old one open; every write
673
+ // then failed with SQLITE_IOERR until restart. Excluding the sidecars from sync takes them out
674
+ // of the client's hands, and it matches the WAL-exit contract: a WAL that never leaves this
675
+ // machine cannot be replayed onto another machine's main file. The attribute lives on the
676
+ // inode and SQLite recreates both files after the last connection closes, so it is set after
677
+ // every open and whenever a sidecar at the path is not the one recorded here.
678
+ walIno = null;
679
+ shmIno = null;
680
+ reopenCount = 0;
681
+ reopenTimes = [];
682
+ inFlight = 0; // tool calls running (reopen waits for them)
683
+ pendingReopen = null;
684
+ walMismatchTicks = 0; // a swap must be seen on 2 consecutive ticks
685
+ walWatchPausedUntil = 0; // storm brake for the inode watch
686
+ walWatchOff = false; // file system gives no inode (ino 0)
687
+ lastWalSize = 0;
688
+ dbDown = null;
689
+ shuttingDown = false;
690
+ // Set by main(): the full shutdown (closes the MCP transport first, then the engine). Without it
691
+ // the process would stay alive on stdin with no database.
692
+ onFatal = null;
693
+ guardSidecars() {
694
+ const wal = DB_FILE_PATH + '-wal', shm = DB_FILE_PATH + '-shm';
695
+ if (process.platform === 'darwin') {
696
+ for (const p of [wal, shm]) {
697
+ if (!fsSync.existsSync(p))
698
+ continue;
699
+ try {
700
+ execFileSync('/usr/bin/xattr', ['-w', SYNC_EXCLUDE_ATTR, '1', p], { stdio: 'ignore' });
701
+ }
702
+ catch (e) {
703
+ // Fail open: the attribute is protection, not a correctness condition.
704
+ console.error(`⚠️ sidecar sync-exclude failed: ${p}: ${e.message}`);
705
+ }
706
+ }
707
+ }
708
+ const w = fsSync.statSync(wal, { throwIfNoEntry: false, bigint: true });
709
+ const h = fsSync.statSync(shm, { throwIfNoEntry: false, bigint: true });
710
+ this.walIno = w ? w.ino : null;
711
+ this.shmIno = h ? h.ino : null;
712
+ if (w && w.ino === 0n && !this.walWatchOff) {
713
+ this.walWatchOff = true;
714
+ console.error('⚠️ file system reports inode 0 — sidecar swap watch disabled');
715
+ }
716
+ this.walMismatchTicks = 0;
717
+ }
718
+ // Runs on the WAL keeper tick. Returns true when it reopened.
719
+ walTick() {
720
+ if (this.shuttingDown)
721
+ return false;
722
+ if (!this.db)
723
+ return this.dbDown ? this.retryOpen() : false;
724
+ if (this.walWatchOff || Date.now() < this.walWatchPausedUntil)
725
+ return false;
726
+ const w = fsSync.statSync(DB_FILE_PATH + '-wal', { throwIfNoEntry: false, bigint: true });
727
+ const h = fsSync.statSync(DB_FILE_PATH + '-shm', { throwIfNoEntry: false, bigint: true });
728
+ if (this.walIno === null) {
729
+ // Nothing recorded yet (no write had created the file): record and mark it now.
730
+ if (w)
731
+ this.guardSidecars();
732
+ return false;
733
+ }
734
+ const walOk = !!w && w.ino === this.walIno;
735
+ const shmOk = this.shmIno === null || (!!h && h.ino === this.shmIno);
736
+ if (walOk && shmOk) {
737
+ this.walMismatchTicks = 0;
738
+ if (w)
739
+ this.lastWalSize = Number(w.size);
740
+ return false;
741
+ }
742
+ // Two consecutive ticks, so a value that flickers back within a second is not a swap.
743
+ if (++this.walMismatchTicks < 2)
744
+ return false;
745
+ const reason = !w ? 'wal-missing' : !walOk ? 'wal-inode-changed' : 'shm-inode-changed';
746
+ if (this.inFlight > 0) {
747
+ this.pendingReopen = reason;
748
+ return false;
749
+ }
750
+ return this.reopenDb(reason);
751
+ }
752
+ stormCount() {
753
+ const now = Date.now();
754
+ return this.reopenTimes.filter(t => now - t < REOPEN_STORM_WINDOW_MS).length;
755
+ }
756
+ // Close and reopen the connection with the same configuration. Migrations are not rerun.
757
+ // Returns false when there was nothing to reopen or the reopen failed (the engine then
758
+ // retries with backoff from the keeper tick and, after REOPEN_MAX_TRIES, exits so the host
759
+ // sees a failed server instead of a live one that answers every call with an error).
760
+ // force = an I/O error was seen on this handle: reopen even inside the storm window (holding a
761
+ // dead handle helps nobody). The storm brake is for the inode watch.
762
+ reopenDb(reason, force = false) {
763
+ if (this.shuttingDown || !this.db)
764
+ return false;
765
+ if (!force && this.stormCount() >= REOPEN_STORM_MAX) {
766
+ // Keep the connection; pause the inode watch so a flickering file system cannot make
767
+ // this a once-a-second close/open loop. An IOERR path still gets its error.
768
+ this.walWatchPausedUntil = Date.now() + WAL_WATCH_PAUSE_MS;
769
+ console.error(`⚠️ reopen storm (${REOPEN_STORM_MAX} in ${REOPEN_STORM_WINDOW_MS / 1000}s) — watch paused, reason=${reason}`);
770
+ return false;
771
+ }
772
+ const old = this.db;
773
+ // Salvage: frames committed to the old WAL after the last checkpoint (≤1 s) go to the main
774
+ // file if the old descriptor is still readable. An I/O error here just means they are gone.
775
+ // busy_timeout 0: another engine's read snapshot must not stall this for 5 s.
776
+ try {
777
+ old.pragma('busy_timeout = 0');
778
+ old.pragma('wal_checkpoint(TRUNCATE)');
779
+ }
780
+ catch { /* old file unreadable */ }
781
+ try {
782
+ old.close();
783
+ }
784
+ catch { /* the handle may already be unusable */ }
785
+ this.db = null;
786
+ this.reopenTimes.push(Date.now());
787
+ return this.openAfterClose(reason);
788
+ }
789
+ openAfterClose(reason) {
790
+ try {
791
+ this.openConnection();
792
+ this.guardSidecars();
793
+ }
794
+ catch (e) {
795
+ try {
796
+ this.db?.close();
797
+ }
798
+ catch { /* half-open */ }
799
+ this.db = null;
800
+ const tries = (this.dbDown?.tries ?? 0) + 1;
801
+ const delay = Math.min(1000 * 2 ** (tries - 1), 60_000);
802
+ this.dbDown = { reason, tries, nextAt: Date.now() + delay };
803
+ console.error(`❌ db reopen failed (reason=${reason}, try=${tries}): ${e.message}`);
804
+ if (tries >= REOPEN_MAX_TRIES) {
805
+ console.error('❌ database cannot be reopened — exiting so the host shows a failed server');
806
+ process.exitCode = 1;
807
+ if (this.onFatal)
808
+ this.onFatal();
809
+ else
810
+ void this.shutdownAll().finally(() => process.exit(process.exitCode ?? 1));
811
+ }
812
+ return false;
813
+ }
814
+ this.dbDown = null;
815
+ this.pendingReopen = null;
816
+ this.reopenCount++;
817
+ console.error(`🔁 db reopened (reason=${reason}, n=${this.reopenCount}, walsz-before=${this.lastWalSize})`);
818
+ // A reconciliation cut short by the old handle is 'pending', not failed — run it again.
819
+ if (this.coordinator?.reconState === 'pending')
820
+ void this.startReconciliation().catch(() => { });
821
+ return true;
822
+ }
823
+ retryOpen() {
824
+ if (this.shuttingDown)
825
+ return false;
826
+ if (!this.dbDown || Date.now() < this.dbDown.nextAt)
827
+ return false;
828
+ return this.openAfterClose(`retry-${this.dbDown.reason}`);
829
+ }
830
+ // Tool-call wrapper: an SQLITE_IOERR* reopens the connection. The call is retried once only
831
+ // when the tool is idempotent — a failed transaction is rolled back whole, but a tool can
832
+ // span several transactions, so a non-idempotent one gets the error back (after the reopen)
833
+ // and the caller decides. Too many reopens in a short window → no retry, surface the error.
834
+ async runWithIoRecovery(tool, fn) {
835
+ if (!this.db && this.dbDown)
836
+ this.retryOpen();
837
+ const handle = this.db;
838
+ this.inFlight++;
839
+ try {
840
+ try {
841
+ return await fn();
842
+ }
843
+ catch (e) {
844
+ if (!isIoError(e))
845
+ throw e;
846
+ const storm = this.stormCount() >= REOPEN_STORM_MAX;
847
+ // Several calls hit by one swap reopen once: if another call already replaced the
848
+ // handle, just use the new one.
849
+ const reopened = (this.db !== null && this.db !== handle)
850
+ || this.reopenDb(`tool-${tool}-${e.code}`, true);
851
+ if (storm || !reopened || !IDEMPOTENT_TOOLS.has(tool))
852
+ throw e;
853
+ return await fn();
854
+ }
855
+ }
856
+ finally {
857
+ this.inFlight--;
858
+ if (this.inFlight === 0 && this.pendingReopen)
859
+ this.reopenDb(this.pendingReopen);
860
+ }
861
+ }
862
+ walTimer = null;
863
+ walSoon = null;
864
+ // Every write path ends up here without having to be listed: the periodic tick looks at
865
+ // the size of the -wal file, not at which tool ran, so background writers (reconciliation,
866
+ // backfill) are covered as well. noteActivity() only shortens the wait after a tool call.
867
+ startWalKeeper(tickMs = 1000) {
868
+ if (this.walTimer)
869
+ return;
870
+ this.walTimer = setInterval(() => {
871
+ try {
872
+ this.walTick();
873
+ }
874
+ catch (e) {
875
+ console.error(`⚠️ wal tick: ${e.message}`);
876
+ }
877
+ this.checkpointWal();
878
+ }, tickMs);
879
+ this.walTimer.unref();
880
+ }
881
+ noteActivity(delayMs = 200) {
882
+ if (this.walSoon)
883
+ return;
884
+ this.walSoon = setTimeout(() => { this.walSoon = null; this.checkpointWal(); }, delayMs);
885
+ this.walSoon.unref();
886
+ }
596
887
  cleanup() {
888
+ this.shuttingDown = true;
889
+ if (this.walTimer) {
890
+ clearInterval(this.walTimer);
891
+ this.walTimer = null;
892
+ }
893
+ if (this.walSoon) {
894
+ clearTimeout(this.walSoon);
895
+ this.walSoon = null;
896
+ }
597
897
  if (this.encoding) {
598
898
  this.encoding.free();
599
899
  this.encoding = null;
600
900
  }
601
901
  this.embeddingCache.clear();
602
902
  if (this.db) {
903
+ // close() folds the WAL only for the last connection; with other engines attached it
904
+ // would leave every frame behind.
905
+ this.checkpointWal();
603
906
  this.db.close();
604
907
  this.db = null;
605
908
  }
@@ -4061,116 +4364,120 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4061
4364
  try {
4062
4365
  // Validate arguments using our structured schema
4063
4366
  const validatedArgs = validateToolArgs(name, args);
4064
- switch (name) {
4065
- // Original MCP tools
4066
- case "createEntities":
4067
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.createEntities(validatedArgs.entities), null, 2) }] };
4068
- case "createRelations":
4069
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.createRelations(validatedArgs.relations), null, 2) }] };
4070
- case "addObservations":
4071
- // v13: status·sources 를 그대로 넘긴다. 여기서 떨어뜨리면 스키마가 받아도
4072
- // 엔진에 도달하지 않아 provenance 가 조용히 사라진다.
4073
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.addObservations(validatedArgs.observations), null, 2) }] };
4074
- // v13 observation lifecycle (spec §6.1 / §6.2)
4075
- case "correctObservation":
4076
- return { content: [{ type: "text", text: JSON.stringify({ observation_id: await ragKgManager.correctObservation(validatedArgs.observation_id, validatedArgs.content, validatedArgs.change_kind ?? 'correction', validatedArgs.reason) }, null, 2) }] };
4077
- case "retractObservation":
4078
- await ragKgManager.retractObservation(validatedArgs.observation_id, validatedArgs.reason);
4079
- return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'retracted' }, null, 2) }] };
4080
- case "restoreObservation":
4081
- await ragKgManager.restoreObservation(validatedArgs.observation_id, validatedArgs.reason);
4082
- return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'active' }, null, 2) }] };
4083
- case "approveObservation":
4084
- await ragKgManager.approveObservation(validatedArgs.observation_id, validatedArgs.reason);
4085
- return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'active' }, null, 2) }] };
4086
- case "declineObservation":
4087
- await ragKgManager.declineObservation(validatedArgs.observation_id, validatedArgs.reason);
4088
- return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'retracted' }, null, 2) }] };
4089
- case "purgeObservation":
4090
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.purgeObservation(validatedArgs.observation_id, validatedArgs.confirm), null, 2) }] };
4091
- case "getObservationHistory":
4092
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getObservationHistory({
4093
- entity_name: validatedArgs.entity_name,
4094
- observation_id: validatedArgs.observation_id,
4095
- root_id: validatedArgs.root_id,
4096
- }), null, 2) }] };
4097
- case "deleteEntities":
4098
- await ragKgManager.deleteEntities(validatedArgs.entityNames);
4099
- return { content: [{ type: "text", text: "Entities deleted successfully" }] };
4100
- case "deleteObservations":
4101
- // v3.6 (spec §5c, breaking): structured per-entity results replace the
4102
- // bare success string — mixed embedded/queued/no-op states are visible.
4103
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.deleteObservations(validatedArgs.deletions), null, 2) }] };
4104
- case "deleteRelations":
4105
- await ragKgManager.deleteRelations(validatedArgs.relations);
4106
- return { content: [{ type: "text", text: "Relations deleted successfully" }] };
4107
- case "updateRelations":
4108
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.updateRelations(validatedArgs.updates), null, 2) }] };
4109
- case "readGraph":
4110
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.readGraph(), null, 2) }] };
4111
- case "searchNodes":
4112
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.searchNodes(validatedArgs.query, validatedArgs.limit || 10, validatedArgs.since, validatedArgs.until), null, 2) }] };
4113
- case "openNodes":
4114
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.openNodes(validatedArgs.names), null, 2) }] };
4115
- case "getNeighbors":
4116
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getNeighbors(validatedArgs.entityNames, validatedArgs.depth || 1, validatedArgs.relationType), null, 2) }] };
4117
- // New RAG tools
4118
- case "storeDocument":
4119
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.storeDocument(validatedArgs.id, validatedArgs.content, validatedArgs.metadata || {}), null, 2) }] };
4120
- case "chunkDocument":
4121
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.chunkDocument(validatedArgs.documentId, { maxTokens: validatedArgs.maxTokens, overlap: validatedArgs.overlap }), null, 2) }] };
4122
- case "embedChunks":
4123
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.embedChunks(validatedArgs.documentId), null, 2) }] };
4124
- case "extractTerms":
4125
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.extractTerms(validatedArgs.documentId, { minLength: validatedArgs.minLength, includeCapitalized: validatedArgs.includeCapitalized, customPatterns: validatedArgs.customPatterns }), null, 2) }] };
4126
- case "linkEntitiesToDocument":
4127
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.linkEntitiesToDocument(validatedArgs.documentId, validatedArgs.entityNames), null, 2) }] };
4128
- case "hybridSearch":
4129
- const limit = typeof validatedArgs.limit === 'number' ? validatedArgs.limit : 5;
4130
- // v5.3.0: graph is opt-in — only an explicit true enables the re-ranker (schema default false).
4131
- const useGraph = validatedArgs.useGraph === true;
4132
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.hybridSearch(validatedArgs.query, limit, useGraph), null, 2) }] };
4133
- case "getDetailedContext":
4134
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getDetailedContext(validatedArgs.chunkId, validatedArgs.includeSurrounding !== false), null, 2) }] };
4135
- case "getKnowledgeGraphStats":
4136
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getKnowledgeGraphStats(), null, 2) }] };
4137
- case "deleteDocuments":
4138
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.deleteDocuments(validatedArgs.documentIds), null, 2) }] };
4139
- case "listDocuments":
4140
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.listDocuments(validatedArgs.includeMetadata !== false), null, 2) }] };
4141
- case "syncDocumentFromFile":
4142
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.syncDocumentFromFile(validatedArgs.path, validatedArgs.documentId, {
4143
- metadata: validatedArgs.metadata,
4144
- content: validatedArgs.content,
4145
- excludePattern: validatedArgs.excludePattern,
4146
- entityNames: validatedArgs.entityNames,
4147
- chunkParams: validatedArgs.chunkParams,
4148
- }), null, 2) }] };
4149
- // NEW: Entity embedding tools
4150
- case "embedAllEntities":
4151
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.embedAllEntities(), null, 2) }] };
4152
- // NEW: Export/Import tools
4153
- case "exportGraph":
4154
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.exportGraph(), null, 2) }] };
4155
- case "importGraph":
4156
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.importGraph(validatedArgs.data, { merge: validatedArgs.merge !== false }), null, 2) }] };
4157
- // NEW: Migration tools
4158
- case "getMigrationStatus":
4159
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getMigrationStatus(), null, 2) }] };
4160
- case "runMigrations":
4161
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.runMigrations(), null, 2) }] };
4162
- case "rollbackMigration":
4163
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.rollbackMigration(validatedArgs.targetVersion), null, 2) }] };
4164
- // Graph Analytics tools (graphology)
4165
- case "getGraphMetrics":
4166
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getGraphMetrics(validatedArgs.entityNames, validatedArgs.metrics, validatedArgs.limit || 10), null, 2) }] };
4167
- case "detectCommunities":
4168
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.detectCommunities(validatedArgs.resolution || 1.0), null, 2) }] };
4169
- case "analyzeGraphStructure":
4170
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.analyzeGraphStructure(), null, 2) }] };
4171
- default:
4172
- throw new Error(`Unknown tool: ${name}`);
4173
- }
4367
+ ragKgManager.noteActivity(); // fold the WAL shortly after this call, whatever it wrote
4368
+ // An SQLITE_IOERR* reopens the connection; idempotent tools are retried once (wal-sidecar-guard).
4369
+ return await ragKgManager.runWithIoRecovery(name, async () => {
4370
+ switch (name) {
4371
+ // Original MCP tools
4372
+ case "createEntities":
4373
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.createEntities(validatedArgs.entities), null, 2) }] };
4374
+ case "createRelations":
4375
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.createRelations(validatedArgs.relations), null, 2) }] };
4376
+ case "addObservations":
4377
+ // v13: status·sources 를 그대로 넘긴다. 여기서 떨어뜨리면 스키마가 받아도
4378
+ // 엔진에 도달하지 않아 provenance 가 조용히 사라진다.
4379
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.addObservations(validatedArgs.observations), null, 2) }] };
4380
+ // v13 observation lifecycle (spec §6.1 / §6.2)
4381
+ case "correctObservation":
4382
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: await ragKgManager.correctObservation(validatedArgs.observation_id, validatedArgs.content, validatedArgs.change_kind ?? 'correction', validatedArgs.reason) }, null, 2) }] };
4383
+ case "retractObservation":
4384
+ await ragKgManager.retractObservation(validatedArgs.observation_id, validatedArgs.reason);
4385
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'retracted' }, null, 2) }] };
4386
+ case "restoreObservation":
4387
+ await ragKgManager.restoreObservation(validatedArgs.observation_id, validatedArgs.reason);
4388
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'active' }, null, 2) }] };
4389
+ case "approveObservation":
4390
+ await ragKgManager.approveObservation(validatedArgs.observation_id, validatedArgs.reason);
4391
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'active' }, null, 2) }] };
4392
+ case "declineObservation":
4393
+ await ragKgManager.declineObservation(validatedArgs.observation_id, validatedArgs.reason);
4394
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'retracted' }, null, 2) }] };
4395
+ case "purgeObservation":
4396
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.purgeObservation(validatedArgs.observation_id, validatedArgs.confirm), null, 2) }] };
4397
+ case "getObservationHistory":
4398
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getObservationHistory({
4399
+ entity_name: validatedArgs.entity_name,
4400
+ observation_id: validatedArgs.observation_id,
4401
+ root_id: validatedArgs.root_id,
4402
+ }), null, 2) }] };
4403
+ case "deleteEntities":
4404
+ await ragKgManager.deleteEntities(validatedArgs.entityNames);
4405
+ return { content: [{ type: "text", text: "Entities deleted successfully" }] };
4406
+ case "deleteObservations":
4407
+ // v3.6 (spec §5c, breaking): structured per-entity results replace the
4408
+ // bare success string — mixed embedded/queued/no-op states are visible.
4409
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.deleteObservations(validatedArgs.deletions), null, 2) }] };
4410
+ case "deleteRelations":
4411
+ await ragKgManager.deleteRelations(validatedArgs.relations);
4412
+ return { content: [{ type: "text", text: "Relations deleted successfully" }] };
4413
+ case "updateRelations":
4414
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.updateRelations(validatedArgs.updates), null, 2) }] };
4415
+ case "readGraph":
4416
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.readGraph(), null, 2) }] };
4417
+ case "searchNodes":
4418
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.searchNodes(validatedArgs.query, validatedArgs.limit || 10, validatedArgs.since, validatedArgs.until), null, 2) }] };
4419
+ case "openNodes":
4420
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.openNodes(validatedArgs.names), null, 2) }] };
4421
+ case "getNeighbors":
4422
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getNeighbors(validatedArgs.entityNames, validatedArgs.depth || 1, validatedArgs.relationType), null, 2) }] };
4423
+ // New RAG tools
4424
+ case "storeDocument":
4425
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.storeDocument(validatedArgs.id, validatedArgs.content, validatedArgs.metadata || {}), null, 2) }] };
4426
+ case "chunkDocument":
4427
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.chunkDocument(validatedArgs.documentId, { maxTokens: validatedArgs.maxTokens, overlap: validatedArgs.overlap }), null, 2) }] };
4428
+ case "embedChunks":
4429
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.embedChunks(validatedArgs.documentId), null, 2) }] };
4430
+ case "extractTerms":
4431
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.extractTerms(validatedArgs.documentId, { minLength: validatedArgs.minLength, includeCapitalized: validatedArgs.includeCapitalized, customPatterns: validatedArgs.customPatterns }), null, 2) }] };
4432
+ case "linkEntitiesToDocument":
4433
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.linkEntitiesToDocument(validatedArgs.documentId, validatedArgs.entityNames), null, 2) }] };
4434
+ case "hybridSearch":
4435
+ const limit = typeof validatedArgs.limit === 'number' ? validatedArgs.limit : 5;
4436
+ // v5.3.0: graph is opt-in — only an explicit true enables the re-ranker (schema default false).
4437
+ const useGraph = validatedArgs.useGraph === true;
4438
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.hybridSearch(validatedArgs.query, limit, useGraph), null, 2) }] };
4439
+ case "getDetailedContext":
4440
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getDetailedContext(validatedArgs.chunkId, validatedArgs.includeSurrounding !== false), null, 2) }] };
4441
+ case "getKnowledgeGraphStats":
4442
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getKnowledgeGraphStats(), null, 2) }] };
4443
+ case "deleteDocuments":
4444
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.deleteDocuments(validatedArgs.documentIds), null, 2) }] };
4445
+ case "listDocuments":
4446
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.listDocuments(validatedArgs.includeMetadata !== false), null, 2) }] };
4447
+ case "syncDocumentFromFile":
4448
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.syncDocumentFromFile(validatedArgs.path, validatedArgs.documentId, {
4449
+ metadata: validatedArgs.metadata,
4450
+ content: validatedArgs.content,
4451
+ excludePattern: validatedArgs.excludePattern,
4452
+ entityNames: validatedArgs.entityNames,
4453
+ chunkParams: validatedArgs.chunkParams,
4454
+ }), null, 2) }] };
4455
+ // NEW: Entity embedding tools
4456
+ case "embedAllEntities":
4457
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.embedAllEntities(), null, 2) }] };
4458
+ // NEW: Export/Import tools
4459
+ case "exportGraph":
4460
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.exportGraph(), null, 2) }] };
4461
+ case "importGraph":
4462
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.importGraph(validatedArgs.data, { merge: validatedArgs.merge !== false }), null, 2) }] };
4463
+ // NEW: Migration tools
4464
+ case "getMigrationStatus":
4465
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getMigrationStatus(), null, 2) }] };
4466
+ case "runMigrations":
4467
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.runMigrations(), null, 2) }] };
4468
+ case "rollbackMigration":
4469
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.rollbackMigration(validatedArgs.targetVersion), null, 2) }] };
4470
+ // Graph Analytics tools (graphology)
4471
+ case "getGraphMetrics":
4472
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getGraphMetrics(validatedArgs.entityNames, validatedArgs.metrics, validatedArgs.limit || 10), null, 2) }] };
4473
+ case "detectCommunities":
4474
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.detectCommunities(validatedArgs.resolution || 1.0), null, 2) }] };
4475
+ case "analyzeGraphStructure":
4476
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.analyzeGraphStructure(), null, 2) }] };
4477
+ default:
4478
+ throw new Error(`Unknown tool: ${name}`);
4479
+ }
4480
+ });
4174
4481
  }
4175
4482
  catch (error) {
4176
4483
  // v3.6 (spec §5c): machine-distinguishable failures. Embedding-gate errors
@@ -4235,6 +4542,10 @@ async function main() {
4235
4542
  if (shuttingDown)
4236
4543
  return;
4237
4544
  shuttingDown = true;
4545
+ ragKgManager.shuttingDown = true; // no reopen inside the exit window (wal-sidecar-guard)
4546
+ // Synchronously, before anything is awaited: a host may SIGKILL before the settle
4547
+ // sequence below completes (codex: ~185 ms after SIGTERM).
4548
+ ragKgManager.checkpointWal();
4238
4549
  void (async () => {
4239
4550
  try {
4240
4551
  await server.close();
@@ -4250,6 +4561,14 @@ async function main() {
4250
4561
  };
4251
4562
  process.on('SIGINT', shutdown);
4252
4563
  process.on('SIGTERM', shutdown);
4564
+ // SIGHUP = the terminal or multiplexer pane was closed. Without a handler the default
4565
+ // action kills the process before 'exit' listeners run, so the database is never closed.
4566
+ // On Windows, Node raises SIGHUP when the console window is closed and SIGBREAK on
4567
+ // Ctrl+Break; registering them is harmless where they never fire.
4568
+ process.on('SIGHUP', shutdown);
4569
+ process.on('SIGBREAK', shutdown);
4570
+ ragKgManager.onFatal = shutdown; // a database that cannot be reopened ends the server (wal-sidecar-guard)
4571
+ ragKgManager.startWalKeeper();
4253
4572
  process.on('exit', () => { try {
4254
4573
  ragKgManager.cleanup();
4255
4574
  }
@@ -35,6 +35,7 @@ export declare class BackfillCoordinator {
35
35
  private kickTimer;
36
36
  private sweepTimer;
37
37
  private shuttingDown;
38
+ private connLossReruns;
38
39
  private scanning;
39
40
  private scanPromise;
40
41
  private rerunRequested;
@@ -14,6 +14,15 @@
14
14
  // kick() merely triggers a debounced scan. All reconciliation writes are
15
15
  // per-row transactions with a CAS re-check so a foreground embed that lands
16
16
  // first is never downgraded (6R note 3).
17
+ // A handle closed by the engine's reopen (wal-sidecar-guard), or an I/O error from a sidecar the
18
+ // sync client swapped out: the run was cut, not broken.
19
+ const CONN_LOSS_RERUN_MAX = 3;
20
+ function isConnectionLoss(e) {
21
+ const code = e?.code;
22
+ if (typeof code === 'string' && code.startsWith('SQLITE_IOERR'))
23
+ return true;
24
+ return e instanceof Error && /database connection is not open/i.test(e.message);
25
+ }
17
26
  const RECON_BATCH = 50;
18
27
  const KICK_DEBOUNCE_MS = 200;
19
28
  const SWEEP_MS = 5 * 60_000;
@@ -25,6 +34,7 @@ export class BackfillCoordinator {
25
34
  kickTimer = null;
26
35
  sweepTimer = null;
27
36
  shuttingDown = false;
37
+ connLossReruns = 0;
28
38
  scanning = false;
29
39
  scanPromise = null;
30
40
  rerunRequested = false;
@@ -178,6 +188,7 @@ export class BackfillCoordinator {
178
188
  if (remaining !== 0)
179
189
  throw new Error(`complete invariant violated: ${remaining} unreconciled vectors remain`);
180
190
  this.recon = 'complete';
191
+ this.connLossReruns = 0;
181
192
  this.snapshot = null;
182
193
  console.error('✅ provenance reconciliation complete');
183
194
  this.kick();
@@ -190,6 +201,18 @@ export class BackfillCoordinator {
190
201
  this.reconPromise = null;
191
202
  return;
192
203
  }
204
+ if (isConnectionLoss(e) && this.connLossReruns < CONN_LOSS_RERUN_MAX) {
205
+ // The engine reopened the connection under us (wal-sidecar-guard). Not broken: run
206
+ // again on the new handle. If there is no handle right now (reopen failed), the engine
207
+ // starts reconciliation itself once it reopens.
208
+ this.connLossReruns++;
209
+ this.recon = 'pending';
210
+ this.reconPromise = null;
211
+ if (this.deps.db())
212
+ setTimeout(() => { void this.runReconciliation(); }, 0).unref?.();
213
+ console.error(`↻ reconciliation interrupted by a reconnect — rerun ${this.connLossReruns}/${CONN_LOSS_RERUN_MAX}`);
214
+ return;
215
+ }
193
216
  this.recon = 'failed';
194
217
  this.reconError = e instanceof Error ? e.message : String(e);
195
218
  console.error(`❌ reconciliation failed — vector search stays disabled: ${this.reconError}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rag-memory-epf-mcp",
3
- "version": "6.3.0",
3
+ "version": "6.3.2",
4
4
  "engines": {
5
5
  "node": ">=24"
6
6
  },
@@ -45,7 +45,7 @@
45
45
  "prepare": "npm run build",
46
46
  "watch": "tsc --watch",
47
47
  "verify:invariants": "node test/chunk-invariants.test.mjs",
48
- "verify:engine": "node test/engine-smoke.test.mjs && node test/launch-smoke.test.mjs && node test/sync-atomicity.test.mjs && node test/dedup.test.mjs && node test/search-degradation.test.mjs && node test/entity-embed-cap.test.mjs && node test/migration12.test.mjs && node test/model-cache.test.mjs && node test/embedding-gate.test.mjs && node test/lazy-boot.test.mjs && node test/reconciliation.test.mjs && node test/backfill.test.mjs && node test/fts-query.test.mjs && node test/search-contracts.test.mjs && node test/tool-contracts.test.mjs && node test/bounded-exit.test.mjs && node test/observation-schema.test.mjs && node test/search-graph-default.test.mjs && node test/observation-migration.test.mjs && node test/observation-lifecycle.test.mjs && node test/observation-contracts.test.mjs && node test/observation-search.test.mjs && node test/observation-cascade.test.mjs && node test/observation-realdata.test.mjs && node test/chunker-c.test.mjs && node test/migration14.test.mjs && node test/chunk-params-validation.test.mjs && node test/vector-reuse.test.mjs && node test/entity-range-linking.test.mjs && node test/alias-link-gate.test.mjs && node test/entity-name-boundary.test.mjs && node test/stats-chunking.test.mjs && node test/migration14-realdata.test.mjs && node test/migration14-realdata-sync.test.mjs && node test/search-summaries-off.test.mjs && node test/document-return-contracts.test.mjs && node test/observation-date-prefix.test.mjs && node test/delete-entities-cascade.test.mjs && node test/delete-entities-kg-hygiene.test.mjs && node test/backup-publish-portable.test.mjs && node test/graph-context-explain.test.mjs && node test/eval-graph-role-libs.test.mjs && node test/eval-graph-role-t5b.test.mjs && node --test test/eval-graph-role-t8-fix.test.mjs && node --test test/eval-graph-role-t7-upstream.test.mjs && node --test test/eval-graph-role-t11-decision.test.mjs && node --test test/eval-graph-role-prereq-fix.test.mjs && node test/db-path.test.mjs && node test/mmap-option.test.mjs",
48
+ "verify:engine": "node test/engine-smoke.test.mjs && node test/launch-smoke.test.mjs && node test/sync-atomicity.test.mjs && node test/dedup.test.mjs && node test/search-degradation.test.mjs && node test/entity-embed-cap.test.mjs && node test/migration12.test.mjs && node test/model-cache.test.mjs && node test/embedding-gate.test.mjs && node test/lazy-boot.test.mjs && node test/reconciliation.test.mjs && node test/backfill.test.mjs && node test/fts-query.test.mjs && node test/search-contracts.test.mjs && node test/tool-contracts.test.mjs && node test/bounded-exit.test.mjs && node test/observation-schema.test.mjs && node test/search-graph-default.test.mjs && node test/observation-migration.test.mjs && node test/observation-lifecycle.test.mjs && node test/observation-contracts.test.mjs && node test/observation-search.test.mjs && node test/observation-cascade.test.mjs && node test/observation-realdata.test.mjs && node test/chunker-c.test.mjs && node test/migration14.test.mjs && node test/chunk-params-validation.test.mjs && node test/vector-reuse.test.mjs && node test/entity-range-linking.test.mjs && node test/alias-link-gate.test.mjs && node test/entity-name-boundary.test.mjs && node test/stats-chunking.test.mjs && node test/migration14-realdata.test.mjs && node test/migration14-realdata-sync.test.mjs && node test/search-summaries-off.test.mjs && node test/document-return-contracts.test.mjs && node test/observation-date-prefix.test.mjs && node test/delete-entities-cascade.test.mjs && node test/delete-entities-kg-hygiene.test.mjs && node test/backup-publish-portable.test.mjs && node test/graph-context-explain.test.mjs && node test/eval-graph-role-libs.test.mjs && node test/eval-graph-role-t5b.test.mjs && node --test test/eval-graph-role-t8-fix.test.mjs && node --test test/eval-graph-role-t7-upstream.test.mjs && node --test test/eval-graph-role-t11-decision.test.mjs && node --test test/eval-graph-role-prereq-fix.test.mjs && node test/db-path.test.mjs && node test/mmap-option.test.mjs && node test/wal-exit.test.mjs && node test/wal-sidecar-guard.test.mjs",
49
49
  "test": "npm run build && npm run verify:invariants && npm run verify:engine",
50
50
  "prepublishOnly": "npm run build && npm run verify:invariants && npm run verify:engine"
51
51
  },