diff --git a/CHANGELOG.md b/CHANGELOG.md index 4772f195723..1b1ec3789ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ Docs: https://docs.openclaw.ai - Doctor/TTS: migrate legacy `messages.tts.enabled`, agent TTS, channel TTS, and voice-call plugin TTS toggles to `auto` mode during `openclaw doctor --fix`, matching the documented TTS config contract. Thanks @vincentkoc. - CLI/logs: fall back to the configured Gateway file log when implicit loopback Gateway connections close or time out before or during `logs.tail`, so `openclaw logs` still works while diagnosing local-model Gateway disconnects. Refs #74078. Thanks @sakalaboator. - MCP/plugins: stringify non-array plugin tool results with chat-content coercion instead of default object stringification, so MCP callers receive useful JSON/text content from plugin tools. Thanks @vincentkoc. -- Active Memory/QMD: run QMD boot refresh through a one-shot subprocess path, preserve interactive file watching, and align watcher dependency/build ignores with QMD's scanner so gateway startup avoids arming long-lived QMD watchers. Thanks @codexGW. +- Active Memory/QMD: make gateway-start QMD refresh opt-in via `memory.qmd.update.startup`, keep normal memory access lazy, preserve interactive file watching, and align watcher dependency/build ignores with QMD's scanner so cold gateway startup no longer imports or initializes QMD by default. Thanks @codexGW. - Channels/Discord: remove Discord-owned queued-run timeout replies through the shared channel lifecycle queue while preserving message ordering and compatibility timeout constants, so long Discord turns stay governed by session/tool/runtime lifecycle instead of channel fallback errors. Thanks @codexGW. - Agents/tools: clamp `process.poll` waits to 30 seconds, advertise that cap in the tool schema, and honor abort signals while waiting, so long command polls cannot pin agent responsiveness after cancellation. Thanks @vincentkoc. - Plugin SDK: add tracked Discord component-message helpers and a Telegram account-resolution compatibility facade, so existing plugins using those subpaths resolve while new plugins stay on generic channel SDK contracts. Thanks @vincentkoc. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 2478297bb74..f1060e0fce8 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -6a67688ac174403c996027d90fa16eabb9aeff6a8af890b17d4628910c3b440f config-baseline.json -8bc9fda7c1096472beaa416a61043ce51d691d4dcad9ed3e0be46e68bb70b0ce config-baseline.core.json +96edba9fd67fa057f6b6c43d54a168db25d0e27ddd4e91a7e2918c8657f0f212 config-baseline.json +510ed7af2e3731c8a307dbc10181328f82764a4e8dd9e9dddc6118db6f882ff7 config-baseline.core.json 9f5fad66a49fa618d64a963470aa69fed9fe4b4639cc4321f9ec04bfb2f8aa50 config-baseline.channel.json 0dd6583fafae6c9134e46c4cf9bddee9822d6436436dcb1a6dcba6d012962e51 config-baseline.plugin.json diff --git a/docs/concepts/memory-qmd.md b/docs/concepts/memory-qmd.md index 860ae1a5de1..b73b0ba1d45 100644 --- a/docs/concepts/memory-qmd.md +++ b/docs/concepts/memory-qmd.md @@ -51,17 +51,21 @@ present. ## How the sidecar works - OpenClaw creates collections from your workspace memory files and any - configured `memory.qmd.paths`, then runs `qmd update` on boot and - periodically (default every 5 minutes). These refreshes run through QMD - subprocesses, not an in-process filesystem crawl. Semantic modes also run - `qmd embed`. + configured `memory.qmd.paths`, then runs `qmd update` when the QMD manager is + opened and periodically afterward (default every 5 minutes). These refreshes + run through QMD subprocesses, not an in-process filesystem crawl. Semantic + modes also run `qmd embed`. - The default workspace collection tracks `MEMORY.md` plus the `memory/` tree. Lowercase `memory.md` is not indexed as a root memory file. - QMD's own scanner ignores hidden paths and common dependency/build directories such as `.git`, `.cache`, `node_modules`, `vendor`, `dist`, and - `build`. Boot refreshes use a one-shot QMD subprocess path instead of - creating the full long-lived in-process watcher during gateway startup. -- Boot refresh runs in the background so chat startup is not blocked. + `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. - 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 84fa911412e..9518474bd42 100644 --- a/docs/reference/memory-config.md +++ b/docs/reference/memory-config.md @@ -497,8 +497,10 @@ 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 on startup in a QMD subprocess | - | `update.waitForBootSync` | `boolean` | `false` | Block startup until refresh completes | + | `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.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 | | `update.commandTimeoutMs` | `number` | -- | Timeout for QMD commands | | `update.updateTimeoutMs` | `number` | -- | Timeout for QMD update operations | diff --git a/packages/memory-host-sdk/src/host/backend-config.test.ts b/packages/memory-host-sdk/src/host/backend-config.test.ts index 3ffbb7f073b..d1d8119de67 100644 --- a/packages/memory-host-sdk/src/host/backend-config.test.ts +++ b/packages/memory-host-sdk/src/host/backend-config.test.ts @@ -93,6 +93,9 @@ describe("resolveMemoryBackendConfig", () => { expect(resolved.qmd?.command).toBe("qmd"); expect(resolved.qmd?.searchMode).toBe("search"); expect(resolved.qmd?.update.intervalMs).toBeGreaterThan(0); + expect(resolved.qmd?.update.onBoot).toBe(true); + expect(resolved.qmd?.update.startup).toBe("off"); + expect(resolved.qmd?.update.startupDelayMs).toBe(120_000); expect(resolved.qmd?.update.waitForBootSync).toBe(false); expect(resolved.qmd?.update.commandTimeoutMs).toBe(30_000); expect(resolved.qmd?.update.updateTimeoutMs).toBe(120_000); @@ -351,6 +354,25 @@ describe("resolveMemoryBackendConfig", () => { expect(resolved.qmd?.update.embedTimeoutMs).toBe(360_000); }); + it("resolves qmd startup refresh overrides", () => { + const cfg = { + agents: { defaults: { workspace: "/tmp/memory-test" } }, + memory: { + backend: "qmd", + qmd: { + update: { + startup: "idle", + startupDelayMs: 45_000, + }, + }, + }, + } as OpenClawConfig; + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + expect(resolved.qmd?.update.startup).toBe("idle"); + expect(resolved.qmd?.update.startupDelayMs).toBe(45_000); + expect(resolved.qmd?.update.onBoot).toBe(true); + }); + it("resolves qmd search mode override", () => { const cfg = { agents: { defaults: { workspace: "/tmp/memory-test" } }, diff --git a/packages/memory-host-sdk/src/host/backend-config.ts b/packages/memory-host-sdk/src/host/backend-config.ts index 4a81efa08b0..cda2e9744b2 100644 --- a/packages/memory-host-sdk/src/host/backend-config.ts +++ b/packages/memory-host-sdk/src/host/backend-config.ts @@ -8,6 +8,7 @@ import { type MemoryQmdIndexPath, type MemoryQmdMcporterConfig, type MemoryQmdSearchMode, + type MemoryQmdStartupMode, type OpenClawConfig, parseDurationMs, resolveAgentWorkspaceDir, @@ -35,6 +36,8 @@ export type ResolvedQmdUpdateConfig = { intervalMs: number; debounceMs: number; onBoot: boolean; + startup: MemoryQmdStartupMode; + startupDelayMs: number; waitForBootSync: boolean; embedIntervalMs: number; commandTimeoutMs: number; @@ -82,6 +85,8 @@ const DEFAULT_QMD_TIMEOUT_MS = 4_000; // Defaulting to `query` can be extremely slow on CPU-only systems (query expansion + rerank). // Prefer a faster mode for interactive use; users can opt into `query` for best recall. const DEFAULT_QMD_SEARCH_MODE: MemoryQmdSearchMode = "search"; +const DEFAULT_QMD_STARTUP: MemoryQmdStartupMode = "off"; +const DEFAULT_QMD_STARTUP_DELAY_MS = 120_000; const DEFAULT_QMD_EMBED_INTERVAL = "60m"; const DEFAULT_QMD_COMMAND_TIMEOUT_MS = 30_000; const DEFAULT_QMD_UPDATE_TIMEOUT_MS = 120_000; @@ -209,6 +214,21 @@ function resolveTimeoutMs(raw: number | undefined, fallback: number): number { return fallback; } +function resolveStartupMode(raw: MemoryQmdConfig["update"]): MemoryQmdStartupMode { + const value = raw?.startup; + if (value === "idle" || value === "immediate" || value === "off") { + return value; + } + return DEFAULT_QMD_STARTUP; +} + +function resolveStartupDelayMs(raw: number | undefined): number { + if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) { + return Math.floor(raw); + } + return DEFAULT_QMD_STARTUP_DELAY_MS; +} + function resolveLimits(raw?: MemoryQmdConfig["limits"]): ResolvedQmdLimitsConfig { const parsed: ResolvedQmdLimitsConfig = { ...DEFAULT_QMD_LIMITS }; if (raw?.maxResults && raw.maxResults > 0) { @@ -404,6 +424,8 @@ export function resolveMemoryBackendConfig(params: { intervalMs: resolveIntervalMs(qmdCfg?.update?.interval), debounceMs: resolveDebounceMs(qmdCfg?.update?.debounceMs), onBoot: qmdCfg?.update?.onBoot !== false, + startup: resolveStartupMode(qmdCfg?.update), + startupDelayMs: resolveStartupDelayMs(qmdCfg?.update?.startupDelayMs), waitForBootSync: qmdCfg?.update?.waitForBootSync === true, embedIntervalMs: resolveEmbedIntervalMs(qmdCfg?.update?.embedInterval), commandTimeoutMs: resolveTimeoutMs( diff --git a/packages/memory-host-sdk/src/host/config-utils.ts b/packages/memory-host-sdk/src/host/config-utils.ts index 25d0824acef..2854016832e 100644 --- a/packages/memory-host-sdk/src/host/config-utils.ts +++ b/packages/memory-host-sdk/src/host/config-utils.ts @@ -7,6 +7,7 @@ export type ChatType = "direct" | "group" | "channel"; export type MemoryBackend = "builtin" | "qmd"; export type MemoryCitationsMode = "auto" | "on" | "off"; export type MemoryQmdSearchMode = "query" | "search" | "vsearch"; +export type MemoryQmdStartupMode = "off" | "idle" | "immediate"; export type SessionSendPolicyAction = "allow" | "deny"; export type SessionSendPolicyMatch = { @@ -46,6 +47,8 @@ export type MemoryQmdUpdateConfig = { interval?: string; debounceMs?: number; onBoot?: boolean; + startup?: MemoryQmdStartupMode; + startupDelayMs?: number; waitForBootSync?: boolean; embedInterval?: string; commandTimeoutMs?: number; diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index ef7f1b6982d..38cafcfcef2 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -23221,15 +23221,30 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, onBoot: { type: "boolean", - title: "QMD Update on Startup", + title: "QMD Update on Manager Start", description: - "Runs an initial QMD update once during gateway startup (default: true). Keep enabled so recall starts from a fresh baseline; disable only when startup speed is more important than immediate freshness.", + "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.", + }, + startup: { + type: "string", + enum: ["off", "idle", "immediate"], + title: "QMD Gateway Startup Refresh", + description: + "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.", + }, + startupDelayMs: { + type: "integer", + minimum: 0, + maximum: 9007199254740991, + title: "QMD Gateway Startup Delay (ms)", + description: + '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.', }, waitForBootSync: { type: "boolean", - title: "QMD Wait for Boot Sync", + title: "QMD Wait for Manager-Start Sync", description: - "Blocks startup completion until the initial boot-time QMD sync finishes (default: false). Enable when you need fully up-to-date recall before serving traffic, and keep off for faster boot.", + "Blocks QMD manager opening until its initial manager-start update finishes (default: false). Startup refreshes remain opt-in through `memory.qmd.update.startup`.", }, embedInterval: { type: "string", @@ -26498,13 +26513,23 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { tags: ["performance", "storage"], }, "memory.qmd.update.onBoot": { - label: "QMD Update on Startup", - help: "Runs an initial QMD update once during gateway startup (default: true). Keep enabled so recall starts from a fresh baseline; disable only when startup speed is more important than immediate freshness.", + label: "QMD Update on Manager Start", + help: "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.", + tags: ["storage"], + }, + "memory.qmd.update.startup": { + label: "QMD Gateway Startup Refresh", + help: "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.", + tags: ["storage"], + }, + "memory.qmd.update.startupDelayMs": { + label: "QMD Gateway Startup Delay (ms)", + help: '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.', tags: ["storage"], }, "memory.qmd.update.waitForBootSync": { - label: "QMD Wait for Boot Sync", - help: "Blocks startup completion until the initial boot-time QMD sync finishes (default: false). Enable when you need fully up-to-date recall before serving traffic, and keep off for faster boot.", + label: "QMD Wait for Manager-Start Sync", + help: "Blocks QMD manager opening until its initial manager-start update finishes (default: false). Startup refreshes remain opt-in through `memory.qmd.update.startup`.", tags: ["storage"], }, "memory.qmd.update.embedInterval": { diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index e1591d9657c..b9f485460a8 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -60,6 +60,8 @@ const TARGET_KEYS = [ "memory.qmd.update.interval", "memory.qmd.update.debounceMs", "memory.qmd.update.onBoot", + "memory.qmd.update.startup", + "memory.qmd.update.startupDelayMs", "memory.qmd.update.waitForBootSync", "memory.qmd.update.embedInterval", "memory.qmd.update.commandTimeoutMs", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 8024365a134..6f7d6fa55ff 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1122,9 +1122,13 @@ 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 once during gateway startup (default: true). Keep enabled so recall starts from a fresh baseline; disable only when startup speed is more important than immediate freshness.", + "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.", + "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.", + "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": - "Blocks startup completion until the initial boot-time QMD sync finishes (default: false). Enable when you need fully up-to-date recall before serving traffic, and keep off for faster boot.", + "Blocks QMD manager opening until its initial manager-start update finishes (default: false). Startup refreshes remain opt-in through `memory.qmd.update.startup`.", "memory.qmd.update.embedInterval": "Sets how often QMD recomputes embeddings (duration string, default: 60m; set 0 to disable periodic embeds). Lower intervals improve freshness but increase embedding workload and cost.", "memory.qmd.update.commandTimeoutMs": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index cd432a480e1..9718d9082c3 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -481,8 +481,10 @@ export const FIELD_LABELS: Record = { "memory.qmd.sessions.retentionDays": "QMD Session Retention (days)", "memory.qmd.update.interval": "QMD Update Interval", "memory.qmd.update.debounceMs": "QMD Update Debounce (ms)", - "memory.qmd.update.onBoot": "QMD Update on Startup", - "memory.qmd.update.waitForBootSync": "QMD Wait for Boot Sync", + "memory.qmd.update.onBoot": "QMD Update on Manager Start", + "memory.qmd.update.startup": "QMD Gateway Startup Refresh", + "memory.qmd.update.startupDelayMs": "QMD Gateway Startup Delay (ms)", + "memory.qmd.update.waitForBootSync": "QMD Wait for Manager-Start Sync", "memory.qmd.update.embedInterval": "QMD Embed Interval", "memory.qmd.update.commandTimeoutMs": "QMD Command Timeout (ms)", "memory.qmd.update.updateTimeoutMs": "QMD Update Timeout (ms)", diff --git a/src/config/types.memory.ts b/src/config/types.memory.ts index ce09d8e848e..2bb15d11aad 100644 --- a/src/config/types.memory.ts +++ b/src/config/types.memory.ts @@ -3,6 +3,7 @@ import type { SessionSendPolicyConfig } from "./types.base.js"; export type MemoryBackend = "builtin" | "qmd"; export type MemoryCitationsMode = "auto" | "on" | "off"; export type MemoryQmdSearchMode = "query" | "search" | "vsearch"; +export type MemoryQmdStartupMode = "off" | "idle" | "immediate"; export type MemoryConfig = { backend?: MemoryBackend; @@ -53,6 +54,8 @@ export type MemoryQmdUpdateConfig = { interval?: string; debounceMs?: number; onBoot?: boolean; + startup?: MemoryQmdStartupMode; + startupDelayMs?: number; waitForBootSync?: boolean; embedInterval?: string; commandTimeoutMs?: number; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 1249e4872db..a2a8ac55924 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -70,6 +70,8 @@ const MemoryQmdUpdateSchema = z interval: z.string().optional(), debounceMs: z.number().int().nonnegative().optional(), onBoot: z.boolean().optional(), + startup: z.enum(["off", "idle", "immediate"]).optional(), + startupDelayMs: z.number().int().nonnegative().optional(), waitForBootSync: z.boolean().optional(), embedInterval: z.string().optional(), commandTimeoutMs: z.number().int().nonnegative().optional(), diff --git a/src/gateway/server-startup-memory.test.ts b/src/gateway/server-startup-memory.test.ts index e52266dbc97..f269a64cecf 100644 --- a/src/gateway/server-startup-memory.test.ts +++ b/src/gateway/server-startup-memory.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import type { MemoryQmdUpdateConfig } from "../config/types.memory.js"; const { getMemorySearchManagerMock } = vi.hoisted(() => ({ getMemorySearchManagerMock: vi.fn(), @@ -11,10 +12,13 @@ vi.mock("../plugins/memory-runtime.js", () => ({ import { startGatewayMemoryBackend } from "./server-startup-memory.js"; -function createQmdConfig(agents: OpenClawConfig["agents"]): OpenClawConfig { +function createQmdConfig( + agents: OpenClawConfig["agents"], + update: MemoryQmdUpdateConfig = { startup: "immediate" }, +): OpenClawConfig { return { agents, - memory: { backend: "qmd", qmd: {} }, + memory: { backend: "qmd", qmd: { update } }, } as OpenClawConfig; } @@ -49,6 +53,20 @@ describe("startGatewayMemoryBackend", () => { expect(log.warn).not.toHaveBeenCalled(); }); + it("keeps qmd managers lazy when startup refresh is not opted in", async () => { + const cfg = { + agents: { list: [{ id: "main", default: true }] }, + memory: { backend: "qmd", qmd: {} }, + } as OpenClawConfig; + const log = createGatewayLogMock(); + + await startGatewayMemoryBackend({ cfg, log }); + + expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); + expect(log.info).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); + }); + it("runs qmd boot sync for the default and explicitly configured agents", async () => { const cfg = createQmdConfig({ list: [ @@ -162,7 +180,7 @@ describe("startGatewayMemoryBackend", () => { memory: { backend: "qmd", qmd: { - update: { onBoot: false, interval: "0s", embedInterval: "0s" }, + update: { startup: "immediate", onBoot: false, interval: "0s", embedInterval: "0s" }, }, }, } as OpenClawConfig; diff --git a/src/gateway/server-startup-memory.ts b/src/gateway/server-startup-memory.ts index cbe082eac88..a5c9b355ad6 100644 --- a/src/gateway/server-startup-memory.ts +++ b/src/gateway/server-startup-memory.ts @@ -9,7 +9,7 @@ import { getActiveMemorySearchManager } from "../plugins/memory-runtime.js"; import { normalizeAgentId } from "../routing/session-key.js"; function shouldRunQmdStartupBootSync(qmd: ResolvedQmdConfig): boolean { - return qmd.update.onBoot; + return qmd.update.onBoot && qmd.update.startup !== "off"; } function hasExplicitAgentMemorySearchConfig(cfg: OpenClawConfig, agentId: string): boolean { diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 31dea54f722..d7c17441c7d 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -253,7 +253,7 @@ describe("startGatewayPostAttachRuntime", () => { expect(hoisted.startGatewayMemoryBackend).not.toHaveBeenCalled(); }); - it("starts the qmd memory backend only when configured", async () => { + it("keeps the qmd memory backend lazy by default", async () => { await startGatewayPostAttachRuntime({ ...createPostAttachParams(), gatewayPluginConfigAtStart: { @@ -262,11 +262,68 @@ describe("startGatewayPostAttachRuntime", () => { } as never, }); + expect(hoisted.startGatewayMemoryBackend).not.toHaveBeenCalled(); + expect( + __testing.resolveGatewayMemoryStartupPolicy({ memory: { backend: "qmd" } } as never), + ).toEqual({ mode: "off" }); + expect( + __testing.resolveGatewayMemoryStartupPolicy({ + memory: { backend: "qmd", qmd: { update: { startup: "immediate", onBoot: false } } }, + } as never), + ).toEqual({ mode: "off" }); + }); + + it("starts the qmd memory backend when startup refresh is immediate", async () => { + await startGatewayPostAttachRuntime({ + ...createPostAttachParams(), + gatewayPluginConfigAtStart: { + hooks: { internal: { enabled: false } }, + memory: { backend: "qmd", qmd: { update: { startup: "immediate" } } }, + } as never, + }); + await vi.waitFor(() => { expect(hoisted.startGatewayMemoryBackend).toHaveBeenCalledTimes(1); }); }); + it("defers qmd memory backend startup refresh until the idle delay elapses", async () => { + vi.useFakeTimers(); + try { + await startGatewaySidecars({ + cfg: { + hooks: { internal: { enabled: false } }, + memory: { backend: "qmd", qmd: { update: { startup: "idle", startupDelayMs: 25 } } }, + } as never, + pluginRegistry: createPostAttachParams().pluginRegistry, + defaultWorkspaceDir: "/tmp/openclaw-workspace", + deps: {} as never, + startChannels: vi.fn(async () => undefined), + log: { warn: vi.fn() }, + logHooks: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + logChannels: { + info: vi.fn(), + error: vi.fn(), + }, + }); + + expect(hoisted.startGatewayMemoryBackend).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(24); + expect(hoisted.startGatewayMemoryBackend).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + await vi.waitFor(() => { + expect(hoisted.startGatewayMemoryBackend).toHaveBeenCalledTimes(1); + }); + } finally { + vi.useRealTimers(); + } + }); + it("waits for sidecars by default before returning", async () => { let resumeSidecars!: () => void; const sidecarsReady = new Promise<{ pluginServices: null }>((resolve) => { diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 130ed15d054..3bf93c7e096 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -24,6 +24,7 @@ const ACP_BACKEND_READY_POLL_MS = 50; const PRIMARY_MODEL_PREWARM_TIMEOUT_MS = 5_000; const STARTUP_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000; const SKIP_STARTUP_MODEL_PREWARM_ENV = "OPENCLAW_SKIP_STARTUP_MODEL_PREWARM"; +const QMD_STARTUP_IDLE_DELAY_MS = 120_000; type Awaitable = T | Promise; @@ -32,6 +33,11 @@ type GatewayStartupTrace = { measure: (name: string, run: () => Awaitable) => Promise; }; +type GatewayMemoryStartupPolicy = + | { mode: "off" } + | { mode: "immediate" } + | { mode: "idle"; delayMs: number }; + async function measureStartup( startupTrace: GatewayStartupTrace | undefined, name: string, @@ -49,8 +55,51 @@ function shouldSkipStartupModelPrewarm(env: NodeJS.ProcessEnv = process.env): bo return raw === "1" || raw === "true" || raw === "yes" || raw === "on"; } -function shouldStartGatewayMemoryBackend(cfg: OpenClawConfig): boolean { - return cfg.memory?.backend === "qmd"; +function resolveGatewayMemoryStartupPolicy(cfg: OpenClawConfig): GatewayMemoryStartupPolicy { + 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" }; + } + if (startup === "idle") { + const rawDelayMs = cfg.memory.qmd?.update?.startupDelayMs; + const delayMs = + typeof rawDelayMs === "number" && Number.isFinite(rawDelayMs) && rawDelayMs >= 0 + ? Math.floor(rawDelayMs) + : QMD_STARTUP_IDLE_DELAY_MS; + return { mode: "idle", delayMs }; + } + return { mode: "off" }; +} + +function scheduleGatewayMemoryBackend(params: { + cfg: OpenClawConfig; + log: { warn: (msg: string) => void }; + policy: GatewayMemoryStartupPolicy; +}): void { + if (params.policy.mode === "off") { + return; + } + const start = () => { + void import("./server-startup-memory.js") + .then(({ startGatewayMemoryBackend }) => + startGatewayMemoryBackend({ cfg: params.cfg, log: params.log }), + ) + .catch((err) => { + params.log.warn(`qmd memory startup initialization failed: ${String(err)}`); + }); + }; + if (params.policy.mode === "immediate") { + setImmediate(start); + return; + } + const timer = setTimeout(start, params.policy.delayMs); + timer.unref?.(); } function hasGatewayStartHooks(pluginRegistry: ReturnType): boolean { @@ -425,18 +474,11 @@ export async function startGatewaySidecars(params: { } await measureStartup(params.startupTrace, "sidecars.memory", async () => { - if (!shouldStartGatewayMemoryBackend(params.cfg)) { + const policy = resolveGatewayMemoryStartupPolicy(params.cfg); + if (policy.mode === "off") { return; } - setImmediate(() => { - void import("./server-startup-memory.js") - .then(({ startGatewayMemoryBackend }) => - startGatewayMemoryBackend({ cfg: params.cfg, log: params.log }), - ) - .catch((err) => { - params.log.warn(`qmd memory startup initialization failed: ${String(err)}`); - }); - }); + scheduleGatewayMemoryBackend({ cfg: params.cfg, log: params.log, policy }); }); await measureStartup(params.startupTrace, "sidecars.restart-sentinel", async () => { @@ -680,6 +722,7 @@ export async function startGatewayPostAttachRuntime( export const __testing = { prewarmConfiguredPrimaryModel, prewarmConfiguredPrimaryModelWithTimeout, + resolveGatewayMemoryStartupPolicy, schedulePrimaryModelPrewarm, shouldSkipStartupModelPrewarm, }; diff --git a/src/memory-host-sdk/host/backend-config.test.ts b/src/memory-host-sdk/host/backend-config.test.ts index 60156ad6001..cfbe14cf9ff 100644 --- a/src/memory-host-sdk/host/backend-config.test.ts +++ b/src/memory-host-sdk/host/backend-config.test.ts @@ -72,6 +72,9 @@ describe("resolveMemoryBackendConfig", () => { expect(resolved.qmd?.command).toBe("qmd"); expect(resolved.qmd?.searchMode).toBe("search"); expect(resolved.qmd?.update.intervalMs).toBeGreaterThan(0); + expect(resolved.qmd?.update.onBoot).toBe(true); + expect(resolved.qmd?.update.startup).toBe("off"); + expect(resolved.qmd?.update.startupDelayMs).toBe(120_000); expect(resolved.qmd?.update.waitForBootSync).toBe(false); expect(resolved.qmd?.update.commandTimeoutMs).toBe(30_000); expect(resolved.qmd?.update.updateTimeoutMs).toBe(120_000); @@ -306,6 +309,25 @@ describe("resolveMemoryBackendConfig", () => { expect(resolved.qmd?.update.embedTimeoutMs).toBe(360_000); }); + it("resolves qmd startup refresh overrides", () => { + const cfg = { + agents: { defaults: { workspace: "/tmp/memory-test" } }, + memory: { + backend: "qmd", + qmd: { + update: { + startup: "idle", + startupDelayMs: 45_000, + }, + }, + }, + } as OpenClawConfig; + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + expect(resolved.qmd?.update.startup).toBe("idle"); + expect(resolved.qmd?.update.startupDelayMs).toBe(45_000); + expect(resolved.qmd?.update.onBoot).toBe(true); + }); + it("resolves qmd search mode override", () => { const cfg = { agents: { defaults: { workspace: "/tmp/memory-test" } }, diff --git a/src/memory-host-sdk/host/backend-config.ts b/src/memory-host-sdk/host/backend-config.ts index 05b9f6ca110..efa7780a4d0 100644 --- a/src/memory-host-sdk/host/backend-config.ts +++ b/src/memory-host-sdk/host/backend-config.ts @@ -10,6 +10,7 @@ import type { MemoryQmdIndexPath, MemoryQmdMcporterConfig, MemoryQmdSearchMode, + MemoryQmdStartupMode, } from "../../config/types.memory.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { CANONICAL_ROOT_MEMORY_FILENAME } from "../../memory/root-memory-files.js"; @@ -38,6 +39,8 @@ export type ResolvedQmdUpdateConfig = { intervalMs: number; debounceMs: number; onBoot: boolean; + startup: MemoryQmdStartupMode; + startupDelayMs: number; waitForBootSync: boolean; embedIntervalMs: number; commandTimeoutMs: number; @@ -85,6 +88,8 @@ const DEFAULT_QMD_TIMEOUT_MS = 4_000; // Defaulting to `query` can be extremely slow on CPU-only systems (query expansion + rerank). // Prefer a faster mode for interactive use; users can opt into `query` for best recall. const DEFAULT_QMD_SEARCH_MODE: MemoryQmdSearchMode = "search"; +const DEFAULT_QMD_STARTUP: MemoryQmdStartupMode = "off"; +const DEFAULT_QMD_STARTUP_DELAY_MS = 120_000; const DEFAULT_QMD_EMBED_INTERVAL = "60m"; const DEFAULT_QMD_COMMAND_TIMEOUT_MS = 30_000; const DEFAULT_QMD_UPDATE_TIMEOUT_MS = 120_000; @@ -216,6 +221,21 @@ function resolveTimeoutMs(raw: number | undefined, fallback: number): number { return fallback; } +function resolveStartupMode(raw: MemoryQmdConfig["update"]): MemoryQmdStartupMode { + const value = raw?.startup; + if (value === "idle" || value === "immediate" || value === "off") { + return value; + } + return DEFAULT_QMD_STARTUP; +} + +function resolveStartupDelayMs(raw: number | undefined): number { + if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) { + return Math.floor(raw); + } + return DEFAULT_QMD_STARTUP_DELAY_MS; +} + function resolveLimits(raw?: MemoryQmdConfig["limits"]): ResolvedQmdLimitsConfig { const parsed: ResolvedQmdLimitsConfig = { ...DEFAULT_QMD_LIMITS }; if (raw?.maxResults && raw.maxResults > 0) { @@ -411,6 +431,8 @@ export function resolveMemoryBackendConfig(params: { intervalMs: resolveIntervalMs(qmdCfg?.update?.interval), debounceMs: resolveDebounceMs(qmdCfg?.update?.debounceMs), onBoot: qmdCfg?.update?.onBoot !== false, + startup: resolveStartupMode(qmdCfg?.update), + startupDelayMs: resolveStartupDelayMs(qmdCfg?.update?.startupDelayMs), waitForBootSync: qmdCfg?.update?.waitForBootSync === true, embedIntervalMs: resolveEmbedIntervalMs(qmdCfg?.update?.embedInterval), commandTimeoutMs: resolveTimeoutMs(