mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 12:58:09 +00:00
fix(gateway): arm qmd startup maintenance
Fix Gateway QMD startup so interval and embedding maintenance are armed when configured, even when the immediate on-boot update is disabled.
This commit is contained in:
@@ -62,10 +62,12 @@ present.
|
||||
`build`. Gateway startup does not initialize QMD by default, so cold boot
|
||||
avoids importing the memory runtime or creating the long-lived watcher before
|
||||
memory is first used.
|
||||
- If you want a gateway-start refresh anyway, set
|
||||
`memory.qmd.update.startup` to `idle` or `immediate`. The opt-in startup
|
||||
refresh uses a one-shot QMD subprocess path instead of creating the full
|
||||
long-lived in-process watcher.
|
||||
- If you want QMD initialized at gateway start anyway, set
|
||||
`memory.qmd.update.startup` to `idle` or `immediate`. With
|
||||
`memory.qmd.update.onBoot: true`, startup runs the initial refresh. With
|
||||
`onBoot: false`, startup skips that immediate refresh but still opens the
|
||||
long-lived manager when update or embed intervals are configured, so QMD can
|
||||
own its regular watcher and timers.
|
||||
- Searches use the configured `searchMode` (default: `search`; also supports
|
||||
`vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic
|
||||
vector readiness probes and embedding maintenance in that mode. If a mode
|
||||
|
||||
@@ -498,8 +498,8 @@ QMD model overrides stay on the QMD side, not OpenClaw config. If you need to ov
|
||||
| ------------------------- | --------- | ------- | ------------------------------------- |
|
||||
| `update.interval` | `string` | `5m` | Refresh interval |
|
||||
| `update.debounceMs` | `number` | `15000` | Debounce file changes |
|
||||
| `update.onBoot` | `boolean` | `true` | Refresh when the long-lived QMD manager opens; also gates opt-in startup refresh |
|
||||
| `update.startup` | `string` | `off` | Optional gateway-start refresh: `off`, `idle`, or `immediate` |
|
||||
| `update.onBoot` | `boolean` | `true` | Refresh when the long-lived QMD manager opens; set false to skip the immediate boot update |
|
||||
| `update.startup` | `string` | `off` | Optional gateway-start QMD initialization: `off`, `idle`, or `immediate` |
|
||||
| `update.startupDelayMs` | `number` | `120000` | Delay before `startup: "idle"` refresh runs |
|
||||
| `update.waitForBootSync` | `boolean` | `false` | Block manager opening until its initial refresh completes |
|
||||
| `update.embedInterval` | `string` | -- | Separate embed cadence |
|
||||
@@ -548,7 +548,7 @@ QMD model overrides stay on the QMD side, not OpenClaw config. If you need to ov
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
QMD boot refreshes use a one-shot subprocess path during gateway startup. The long-lived QMD manager still owns the regular file watcher and interval timers when memory search is opened for interactive use.
|
||||
When gateway-start QMD initialization is enabled, OpenClaw starts QMD only for eligible agents. If `update.onBoot` is true and no interval/embed maintenance is configured, startup uses a one-shot manager for the boot refresh and closes it. If an update or embed interval is configured, startup opens the long-lived QMD manager so it can own the watcher and interval timers; `update.onBoot: false` skips only the immediate boot refresh.
|
||||
|
||||
### Full QMD example
|
||||
|
||||
|
||||
@@ -1302,9 +1302,9 @@ 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 when the long-lived QMD manager opens (default: true). Set false to disable manager-start updates and legacy/opt-in startup refreshes.",
|
||||
"Runs an initial QMD update when the long-lived QMD manager opens (default: true). Set false to skip manager-start boot updates while keeping configured interval/embed maintenance.",
|
||||
"memory.qmd.update.startup":
|
||||
"Controls whether Gateway startup schedules a QMD refresh before memory is first used (`off`, `idle`, or `immediate`; default: off). Keep off for fastest startup and lazy memory initialization.",
|
||||
"Controls whether Gateway startup initializes QMD before memory is first used (`off`, `idle`, or `immediate`; default: off). With onBoot disabled, startup only arms configured interval/embed maintenance.",
|
||||
"memory.qmd.update.startupDelayMs":
|
||||
'Sets the idle delay before an opt-in `memory.qmd.update.startup: "idle"` refresh runs (default: 120000). Increase to keep cold-start CPU available for channels and providers.',
|
||||
"memory.qmd.update.waitForBootSync":
|
||||
|
||||
@@ -55,12 +55,20 @@ function expectNoMemoryBackendStartup(log: ReturnType<typeof createGatewayLogMoc
|
||||
}
|
||||
|
||||
function expectQmdManagerRequests(cfg: OpenClawConfig, agentIds: string[]) {
|
||||
expectQmdManagerRequestsWithPurpose(cfg, agentIds, "cli");
|
||||
}
|
||||
|
||||
function expectQmdManagerRequestsWithPurpose(
|
||||
cfg: OpenClawConfig,
|
||||
agentIds: string[],
|
||||
purpose: "cli" | "default",
|
||||
) {
|
||||
expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(agentIds.length);
|
||||
for (const [index, agentId] of agentIds.entries()) {
|
||||
expect(getMemorySearchManagerMock).toHaveBeenNthCalledWith(index + 1, {
|
||||
cfg,
|
||||
agentId,
|
||||
purpose: "cli",
|
||||
purpose,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -104,13 +112,16 @@ describe("startGatewayMemoryBackend", () => {
|
||||
});
|
||||
|
||||
it("runs qmd boot sync for the default and explicitly configured agents", async () => {
|
||||
const cfg = createQmdConfig({
|
||||
list: [
|
||||
{ id: "ops", default: true },
|
||||
{ id: "main", memorySearch: { enabled: true } },
|
||||
{ id: "lazy" },
|
||||
],
|
||||
});
|
||||
const cfg = createQmdConfig(
|
||||
{
|
||||
list: [
|
||||
{ id: "ops", default: true },
|
||||
{ id: "main", memorySearch: { enabled: true } },
|
||||
{ id: "lazy" },
|
||||
],
|
||||
},
|
||||
{ startup: "immediate", interval: "0s", embedInterval: "0s" },
|
||||
);
|
||||
|
||||
const log = await startQmdBackendWithManager(cfg);
|
||||
|
||||
@@ -123,10 +134,13 @@ describe("startGatewayMemoryBackend", () => {
|
||||
});
|
||||
|
||||
it("initializes all qmd agents when memory search is explicitly enabled in defaults", async () => {
|
||||
const cfg = createQmdConfig({
|
||||
defaults: { memorySearch: { enabled: true } },
|
||||
list: [{ id: "ops", default: true }, { id: "main" }],
|
||||
});
|
||||
const cfg = createQmdConfig(
|
||||
{
|
||||
defaults: { memorySearch: { enabled: true } },
|
||||
list: [{ id: "ops", default: true }, { id: "main" }],
|
||||
},
|
||||
{ startup: "immediate", interval: "0s", embedInterval: "0s" },
|
||||
);
|
||||
|
||||
const log = await startQmdBackendWithManager(cfg);
|
||||
|
||||
@@ -138,12 +152,15 @@ describe("startGatewayMemoryBackend", () => {
|
||||
});
|
||||
|
||||
it("logs a warning when qmd manager init fails and continues with other agents", async () => {
|
||||
const cfg = createQmdConfig({
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{ id: "ops", memorySearch: { enabled: true } },
|
||||
],
|
||||
});
|
||||
const cfg = createQmdConfig(
|
||||
{
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{ id: "ops", memorySearch: { enabled: true } },
|
||||
],
|
||||
},
|
||||
{ startup: "immediate", interval: "0s", embedInterval: "0s" },
|
||||
);
|
||||
const log = createGatewayLogMock();
|
||||
getMemorySearchManagerMock
|
||||
.mockResolvedValueOnce({ manager: null, error: "qmd missing" })
|
||||
@@ -158,13 +175,16 @@ describe("startGatewayMemoryBackend", () => {
|
||||
});
|
||||
|
||||
it("skips agents with memory search disabled", async () => {
|
||||
const cfg = createQmdConfig({
|
||||
defaults: { memorySearch: { enabled: true } },
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{ id: "ops", memorySearch: { enabled: false } },
|
||||
],
|
||||
});
|
||||
const cfg = createQmdConfig(
|
||||
{
|
||||
defaults: { memorySearch: { enabled: true } },
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{ id: "ops", memorySearch: { enabled: false } },
|
||||
],
|
||||
},
|
||||
{ startup: "immediate", interval: "0s", embedInterval: "0s" },
|
||||
);
|
||||
|
||||
const log = await startQmdBackendWithManager(cfg);
|
||||
|
||||
@@ -188,4 +208,41 @@ describe("startGatewayMemoryBackend", () => {
|
||||
|
||||
expectNoMemoryBackendStartup(log);
|
||||
});
|
||||
|
||||
it("keeps the full qmd manager alive for startup interval maintenance", async () => {
|
||||
const manager = createQmdManagerMock();
|
||||
getMemorySearchManagerMock.mockResolvedValue({ manager });
|
||||
const cfg = createQmdConfig(
|
||||
{ list: [{ id: "main", default: true }] },
|
||||
{ startup: "immediate", onBoot: false, interval: "5m", embedInterval: "0s" },
|
||||
);
|
||||
|
||||
const log = await startMemoryBackendForTest(cfg);
|
||||
|
||||
expectQmdManagerRequestsWithPurpose(cfg, ["main"], "default");
|
||||
expect(manager.sync).not.toHaveBeenCalled();
|
||||
expect(manager.close).not.toHaveBeenCalled();
|
||||
expect(log.info).toHaveBeenCalledWith(
|
||||
'qmd memory startup manager initialized for 1 agent: "main"',
|
||||
);
|
||||
expect(log.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not manually boot sync full qmd managers that own their startup update", async () => {
|
||||
const manager = createQmdManagerMock();
|
||||
getMemorySearchManagerMock.mockResolvedValue({ manager });
|
||||
const cfg = createQmdConfig(
|
||||
{ list: [{ id: "main", default: true }] },
|
||||
{ startup: "immediate", onBoot: true, interval: "5m", embedInterval: "0s" },
|
||||
);
|
||||
|
||||
const log = await startMemoryBackendForTest(cfg);
|
||||
|
||||
expectQmdManagerRequestsWithPurpose(cfg, ["main"], "default");
|
||||
expect(manager.sync).not.toHaveBeenCalled();
|
||||
expect(manager.close).not.toHaveBeenCalled();
|
||||
expect(log.info).toHaveBeenCalledWith(
|
||||
'qmd memory startup manager initialized for 1 agent: "main"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,16 @@ import {
|
||||
import { getActiveMemorySearchManager } from "../plugins/memory-runtime.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
|
||||
/** True when qmd memory config opts into startup boot sync work. */
|
||||
function shouldRunQmdStartupBootSync(qmd: ResolvedQmdConfig): boolean {
|
||||
return qmd.update.onBoot && qmd.update.startup !== "off";
|
||||
/** True when qmd memory config opts into Gateway startup manager work. */
|
||||
function shouldRunQmdStartupManager(qmd: ResolvedQmdConfig): boolean {
|
||||
return (
|
||||
qmd.update.startup !== "off" && (qmd.update.onBoot || shouldKeepQmdStartupManagerAlive(qmd))
|
||||
);
|
||||
}
|
||||
|
||||
/** True when startup needs the full manager to own QMD background timers. */
|
||||
function shouldKeepQmdStartupManagerAlive(qmd: ResolvedQmdConfig): boolean {
|
||||
return qmd.update.intervalMs > 0 || qmd.update.embedIntervalMs > 0;
|
||||
}
|
||||
|
||||
/** Check whether an agent overrides memory search instead of inheriting defaults. */
|
||||
@@ -46,7 +53,8 @@ export async function startGatewayMemoryBackend(params: {
|
||||
log: { info?: (msg: string) => void; warn: (msg: string) => void };
|
||||
}): Promise<void> {
|
||||
const agentIds = listAgentIds(params.cfg);
|
||||
const armedAgentIds: string[] = [];
|
||||
const bootSyncAgentIds: string[] = [];
|
||||
const initializedAgentIds: string[] = [];
|
||||
const deferredAgentIds: string[] = [];
|
||||
for (const agentId of agentIds) {
|
||||
if (!resolveMemorySearchConfig(params.cfg, agentId)) {
|
||||
@@ -59,7 +67,7 @@ export async function startGatewayMemoryBackend(params: {
|
||||
if (resolved.backend !== "qmd" || !resolved.qmd) {
|
||||
continue;
|
||||
}
|
||||
if (!shouldRunQmdStartupBootSync(resolved.qmd)) {
|
||||
if (!shouldRunQmdStartupManager(resolved.qmd)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
@@ -75,10 +83,11 @@ export async function startGatewayMemoryBackend(params: {
|
||||
continue;
|
||||
}
|
||||
|
||||
const keepManagerAlive = shouldKeepQmdStartupManagerAlive(resolved.qmd);
|
||||
const { manager, error } = await getActiveMemorySearchManager({
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
purpose: "cli",
|
||||
purpose: keepManagerAlive ? "default" : "cli",
|
||||
});
|
||||
if (!manager) {
|
||||
params.log.warn(
|
||||
@@ -86,6 +95,10 @@ export async function startGatewayMemoryBackend(params: {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (keepManagerAlive) {
|
||||
initializedAgentIds.push(agentId);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await manager.sync?.({ reason: "boot", force: true });
|
||||
} catch (err) {
|
||||
@@ -98,11 +111,18 @@ export async function startGatewayMemoryBackend(params: {
|
||||
);
|
||||
});
|
||||
}
|
||||
armedAgentIds.push(agentId);
|
||||
bootSyncAgentIds.push(agentId);
|
||||
}
|
||||
if (armedAgentIds.length > 0) {
|
||||
if (bootSyncAgentIds.length > 0) {
|
||||
params.log.info?.(
|
||||
`qmd memory startup boot sync completed for ${formatAgentCount(armedAgentIds.length)}: ${armedAgentIds
|
||||
`qmd memory startup boot sync completed for ${formatAgentCount(bootSyncAgentIds.length)}: ${bootSyncAgentIds
|
||||
.map((agentId) => `"${agentId}"`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (initializedAgentIds.length > 0) {
|
||||
params.log.info?.(
|
||||
`qmd memory startup manager initialized for ${formatAgentCount(initializedAgentIds.length)}: ${initializedAgentIds
|
||||
.map((agentId) => `"${agentId}"`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
|
||||
@@ -776,7 +776,21 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
testing.resolveGatewayMemoryStartupPolicy({
|
||||
memory: { backend: "qmd", qmd: { update: { startup: "immediate", onBoot: false } } },
|
||||
} as never),
|
||||
).toEqual({ mode: "off" });
|
||||
).toEqual({ mode: "immediate" });
|
||||
});
|
||||
|
||||
it("allows qmd startup initialization when manager-start boot sync is disabled", async () => {
|
||||
await startGatewayPostAttachRuntime({
|
||||
...createPostAttachParams(),
|
||||
gatewayPluginConfigAtStart: {
|
||||
hooks: { internal: { enabled: false } },
|
||||
memory: { backend: "qmd", qmd: { update: { startup: "immediate", onBoot: false } } },
|
||||
} as never,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(hoisted.startGatewayMemoryBackend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("starts the qmd memory backend when startup refresh is immediate", async () => {
|
||||
|
||||
@@ -155,9 +155,6 @@ function resolveGatewayMemoryStartupPolicy(cfg: OpenClawConfig): GatewayMemorySt
|
||||
if (cfg.memory?.backend !== "qmd") {
|
||||
return { mode: "off" };
|
||||
}
|
||||
if (cfg.memory.qmd?.update?.onBoot === false) {
|
||||
return { mode: "off" };
|
||||
}
|
||||
const startup = cfg.memory.qmd?.update?.startup;
|
||||
if (startup === "immediate") {
|
||||
return { mode: "immediate" };
|
||||
|
||||
Reference in New Issue
Block a user