From 2dfbb407ba8f7c22bbb2b7fa6627822e8c13a240 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 14 Feb 2026 18:08:59 -0800 Subject: [PATCH] Memory/QMD: self-heal null-byte collection metadata on update --- src/memory/qmd-manager.ts | 97 +++++++++++++++++++++++++++++++-------- 1 file changed, 77 insertions(+), 20 deletions(-) diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 8b917e27f..62e70e2b3 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -32,6 +32,7 @@ const log = createSubsystemLogger("memory"); const SNIPPET_HEADER_RE = /@@\s*-([0-9]+),([0-9]+)/; const SEARCH_PENDING_UPDATE_WAIT_MS = 500; const MAX_QMD_OUTPUT_CHARS = 200_000; +const NUL_MARKER_RE = /(?:\^@|\\0|\\x00|\\u0000|null\s*byte|nul\s*byte)/i; type CollectionRoot = { path: string; @@ -97,6 +98,7 @@ export class QmdMemoryManager implements MemorySearchManager { private db: SqliteDatabase | null = null; private lastUpdateAt: number | null = null; private lastEmbedAt: number | null = null; + private attemptedNullByteCollectionRepair = false; private constructor(params: { cfg: OpenClawConfig; @@ -228,27 +230,10 @@ export class QmdMemoryManager implements MemorySearchManager { continue; } try { - await this.runQmd( - [ - "collection", - "add", - collection.path, - "--name", - collection.name, - "--mask", - collection.pattern, - ], - { - timeoutMs: this.qmd.update.commandTimeoutMs, - }, - ); + await this.addCollection(collection.path, collection.name, collection.pattern); } catch (err) { const message = err instanceof Error ? err.message : String(err); - // Idempotency: qmd exits non-zero if the collection name already exists. - if (message.toLowerCase().includes("already exists")) { - continue; - } - if (message.toLowerCase().includes("exists")) { + if (this.isCollectionAlreadyExistsError(message)) { continue; } log.warn(`qmd collection add failed for ${collection.name}: ${message}`); @@ -256,6 +241,71 @@ export class QmdMemoryManager implements MemorySearchManager { } } + private isCollectionAlreadyExistsError(message: string): boolean { + const lower = message.toLowerCase(); + return lower.includes("already exists") || lower.includes("exists"); + } + + private isCollectionMissingError(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes("not found") || lower.includes("does not exist") || lower.includes("missing") + ); + } + + private async addCollection(pathArg: string, name: string, pattern: string): Promise { + await this.runQmd(["collection", "add", pathArg, "--name", name, "--mask", pattern], { + timeoutMs: this.qmd.update.commandTimeoutMs, + }); + } + + private async removeCollection(name: string): Promise { + await this.runQmd(["collection", "remove", name], { + timeoutMs: this.qmd.update.commandTimeoutMs, + }); + } + + private shouldRepairNullByteCollectionError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + const lower = message.toLowerCase(); + return ( + (lower.includes("enotdir") || lower.includes("not a directory")) && + NUL_MARKER_RE.test(message) + ); + } + + private async tryRepairNullByteCollections(err: unknown, reason: string): Promise { + if (this.attemptedNullByteCollectionRepair) { + return false; + } + if (!this.shouldRepairNullByteCollectionError(err)) { + return false; + } + this.attemptedNullByteCollectionRepair = true; + log.warn( + `qmd update failed with suspected null-byte collection metadata (${reason}); rebuilding managed collections and retrying once`, + ); + for (const collection of this.qmd.collections) { + try { + await this.removeCollection(collection.name); + } catch (removeErr) { + const removeMessage = removeErr instanceof Error ? removeErr.message : String(removeErr); + if (!this.isCollectionMissingError(removeMessage)) { + log.warn(`qmd collection remove failed for ${collection.name}: ${removeMessage}`); + } + } + try { + await this.addCollection(collection.path, collection.name, collection.pattern); + } catch (addErr) { + const addMessage = addErr instanceof Error ? addErr.message : String(addErr); + if (!this.isCollectionAlreadyExistsError(addMessage)) { + log.warn(`qmd collection add failed for ${collection.name}: ${addMessage}`); + } + } + } + return true; + } + async search( query: string, opts?: { maxResults?: number; minScore?: number; sessionKey?: string }, @@ -470,7 +520,14 @@ export class QmdMemoryManager implements MemorySearchManager { if (this.sessionExporter) { await this.exportSessions(); } - await this.runQmd(["update"], { timeoutMs: this.qmd.update.updateTimeoutMs }); + try { + await this.runQmd(["update"], { timeoutMs: this.qmd.update.updateTimeoutMs }); + } catch (err) { + if (!(await this.tryRepairNullByteCollections(err, reason))) { + throw err; + } + await this.runQmd(["update"], { timeoutMs: this.qmd.update.updateTimeoutMs }); + } const embedIntervalMs = this.qmd.update.embedIntervalMs; const shouldEmbed = Boolean(force) ||