fix: dedupe degraded sqlite-vec warnings (#67898) (thanks @rubencu)

* Agents: dedupe bootstrap truncation warnings

* Memory: dedupe sqlite-vec degradation warnings

* Memory: align degraded vector warning

* test(memory-core): remove stale vector warning arg

* fix(memory-core): reset degraded warning on vector reset

* fix(memory-core): preserve warning latch across reindex rollback

* fix: dedupe degraded sqlite-vec warnings (#67898) (thanks @rubencu)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Rubén Cuevas
2026-04-17 01:39:14 -04:00
committed by GitHub
parent 8205de84a9
commit 7b0e950e09
8 changed files with 117 additions and 35 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -11,12 +11,7 @@ const log = createSubsystemLogger("memory");
export type MemoryReadonlyRecoveryState = {
closed: boolean;
db: DatabaseSync;
vectorReady: Promise<boolean> | 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<void>;
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;

View File

@@ -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<void>;
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<boolean> {
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) {

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -16,10 +16,12 @@ type ReadonlyRecoveryHarness = MemoryReadonlyRecoveryState & {
syncing: Promise<void> | null;
queuedSessionFiles: Set<string>;
queuedSessionSync: Promise<void> | null;
vectorDegradedWriteWarningShown: boolean;
ensureProviderInitialized: ReturnType<typeof vi.fn>;
enqueueTargetedSessionSync: ReturnType<typeof vi.fn>;
runSync: ReturnType<typeof vi.fn>;
openDatabase: ReturnType<typeof vi.fn>;
resetVectorState: ReturnType<typeof vi.fn>;
ensureSchema: ReturnType<typeof vi.fn>;
readMeta: ReturnType<typeof vi.fn>;
};
@@ -66,13 +68,10 @@ describe("memory manager readonly recovery", () => {
queuedSessionFiles: new Set<string>(),
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

View File

@@ -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<boolean> | 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,
};