fix(mcp): detect stale DB after re-indexing and reconnect (#297)

ensureInitialized() now reads meta.json on each tool call to compare
indexedAt against the stored loadedAt timestamp. When gitnexus analyze
rebuilds the index, the next query closes the stale LadybugDB pool
entry and re-opens a fresh connection automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
skyvanguard 2026-03-15 17:16:00 -03:00
parent 6c18ae08f7
commit d4d0132b13
4 changed files with 27 additions and 1 deletions

View file

@ -16,6 +16,7 @@ import { initLbug, executeQuery, executeParameterized, closeLbug, isLbugReady }
import {
listRegisteredRepos,
cleanupOldKuzuFiles,
loadMeta,
type RegistryEntry,
} from '../../storage/repo-manager.js';
// AI context generation is CLI-only (gitnexus analyze)
@ -82,6 +83,7 @@ interface RepoHandle {
indexedAt: string;
lastCommit: string;
stats?: RegistryEntry['stats'];
loadedAt?: string; // meta.indexedAt when DB was opened — for staleness detection
}
export class LocalBackend {
@ -247,7 +249,24 @@ export class LocalBackend {
private async ensureInitialized(repoId: string): Promise<void> {
// Always check the actual pool — the idle timer may have evicted the connection
if (this.initializedRepos.has(repoId) && isLbugReady(repoId)) return;
if (this.initializedRepos.has(repoId) && isLbugReady(repoId)) {
// Staleness check: did `gitnexus analyze` rebuild the DB since we opened it?
const handle = this.repos.get(repoId);
if (handle?.loadedAt) {
const meta = await loadMeta(handle.storagePath);
if (meta?.indexedAt && meta.indexedAt !== handle.loadedAt) {
// DB was rebuilt — close stale connection and fall through to re-init
await closeLbug(repoId);
this.initializedRepos.delete(repoId);
this.contextCache.delete(repoId);
handle.loadedAt = undefined;
} else {
return; // Still fresh
}
} else {
return; // No loadedAt tracked yet (first run)
}
}
const handle = this.repos.get(repoId);
if (!handle) throw new Error(`Unknown repo: ${repoId}`);
@ -255,6 +274,10 @@ export class LocalBackend {
try {
await initLbug(repoId, handle.lbugPath);
this.initializedRepos.add(repoId);
// Record when we loaded so we can detect staleness later
const meta = await loadMeta(handle.storagePath);
handle.loadedAt = meta?.indexedAt;
} catch (err: any) {
// If lock error, mark as not initialized so next call retries
this.initializedRepos.delete(repoId);

View file

@ -54,6 +54,7 @@ const AUGMENT_FTS_INDEXES = [
// Mock repo-manager so augment() finds our test DB
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn(),
loadMeta: vi.fn().mockResolvedValue(null),
}));
let augment: (pattern: string, cwd?: string) => Promise<string>;

View file

@ -14,6 +14,7 @@ import { LOCAL_BACKEND_SEED_DATA, LOCAL_BACKEND_FTS_INDEXES } from '../fixtures/
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
loadMeta: vi.fn().mockResolvedValue(null),
}));
// ─── Block 2: callTool dispatch tests ────────────────────────────────

View file

@ -21,6 +21,7 @@ vi.mock('../../src/mcp/core/lbug-adapter.js', () => ({
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
loadMeta: vi.fn().mockResolvedValue(null),
}));
// Also mock the search modules to avoid loading onnxruntime