mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix(memory): make qmd gateway startup lazy
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user