From 17be26bc4f8bf2d0df9a6c299b05ca443673750e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 11 Jun 2026 01:13:41 +0900 Subject: [PATCH] fix(gateway): arm qmd startup maintenance Fix Gateway QMD startup so interval and embedding maintenance are armed when configured, even when the immediate on-boot update is disabled. --- docs/concepts/memory-qmd.md | 10 +- docs/reference/memory-config.md | 6 +- src/config/schema.help.ts | 4 +- src/gateway/server-startup-memory.test.ts | 107 ++++++++++++++---- src/gateway/server-startup-memory.ts | 38 +++++-- .../server-startup-post-attach.test.ts | 16 ++- src/gateway/server-startup-post-attach.ts | 3 - 7 files changed, 137 insertions(+), 47 deletions(-) diff --git a/docs/concepts/memory-qmd.md b/docs/concepts/memory-qmd.md index 9f687eb48b4..005bcfffa8f 100644 --- a/docs/concepts/memory-qmd.md +++ b/docs/concepts/memory-qmd.md @@ -62,10 +62,12 @@ present. `build`. Gateway startup does not initialize QMD by default, so cold boot avoids importing the memory runtime or creating the long-lived watcher before memory is first used. -- If you want a gateway-start refresh anyway, set - `memory.qmd.update.startup` to `idle` or `immediate`. The opt-in startup - refresh uses a one-shot QMD subprocess path instead of creating the full - long-lived in-process watcher. +- If you want QMD initialized at gateway start anyway, set + `memory.qmd.update.startup` to `idle` or `immediate`. With + `memory.qmd.update.onBoot: true`, startup runs the initial refresh. With + `onBoot: false`, startup skips that immediate refresh but still opens the + long-lived manager when update or embed intervals are configured, so QMD can + own its regular watcher and timers. - Searches use the configured `searchMode` (default: `search`; also supports `vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic vector readiness probes and embedding maintenance in that mode. If a mode diff --git a/docs/reference/memory-config.md b/docs/reference/memory-config.md index 74ea8d30ccf..e6ba0759b7d 100644 --- a/docs/reference/memory-config.md +++ b/docs/reference/memory-config.md @@ -498,8 +498,8 @@ QMD model overrides stay on the QMD side, not OpenClaw config. If you need to ov | ------------------------- | --------- | ------- | ------------------------------------- | | `update.interval` | `string` | `5m` | Refresh interval | | `update.debounceMs` | `number` | `15000` | Debounce file changes | - | `update.onBoot` | `boolean` | `true` | Refresh when the long-lived QMD manager opens; also gates opt-in startup refresh | - | `update.startup` | `string` | `off` | Optional gateway-start refresh: `off`, `idle`, or `immediate` | + | `update.onBoot` | `boolean` | `true` | Refresh when the long-lived QMD manager opens; set false to skip the immediate boot update | + | `update.startup` | `string` | `off` | Optional gateway-start QMD initialization: `off`, `idle`, or `immediate` | | `update.startupDelayMs` | `number` | `120000` | Delay before `startup: "idle"` refresh runs | | `update.waitForBootSync` | `boolean` | `false` | Block manager opening until its initial refresh completes | | `update.embedInterval` | `string` | -- | Separate embed cadence | @@ -548,7 +548,7 @@ QMD model overrides stay on the QMD side, not OpenClaw config. If you need to ov -QMD boot refreshes use a one-shot subprocess path during gateway startup. The long-lived QMD manager still owns the regular file watcher and interval timers when memory search is opened for interactive use. +When gateway-start QMD initialization is enabled, OpenClaw starts QMD only for eligible agents. If `update.onBoot` is true and no interval/embed maintenance is configured, startup uses a one-shot manager for the boot refresh and closes it. If an update or embed interval is configured, startup opens the long-lived QMD manager so it can own the watcher and interval timers; `update.onBoot: false` skips only the immediate boot refresh. ### Full QMD example diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 6a198388c99..c051764fad3 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1302,9 +1302,9 @@ export const FIELD_HELP: Record = { "memory.qmd.update.debounceMs": "Sets the minimum delay between consecutive QMD refresh attempts in milliseconds (default: 15000). Increase this if frequent file changes cause update thrash or unnecessary background load.", "memory.qmd.update.onBoot": - "Runs an initial QMD update when the long-lived QMD manager opens (default: true). Set false to disable manager-start updates and legacy/opt-in startup refreshes.", + "Runs an initial QMD update when the long-lived QMD manager opens (default: true). Set false to skip manager-start boot updates while keeping configured interval/embed maintenance.", "memory.qmd.update.startup": - "Controls whether Gateway startup schedules a QMD refresh before memory is first used (`off`, `idle`, or `immediate`; default: off). Keep off for fastest startup and lazy memory initialization.", + "Controls whether Gateway startup initializes QMD before memory is first used (`off`, `idle`, or `immediate`; default: off). With onBoot disabled, startup only arms configured interval/embed maintenance.", "memory.qmd.update.startupDelayMs": 'Sets the idle delay before an opt-in `memory.qmd.update.startup: "idle"` refresh runs (default: 120000). Increase to keep cold-start CPU available for channels and providers.', "memory.qmd.update.waitForBootSync": diff --git a/src/gateway/server-startup-memory.test.ts b/src/gateway/server-startup-memory.test.ts index 86b525d3d54..6122549875a 100644 --- a/src/gateway/server-startup-memory.test.ts +++ b/src/gateway/server-startup-memory.test.ts @@ -55,12 +55,20 @@ function expectNoMemoryBackendStartup(log: ReturnType { }); it("runs qmd boot sync for the default and explicitly configured agents", async () => { - const cfg = createQmdConfig({ - list: [ - { id: "ops", default: true }, - { id: "main", memorySearch: { enabled: true } }, - { id: "lazy" }, - ], - }); + const cfg = createQmdConfig( + { + list: [ + { id: "ops", default: true }, + { id: "main", memorySearch: { enabled: true } }, + { id: "lazy" }, + ], + }, + { startup: "immediate", interval: "0s", embedInterval: "0s" }, + ); const log = await startQmdBackendWithManager(cfg); @@ -123,10 +134,13 @@ describe("startGatewayMemoryBackend", () => { }); it("initializes all qmd agents when memory search is explicitly enabled in defaults", async () => { - const cfg = createQmdConfig({ - defaults: { memorySearch: { enabled: true } }, - list: [{ id: "ops", default: true }, { id: "main" }], - }); + const cfg = createQmdConfig( + { + defaults: { memorySearch: { enabled: true } }, + list: [{ id: "ops", default: true }, { id: "main" }], + }, + { startup: "immediate", interval: "0s", embedInterval: "0s" }, + ); const log = await startQmdBackendWithManager(cfg); @@ -138,12 +152,15 @@ describe("startGatewayMemoryBackend", () => { }); it("logs a warning when qmd manager init fails and continues with other agents", async () => { - const cfg = createQmdConfig({ - list: [ - { id: "main", default: true }, - { id: "ops", memorySearch: { enabled: true } }, - ], - }); + const cfg = createQmdConfig( + { + list: [ + { id: "main", default: true }, + { id: "ops", memorySearch: { enabled: true } }, + ], + }, + { startup: "immediate", interval: "0s", embedInterval: "0s" }, + ); const log = createGatewayLogMock(); getMemorySearchManagerMock .mockResolvedValueOnce({ manager: null, error: "qmd missing" }) @@ -158,13 +175,16 @@ describe("startGatewayMemoryBackend", () => { }); it("skips agents with memory search disabled", async () => { - const cfg = createQmdConfig({ - defaults: { memorySearch: { enabled: true } }, - list: [ - { id: "main", default: true }, - { id: "ops", memorySearch: { enabled: false } }, - ], - }); + const cfg = createQmdConfig( + { + defaults: { memorySearch: { enabled: true } }, + list: [ + { id: "main", default: true }, + { id: "ops", memorySearch: { enabled: false } }, + ], + }, + { startup: "immediate", interval: "0s", embedInterval: "0s" }, + ); const log = await startQmdBackendWithManager(cfg); @@ -188,4 +208,41 @@ describe("startGatewayMemoryBackend", () => { expectNoMemoryBackendStartup(log); }); + + it("keeps the full qmd manager alive for startup interval maintenance", async () => { + const manager = createQmdManagerMock(); + getMemorySearchManagerMock.mockResolvedValue({ manager }); + const cfg = createQmdConfig( + { list: [{ id: "main", default: true }] }, + { startup: "immediate", onBoot: false, interval: "5m", embedInterval: "0s" }, + ); + + const log = await startMemoryBackendForTest(cfg); + + expectQmdManagerRequestsWithPurpose(cfg, ["main"], "default"); + expect(manager.sync).not.toHaveBeenCalled(); + expect(manager.close).not.toHaveBeenCalled(); + expect(log.info).toHaveBeenCalledWith( + 'qmd memory startup manager initialized for 1 agent: "main"', + ); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it("does not manually boot sync full qmd managers that own their startup update", async () => { + const manager = createQmdManagerMock(); + getMemorySearchManagerMock.mockResolvedValue({ manager }); + const cfg = createQmdConfig( + { list: [{ id: "main", default: true }] }, + { startup: "immediate", onBoot: true, interval: "5m", embedInterval: "0s" }, + ); + + const log = await startMemoryBackendForTest(cfg); + + expectQmdManagerRequestsWithPurpose(cfg, ["main"], "default"); + expect(manager.sync).not.toHaveBeenCalled(); + expect(manager.close).not.toHaveBeenCalled(); + expect(log.info).toHaveBeenCalledWith( + 'qmd memory startup manager initialized for 1 agent: "main"', + ); + }); }); diff --git a/src/gateway/server-startup-memory.ts b/src/gateway/server-startup-memory.ts index bf54e45b2a0..8c45521766f 100644 --- a/src/gateway/server-startup-memory.ts +++ b/src/gateway/server-startup-memory.ts @@ -10,9 +10,16 @@ import { import { getActiveMemorySearchManager } from "../plugins/memory-runtime.js"; import { normalizeAgentId } from "../routing/session-key.js"; -/** True when qmd memory config opts into startup boot sync work. */ -function shouldRunQmdStartupBootSync(qmd: ResolvedQmdConfig): boolean { - return qmd.update.onBoot && qmd.update.startup !== "off"; +/** True when qmd memory config opts into Gateway startup manager work. */ +function shouldRunQmdStartupManager(qmd: ResolvedQmdConfig): boolean { + return ( + qmd.update.startup !== "off" && (qmd.update.onBoot || shouldKeepQmdStartupManagerAlive(qmd)) + ); +} + +/** True when startup needs the full manager to own QMD background timers. */ +function shouldKeepQmdStartupManagerAlive(qmd: ResolvedQmdConfig): boolean { + return qmd.update.intervalMs > 0 || qmd.update.embedIntervalMs > 0; } /** Check whether an agent overrides memory search instead of inheriting defaults. */ @@ -46,7 +53,8 @@ export async function startGatewayMemoryBackend(params: { log: { info?: (msg: string) => void; warn: (msg: string) => void }; }): Promise { const agentIds = listAgentIds(params.cfg); - const armedAgentIds: string[] = []; + const bootSyncAgentIds: string[] = []; + const initializedAgentIds: string[] = []; const deferredAgentIds: string[] = []; for (const agentId of agentIds) { if (!resolveMemorySearchConfig(params.cfg, agentId)) { @@ -59,7 +67,7 @@ export async function startGatewayMemoryBackend(params: { if (resolved.backend !== "qmd" || !resolved.qmd) { continue; } - if (!shouldRunQmdStartupBootSync(resolved.qmd)) { + if (!shouldRunQmdStartupManager(resolved.qmd)) { continue; } if ( @@ -75,10 +83,11 @@ export async function startGatewayMemoryBackend(params: { continue; } + const keepManagerAlive = shouldKeepQmdStartupManagerAlive(resolved.qmd); const { manager, error } = await getActiveMemorySearchManager({ cfg: params.cfg, agentId, - purpose: "cli", + purpose: keepManagerAlive ? "default" : "cli", }); if (!manager) { params.log.warn( @@ -86,6 +95,10 @@ export async function startGatewayMemoryBackend(params: { ); continue; } + if (keepManagerAlive) { + initializedAgentIds.push(agentId); + continue; + } try { await manager.sync?.({ reason: "boot", force: true }); } catch (err) { @@ -98,11 +111,18 @@ export async function startGatewayMemoryBackend(params: { ); }); } - armedAgentIds.push(agentId); + bootSyncAgentIds.push(agentId); } - if (armedAgentIds.length > 0) { + if (bootSyncAgentIds.length > 0) { params.log.info?.( - `qmd memory startup boot sync completed for ${formatAgentCount(armedAgentIds.length)}: ${armedAgentIds + `qmd memory startup boot sync completed for ${formatAgentCount(bootSyncAgentIds.length)}: ${bootSyncAgentIds + .map((agentId) => `"${agentId}"`) + .join(", ")}`, + ); + } + if (initializedAgentIds.length > 0) { + params.log.info?.( + `qmd memory startup manager initialized for ${formatAgentCount(initializedAgentIds.length)}: ${initializedAgentIds .map((agentId) => `"${agentId}"`) .join(", ")}`, ); diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 063817f2edd..78e567da67a 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -776,7 +776,21 @@ describe("startGatewayPostAttachRuntime", () => { testing.resolveGatewayMemoryStartupPolicy({ memory: { backend: "qmd", qmd: { update: { startup: "immediate", onBoot: false } } }, } as never), - ).toEqual({ mode: "off" }); + ).toEqual({ mode: "immediate" }); + }); + + it("allows qmd startup initialization when manager-start boot sync is disabled", async () => { + await startGatewayPostAttachRuntime({ + ...createPostAttachParams(), + gatewayPluginConfigAtStart: { + hooks: { internal: { enabled: false } }, + memory: { backend: "qmd", qmd: { update: { startup: "immediate", onBoot: false } } }, + } as never, + }); + + await vi.waitFor(() => { + expect(hoisted.startGatewayMemoryBackend).toHaveBeenCalledTimes(1); + }); }); it("starts the qmd memory backend when startup refresh is immediate", async () => { diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 9cd1d30296c..6e03fe55526 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -155,9 +155,6 @@ function resolveGatewayMemoryStartupPolicy(cfg: OpenClawConfig): GatewayMemorySt if (cfg.memory?.backend !== "qmd") { return { mode: "off" }; } - if (cfg.memory.qmd?.update?.onBoot === false) { - return { mode: "off" }; - } const startup = cfg.memory.qmd?.update?.startup; if (startup === "immediate") { return { mode: "immediate" };