diff --git a/CHANGELOG.md b/CHANGELOG.md index c5ef367a1bf..6a6348d25f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Plugins/webhooks: enforce synchronous plugin registration with full rollback of failed plugin side effects, and cache SecretRef-backed webhook auth per route so plugin startup and inbound webhook auth stay deterministic. (#67941) Thanks @obviyus. - Telegram/ACP bindings: drop persisted DM bindings that still point at missing or failed ACP sessions on restart, while preserving plugin-owned bindings and uncertain store reads. (#67822) Thanks @chinar-amrutkar. - Telegram/streaming: keep a transient preview on the same Telegram message when auto-compaction retries an in-flight answer, so streamed replies no longer appear duplicated after compaction. (#66939) Thanks @rubencu. +- Memory/sqlite-vec: emit the degraded sqlite-vec warning once per degraded episode instead of repeating it for every file write, while preserving the latch across safe-reindex rollback and resetting it when vector state is genuinely rebuilt. (#67898) Thanks @rubencu. ## 2026.4.15 diff --git a/extensions/memory-core/src/memory/manager-embedding-ops.ts b/extensions/memory-core/src/memory/manager-embedding-ops.ts index f914cd697c0..182c0dbdad9 100644 --- a/extensions/memory-core/src/memory/manager-embedding-ops.ts +++ b/extensions/memory-core/src/memory/manager-embedding-ops.ts @@ -36,6 +36,7 @@ import { } from "./manager-embedding-policy.js"; import { deleteMemoryFtsRows } from "./manager-fts-state.js"; import { MemoryManagerSyncOps } from "./manager-sync-ops.js"; +import { logMemoryVectorDegradedWrite } from "./manager-vector-warning.js"; import { replaceMemoryVectorRow } from "./manager-vector-write.js"; const VECTOR_TABLE = "chunks_vec"; @@ -568,12 +569,14 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps { .run(chunk.text, id, entry.path, source, model, chunk.startLine, chunk.endLine); } } - if (this.vector.enabled && !vectorReady && chunks.length > 0) { - const errDetail = this.vector.loadError ? `: ${this.vector.loadError}` : ""; - log.warn( - `chunks written for ${entry.path} without vector embeddings — chunks_vec not updated (sqlite-vec unavailable${errDetail}). Vector recall degraded for this file.`, - ); - } + this.vectorDegradedWriteWarningShown = logMemoryVectorDegradedWrite({ + vectorEnabled: this.vector.enabled, + vectorReady, + chunkCount: chunks.length, + warningShown: this.vectorDegradedWriteWarningShown, + loadError: this.vector.loadError, + warn: (message) => log.warn(message), + }); this.upsertFileRecord(entry, source); } diff --git a/extensions/memory-core/src/memory/manager-sync-control.ts b/extensions/memory-core/src/memory/manager-sync-control.ts index 847dbd06583..96c9db939ab 100644 --- a/extensions/memory-core/src/memory/manager-sync-control.ts +++ b/extensions/memory-core/src/memory/manager-sync-control.ts @@ -11,12 +11,7 @@ const log = createSubsystemLogger("memory"); export type MemoryReadonlyRecoveryState = { closed: boolean; db: DatabaseSync; - vectorReady: Promise | null; vector: { - enabled: boolean; - available: boolean | null; - extensionPath?: string; - loadError?: string; dims?: number; }; readonlyRecoveryAttempts: number; @@ -30,6 +25,7 @@ export type MemoryReadonlyRecoveryState = { progress?: (update: MemorySyncProgressUpdate) => void; }) => Promise; openDatabase: () => DatabaseSync; + resetVectorState: () => void; ensureSchema: () => void; readMeta: () => { vectorDims?: number } | undefined; }; @@ -107,9 +103,7 @@ export async function runMemorySyncWithReadonlyRecovery( state.db.close(); } catch {} state.db = state.openDatabase(); - state.vectorReady = null; - state.vector.available = null; - state.vector.loadError = undefined; + state.resetVectorState(); state.ensureSchema(); const meta = state.readMeta(); state.vector.dims = meta?.vectorDims; diff --git a/extensions/memory-core/src/memory/manager-sync-ops.ts b/extensions/memory-core/src/memory/manager-sync-ops.ts index 23d1df6f47b..d1055f73bfa 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.ts @@ -166,6 +166,7 @@ export abstract class MemoryManagerSyncOps { string, { lastSize: number; pendingBytes: number; pendingMessages: number } >(); + protected vectorDegradedWriteWarningShown = false; private lastMetaSerialized: string | null = null; protected abstract readonly cache: { enabled: boolean; maxEntries?: number }; @@ -190,6 +191,14 @@ export abstract class MemoryManagerSyncOps { options: { source: MemorySource; content?: string }, ): Promise; + protected resetVectorState(): void { + this.vectorReady = null; + this.vector.available = null; + this.vector.loadError = undefined; + this.vector.dims = undefined; + this.vectorDegradedWriteWarningShown = false; + } + protected async ensureVectorReady(dimensions?: number): Promise { if (!this.vector.enabled) { return false; @@ -1122,6 +1131,7 @@ export abstract class MemoryManagerSyncOps { vectorAvailable: this.vector.available, vectorLoadError: this.vector.loadError, vectorDims: this.vector.dims, + vectorDegradedWriteWarningShown: this.vectorDegradedWriteWarningShown, vectorReady: this.vectorReady, }; @@ -1136,14 +1146,12 @@ export abstract class MemoryManagerSyncOps { this.vector.available = originalDbClosed ? null : originalState.vectorAvailable; this.vector.loadError = originalState.vectorLoadError; this.vector.dims = originalState.vectorDims; + this.vectorDegradedWriteWarningShown = originalState.vectorDegradedWriteWarningShown; this.vectorReady = originalDbClosed ? null : originalState.vectorReady; }; this.db = tempDb; - this.vectorReady = null; - this.vector.available = null; - this.vector.loadError = undefined; - this.vector.dims = undefined; + this.resetVectorState(); this.fts.available = false; this.fts.loadError = undefined; this.ensureSchema(); @@ -1211,9 +1219,7 @@ export abstract class MemoryManagerSyncOps { }); this.db = openMemoryDatabaseAtPath(dbPath, this.settings.store.vector.enabled); - this.vectorReady = null; - this.vector.available = null; - this.vector.loadError = undefined; + this.resetVectorState(); this.ensureSchema(); this.vector.dims = nextMeta?.vectorDims; } catch (err) { diff --git a/extensions/memory-core/src/memory/manager-vector-warning.test.ts b/extensions/memory-core/src/memory/manager-vector-warning.test.ts new file mode 100644 index 00000000000..dcd1a80d5d1 --- /dev/null +++ b/extensions/memory-core/src/memory/manager-vector-warning.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import { logMemoryVectorDegradedWrite } from "./manager-vector-warning.js"; + +describe("memory vector degradation warnings", () => { + it("emits the degraded warning only once for a manager", () => { + const warn = vi.fn(); + + const first = logMemoryVectorDegradedWrite({ + vectorEnabled: true, + vectorReady: false, + chunkCount: 3, + warningShown: false, + loadError: "load failed", + warn, + }); + const second = logMemoryVectorDegradedWrite({ + vectorEnabled: true, + vectorReady: false, + chunkCount: 2, + warningShown: first, + loadError: "load failed", + warn, + }); + + expect(first).toBe(true); + expect(second).toBe(true); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "chunks_vec not updated — sqlite-vec unavailable: load failed. Vector recall degraded. Further duplicate warnings suppressed.", + ); + }); + + it("skips the warning when vector writes are available", () => { + const warn = vi.fn(); + + const shown = logMemoryVectorDegradedWrite({ + vectorEnabled: true, + vectorReady: true, + chunkCount: 1, + warningShown: false, + warn, + }); + + expect(shown).toBe(false); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/memory-core/src/memory/manager-vector-warning.ts b/extensions/memory-core/src/memory/manager-vector-warning.ts new file mode 100644 index 00000000000..0c77035830e --- /dev/null +++ b/extensions/memory-core/src/memory/manager-vector-warning.ts @@ -0,0 +1,22 @@ +export function logMemoryVectorDegradedWrite(params: { + vectorEnabled: boolean; + vectorReady: boolean; + chunkCount: number; + warningShown: boolean; + loadError?: string; + warn: (message: string) => void; +}): boolean { + if ( + !params.vectorEnabled || + params.vectorReady || + params.chunkCount <= 0 || + params.warningShown + ) { + return params.warningShown; + } + const errDetail = params.loadError ? `: ${params.loadError}` : ""; + params.warn( + `chunks_vec not updated — sqlite-vec unavailable${errDetail}. Vector recall degraded. Further duplicate warnings suppressed.`, + ); + return true; +} diff --git a/extensions/memory-core/src/memory/manager.readonly-recovery.test.ts b/extensions/memory-core/src/memory/manager.readonly-recovery.test.ts index 5183c78b926..067ce7cadae 100644 --- a/extensions/memory-core/src/memory/manager.readonly-recovery.test.ts +++ b/extensions/memory-core/src/memory/manager.readonly-recovery.test.ts @@ -16,10 +16,12 @@ type ReadonlyRecoveryHarness = MemoryReadonlyRecoveryState & { syncing: Promise | null; queuedSessionFiles: Set; queuedSessionSync: Promise | null; + vectorDegradedWriteWarningShown: boolean; ensureProviderInitialized: ReturnType; enqueueTargetedSessionSync: ReturnType; runSync: ReturnType; openDatabase: ReturnType; + resetVectorState: ReturnType; ensureSchema: ReturnType; readMeta: ReturnType; }; @@ -66,13 +68,10 @@ describe("memory manager readonly recovery", () => { queuedSessionFiles: new Set(), queuedSessionSync: null, db: initialDb, - vectorReady: null, vector: { - enabled: false, - available: null, - loadError: "stale", dims: 123, }, + vectorDegradedWriteWarningShown: true, readonlyRecoveryAttempts: 0, readonlyRecoverySuccesses: 0, readonlyRecoveryFailures: 0, @@ -81,6 +80,10 @@ describe("memory manager readonly recovery", () => { enqueueTargetedSessionSync: vi.fn(async () => {}), runSync: vi.fn(async (_params) => undefined) as ReadonlyRecoveryHarness["runSync"], openDatabase: vi.fn(() => reopenedDb), + resetVectorState: vi.fn(function (this: ReadonlyRecoveryHarness) { + this.vector.dims = undefined; + this.vectorDegradedWriteWarningShown = false; + }) as ReadonlyRecoveryHarness["resetVectorState"], ensureSchema: vi.fn(() => undefined) as ReadonlyRecoveryHarness["ensureSchema"], readMeta: vi.fn(() => undefined), }; @@ -132,6 +135,7 @@ describe("memory manager readonly recovery", () => { expect(harness.runSync).toHaveBeenCalledTimes(2); expect(harness.openDatabase).toHaveBeenCalledTimes(1); + expect(harness.resetVectorState).toHaveBeenCalledTimes(1); expect(initialClose).toHaveBeenCalledTimes(1); expectReadonlyRecoveryStatus(harness, params.expectedLastError); } @@ -173,9 +177,23 @@ describe("memory manager readonly recovery", () => { ).rejects.toThrow("embedding timeout"); expect(harness.runSync).toHaveBeenCalledTimes(1); expect(harness.openDatabase).not.toHaveBeenCalled(); + expect(harness.resetVectorState).not.toHaveBeenCalled(); expect(initialClose).not.toHaveBeenCalled(); }); + it("clears the degraded warning latch before retrying", async () => { + const { harness } = createReadonlyRecoveryHarness(); + harness.runSync.mockRejectedValueOnce(new Error("attempt to write a readonly database")); + + await expect( + runSyncWithReadonlyRecovery(harness, { + reason: "test", + }), + ).resolves.toBeUndefined(); + + expect(harness.vectorDegradedWriteWarningShown).toBe(false); + }); + it("sets busy_timeout on memory sqlite connections", async () => { const db = openMemoryDatabaseAtPath(indexPath, false); const row = db.prepare("PRAGMA busy_timeout").get() as diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index 78385ea7a35..d9b62839135 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -615,10 +615,6 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem const setDb = (value: DatabaseSync) => { this.db = value; }; - const getVectorReady = () => this.vectorReady; - const setVectorReady = (value: Promise | null) => { - this.vectorReady = value; - }; const getReadonlyRecoveryAttempts = () => this.readonlyRecoveryAttempts; const setReadonlyRecoveryAttempts = (value: number) => { this.readonlyRecoveryAttempts = value; @@ -645,12 +641,6 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem set db(value) { setDb(value); }, - get vectorReady() { - return getVectorReady(); - }, - set vectorReady(value) { - setVectorReady(value); - }, vector: this.vector, get readonlyRecoveryAttempts() { return getReadonlyRecoveryAttempts(); @@ -678,6 +668,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem }, runSync: (nextParams) => this.runSync(nextParams), openDatabase: () => this.openDatabase(), + resetVectorState: () => this.resetVectorState(), ensureSchema: () => this.ensureSchema(), readMeta: () => this.readMeta() ?? undefined, };