fix(memory/qmd): honor embedInterval independent of update interval

This commit is contained in:
Vignesh Natarajan 2026-03-28 18:03:58 -07:00
parent acbdafc4f4
commit 19e52a1ba2
No known key found for this signature in database
GPG Key ID: C5E014CC92E2A144
3 changed files with 159 additions and 0 deletions

View File

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

View File

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

View File

@ -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<void> | null = null;
private queuedForcedUpdate: Promise<void> | 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(