From 19e52a1ba23eeeb619366384ff016720436364ca Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 28 Mar 2026 18:03:58 -0700 Subject: [PATCH] fix(memory/qmd): honor embedInterval independent of update interval --- CHANGELOG.md | 1 + .../src/memory/qmd-manager.test.ts | 134 ++++++++++++++++++ .../memory-core/src/memory/qmd-manager.ts | 24 ++++ 3 files changed, 159 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 642bedc1e5c..8e4277f7ba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Security/LINE: make webhook signature validation run the timing-safe compare even when the supplied signature length is wrong, closing a small timing side-channel. (#55663) Thanks @gavyngong. - LINE/status: stop `openclaw status` from warning about missing credentials when sanitized LINE snapshots are already configured, while still surfacing whether the missing field is the token or secret. (#45701) Thanks @tamaosamu. - Gateway/health: carry webhook-vs-polling account mode from channel descriptors into runtime snapshots so passive channels like LINE and BlueBubbles skip false stale-socket health failures. (#47488) Thanks @karesansui-u. +- Memory/QMD: honor `memory.qmd.update.embedInterval` even when regular QMD update cadence is disabled or slower by arming a dedicated embed-cadence maintenance timer, while avoiding redundant timers when regular updates are already frequent enough. (#37326) Thanks @barronlroth. ## 2026.3.28-beta.1 diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index b49b43637e5..e191877752e 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -2025,6 +2025,140 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("runs periodic embed maintenance even when regular update scheduling is disabled", async () => { + vi.useFakeTimers(); + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + searchMode: "query", + update: { + interval: "0s", + debounceMs: 0, + onBoot: false, + embedInterval: "5m", + }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + + const { manager } = await createManager({ mode: "full" }); + + const commandCallsBefore = spawnMock.mock.calls.filter((call: unknown[]) => { + const args = call[1] as string[]; + return args[0] === "update" || args[0] === "embed"; + }); + expect(commandCallsBefore).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(5 * 60_000); + + const commandCalls = spawnMock.mock.calls + .map((call: unknown[]) => call[1] as string[]) + .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); + expect(commandCalls).toEqual([["update"], ["embed"]]); + + await manager.close(); + }); + + it("runs periodic embed maintenance when embed cadence is faster than update cadence", async () => { + vi.useFakeTimers(); + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + searchMode: "query", + update: { + interval: "20m", + debounceMs: 0, + onBoot: false, + embedInterval: "5m", + }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + + const { manager } = await createManager({ mode: "full" }); + + await vi.advanceTimersByTimeAsync(5 * 60_000); + + const commandCalls = spawnMock.mock.calls + .map((call: unknown[]) => call[1] as string[]) + .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); + expect(commandCalls).toEqual([["update"], ["embed"]]); + + await manager.close(); + }); + + it("does not schedule redundant embed maintenance when regular updates are already more frequent", async () => { + vi.useFakeTimers(); + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + searchMode: "query", + update: { + interval: "5m", + debounceMs: 0, + onBoot: false, + embedInterval: "20m", + }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + + const { manager } = await createManager({ mode: "full" }); + + await vi.advanceTimersByTimeAsync(6 * 60_000); + + const commandCalls = spawnMock.mock.calls + .map((call: unknown[]) => call[1] as string[]) + .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); + expect(commandCalls).toEqual([["update"], ["embed"]]); + + await manager.close(); + }); + + it("does not arm periodic embed maintenance in search mode", async () => { + vi.useFakeTimers(); + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + searchMode: "search", + update: { + interval: "0s", + debounceMs: 0, + onBoot: false, + embedInterval: "5m", + }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + + const { manager } = await createManager({ mode: "full" }); + + await vi.advanceTimersByTimeAsync(5 * 60_000); + + const commandCalls = spawnMock.mock.calls + .map((call: unknown[]) => call[1] as string[]) + .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); + expect(commandCalls).toEqual([]); + + await manager.close(); + }); + it("skips qmd embed in search mode even for forced sync", async () => { cfg = { ...cfg, diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 75c5e3f0704..0fb679a38c1 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -184,6 +184,7 @@ export class QmdMemoryManager implements MemorySearchManager { private readonly maxQmdOutputChars = MAX_QMD_OUTPUT_CHARS; private readonly sessionExporter: SessionExporterConfig | null; private updateTimer: NodeJS.Timeout | null = null; + private embedTimer: NodeJS.Timeout | null = null; private pendingUpdate: Promise | null = null; private queuedForcedUpdate: Promise | null = null; private queuedForcedRuns = 0; @@ -290,6 +291,13 @@ export class QmdMemoryManager implements MemorySearchManager { }); }, this.qmd.update.intervalMs); } + if (this.shouldScheduleEmbedTimer()) { + this.embedTimer = setInterval(() => { + void this.runUpdate("embed-interval").catch((err) => { + log.warn(`qmd embed interval update failed (${String(err)})`); + }); + }, this.qmd.update.embedIntervalMs); + } } private bootstrapCollections(): void { @@ -985,6 +993,10 @@ export class QmdMemoryManager implements MemorySearchManager { clearInterval(this.updateTimer); this.updateTimer = null; } + if (this.embedTimer) { + clearInterval(this.embedTimer); + this.embedTimer = null; + } this.queuedForcedRuns = 0; await this.pendingUpdate?.catch(() => undefined); await this.queuedForcedUpdate?.catch(() => undefined); @@ -1111,6 +1123,18 @@ export class QmdMemoryManager implements MemorySearchManager { ); } + private shouldScheduleEmbedTimer(): boolean { + if (this.qmd.searchMode === "search") { + return false; + } + const embedIntervalMs = this.qmd.update.embedIntervalMs; + if (embedIntervalMs <= 0) { + return false; + } + const updateIntervalMs = this.qmd.update.intervalMs; + return updateIntervalMs <= 0 || updateIntervalMs > embedIntervalMs; + } + private noteEmbedFailure(reason: string, err: unknown): void { this.embedFailureCount += 1; const delayMs = Math.min(