fix(memory): make qmd gateway startup lazy

This commit is contained in:
Peter Steinberger
2026-04-29 08:45:03 +01:00
parent e52b660749
commit 2b811fe6d9
19 changed files with 294 additions and 41 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

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

View File

@@ -1122,9 +1122,13 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@@ -481,8 +481,10 @@ export const FIELD_LABELS: Record<string, string> = {
"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)",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

@@ -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> = T | Promise<T>;
@@ -32,6 +33,11 @@ type GatewayStartupTrace = {
measure: <T>(name: string, run: () => Awaitable<T>) => Promise<T>;
};
type GatewayMemoryStartupPolicy =
| { mode: "off" }
| { mode: "immediate" }
| { mode: "idle"; delayMs: number };
async function measureStartup<T>(
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<typeof loadOpenClawPlugins>): 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,
};

View File

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

View File

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