Skip to content

Commit afc4f06

Browse files
committed
fix(memory): isolate qmd boot refresh
1 parent 7e5d6db commit afc4f06

9 files changed

Lines changed: 115 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
1818

1919
- 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.
2020
- 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.
21+
- 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.
2122
- 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.
2223
- 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.
2324
- 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.

docs/concepts/memory-qmd.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,15 @@ present.
5252

5353
- OpenClaw creates collections from your workspace memory files and any
5454
configured `memory.qmd.paths`, then runs `qmd update` on boot and
55-
periodically (default every 5 minutes). Semantic modes also run `qmd embed`.
55+
periodically (default every 5 minutes). These refreshes run through QMD
56+
subprocesses, not an in-process filesystem crawl. Semantic modes also run
57+
`qmd embed`.
5658
- The default workspace collection tracks `MEMORY.md` plus the `memory/`
5759
tree. Lowercase `memory.md` is not indexed as a root memory file.
60+
- QMD's own scanner ignores hidden paths and common dependency/build
61+
directories such as `.git`, `.cache`, `node_modules`, `vendor`, `dist`, and
62+
`build`. Boot refreshes use a one-shot QMD subprocess path instead of
63+
creating the full long-lived in-process watcher during gateway startup.
5864
- Boot refresh runs in the background so chat startup is not blocked.
5965
- Searches use the configured `searchMode` (default: `search`; also supports
6066
`vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic

docs/reference/memory-config.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,7 @@ QMD model overrides stay on the QMD side, not OpenClaw config. If you need to ov
497497
| ------------------------- | --------- | ------- | ------------------------------------- |
498498
| `update.interval` | `string` | `5m` | Refresh interval |
499499
| `update.debounceMs` | `number` | `15000` | Debounce file changes |
500-
| `update.onBoot` | `boolean` | `true` | Refresh on startup |
500+
| `update.onBoot` | `boolean` | `true` | Refresh on startup in a QMD subprocess |
501501
| `update.waitForBootSync` | `boolean` | `false` | Block startup until refresh completes |
502502
| `update.embedInterval` | `string` | -- | Separate embed cadence |
503503
| `update.commandTimeoutMs` | `number` | -- | Timeout for QMD commands |
@@ -545,6 +545,8 @@ QMD model overrides stay on the QMD side, not OpenClaw config. If you need to ov
545545
</Accordion>
546546
</AccordionGroup>
547547

548+
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.
549+
548550
### Full QMD example
549551

550552
```json5

extensions/memory-core/src/memory/qmd-manager.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,18 @@ describe("QmdMemoryManager", () => {
432432
};
433433
const initialUpdateCalls = spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update");
434434
expect(initialUpdateCalls).toHaveLength(0);
435+
const [, watchOptions] = watchMock.mock.calls[0] as unknown as [
436+
string[],
437+
{ ignored?: (watchPath: string) => boolean },
438+
];
439+
expect(watchOptions.ignored?.(path.join(workspaceDir, "node_modules", "pkg", "note.md"))).toBe(
440+
true,
441+
);
442+
expect(watchOptions.ignored?.(path.join(workspaceDir, ".cache", "qmd", "note.md"))).toBe(true);
443+
expect(watchOptions.ignored?.(path.join(workspaceDir, "vendor", "pkg", "note.md"))).toBe(true);
444+
expect(watchOptions.ignored?.(path.join(workspaceDir, "dist", "note.md"))).toBe(true);
445+
expect(watchOptions.ignored?.(path.join(workspaceDir, "build", "note.md"))).toBe(true);
446+
expect(watchOptions.ignored?.(path.join(workspaceDir, "notes.md"))).toBe(false);
435447

436448
watcher.emit("change", path.join(workspaceDir, "notes.md"));
437449
expect(manager.status().dirty).toBe(true);

extensions/memory-core/src/memory/qmd-manager.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,11 @@ const QMD_EMBED_QUEUE_KEY = Symbol.for("openclaw.qmdEmbedQueueTail");
7979
const QMD_UPDATE_QUEUE_KEY = Symbol.for("openclaw.qmdUpdateQueueState");
8080
const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
8181
".git",
82+
".cache",
8283
"node_modules",
84+
"vendor",
85+
"dist",
86+
"build",
8387
".pnpm-store",
8488
".venv",
8589
"venv",
@@ -402,6 +406,7 @@ export class QmdMemoryManager implements MemorySearchManager {
402406
}
403407

404408
private async initialize(mode: QmdManagerMode): Promise<void> {
409+
const startTime = Date.now();
405410
this.bootstrapCollections();
406411
if (mode === "status") {
407412
return;
@@ -424,10 +429,16 @@ export class QmdMemoryManager implements MemorySearchManager {
424429

425430
await this.ensureCollections();
426431
if (mode === "cli") {
432+
log.info(
433+
`qmd manager initialized for agent "${this.agentId}" mode=cli collections=${this.qmd.collections.length} durationMs=${Date.now() - startTime}`,
434+
);
427435
return;
428436
}
429437

430438
this.ensureWatcher();
439+
log.info(
440+
`qmd manager initialized for agent "${this.agentId}" mode=full collections=${this.qmd.collections.length} durationMs=${Date.now() - startTime}`,
441+
);
431442

432443
if (this.qmd.update.onBoot) {
433444
const bootRun = this.runUpdate("boot", true);
@@ -1459,6 +1470,10 @@ export class QmdMemoryManager implements MemorySearchManager {
14591470
return;
14601471
}
14611472
const run = async () => {
1473+
const startTime = Date.now();
1474+
log.debug(
1475+
`qmd sync started for agent "${this.agentId}" reason=${reason} force=${force === true}`,
1476+
);
14621477
await this.withQmdUpdateQueue(async () => {
14631478
if (this.closed) {
14641479
return;
@@ -1492,6 +1507,9 @@ export class QmdMemoryManager implements MemorySearchManager {
14921507
}
14931508
this.lastUpdateAt = Date.now();
14941509
this.docPathCache.clear();
1510+
log.info(
1511+
`qmd sync completed for agent "${this.agentId}" reason=${reason} durationMs=${Date.now() - startTime}`,
1512+
);
14951513
};
14961514
this.pendingUpdate = run().finally(() => {
14971515
this.pendingUpdate = null;
@@ -1513,7 +1531,10 @@ export class QmdMemoryManager implements MemorySearchManager {
15131531
if (watchPaths.size === 0) {
15141532
return;
15151533
}
1516-
this.watcher = chokidar.watch(Array.from(watchPaths), {
1534+
const watchPathList = Array.from(watchPaths);
1535+
const startTime = Date.now();
1536+
log.info(`qmd watcher starting for agent "${this.agentId}" paths=${watchPathList.length}`);
1537+
this.watcher = chokidar.watch(watchPathList, {
15171538
ignoreInitial: true,
15181539
ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath),
15191540
awaitWriteFinish: {
@@ -1528,6 +1549,11 @@ export class QmdMemoryManager implements MemorySearchManager {
15281549
this.watcher.on("add", markDirty);
15291550
this.watcher.on("change", markDirty);
15301551
this.watcher.on("unlink", markDirty);
1552+
this.watcher.once("ready", () => {
1553+
log.info(
1554+
`qmd watcher ready for agent "${this.agentId}" paths=${watchPathList.length} durationMs=${Date.now() - startTime}`,
1555+
);
1556+
});
15311557
}
15321558

15331559
private resolveCollectionWatchPath(collection: ManagedCollection): string {

src/gateway/server-startup-memory.test.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ function createGatewayLogMock() {
2222
return { info: vi.fn(), warn: vi.fn() };
2323
}
2424

25+
function createQmdManagerMock() {
26+
return {
27+
search: vi.fn(),
28+
sync: vi.fn(async () => undefined),
29+
close: vi.fn(async () => undefined),
30+
};
31+
}
32+
2533
describe("startGatewayMemoryBackend", () => {
2634
beforeEach(() => {
2735
getMemorySearchManagerMock.mockClear();
@@ -41,7 +49,7 @@ describe("startGatewayMemoryBackend", () => {
4149
expect(log.warn).not.toHaveBeenCalled();
4250
});
4351

44-
it("initializes qmd backend for the default and explicitly configured agents", async () => {
52+
it("runs qmd boot sync for the default and explicitly configured agents", async () => {
4553
const cfg = createQmdConfig({
4654
list: [
4755
{ id: "ops", default: true },
@@ -50,15 +58,23 @@ describe("startGatewayMemoryBackend", () => {
5058
],
5159
});
5260
const log = createGatewayLogMock();
53-
getMemorySearchManagerMock.mockResolvedValue({ manager: { search: vi.fn() } });
61+
getMemorySearchManagerMock.mockResolvedValue({ manager: createQmdManagerMock() });
5462

5563
await startGatewayMemoryBackend({ cfg, log });
5664

5765
expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(2);
58-
expect(getMemorySearchManagerMock).toHaveBeenNthCalledWith(1, { cfg, agentId: "ops" });
59-
expect(getMemorySearchManagerMock).toHaveBeenNthCalledWith(2, { cfg, agentId: "main" });
66+
expect(getMemorySearchManagerMock).toHaveBeenNthCalledWith(1, {
67+
cfg,
68+
agentId: "ops",
69+
purpose: "cli",
70+
});
71+
expect(getMemorySearchManagerMock).toHaveBeenNthCalledWith(2, {
72+
cfg,
73+
agentId: "main",
74+
purpose: "cli",
75+
});
6076
expect(log.info).toHaveBeenCalledWith(
61-
'qmd memory startup initialization armed for 2 agents: "ops", "main"',
77+
'qmd memory startup boot sync completed for 2 agents: "ops", "main"',
6278
);
6379
expect(log.info).toHaveBeenCalledWith(
6480
'qmd memory startup initialization deferred for 1 agent: "lazy"',
@@ -72,15 +88,23 @@ describe("startGatewayMemoryBackend", () => {
7288
list: [{ id: "ops", default: true }, { id: "main" }],
7389
});
7490
const log = createGatewayLogMock();
75-
getMemorySearchManagerMock.mockResolvedValue({ manager: { search: vi.fn() } });
91+
getMemorySearchManagerMock.mockResolvedValue({ manager: createQmdManagerMock() });
7692

7793
await startGatewayMemoryBackend({ cfg, log });
7894

7995
expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(2);
80-
expect(getMemorySearchManagerMock).toHaveBeenNthCalledWith(1, { cfg, agentId: "ops" });
81-
expect(getMemorySearchManagerMock).toHaveBeenNthCalledWith(2, { cfg, agentId: "main" });
96+
expect(getMemorySearchManagerMock).toHaveBeenNthCalledWith(1, {
97+
cfg,
98+
agentId: "ops",
99+
purpose: "cli",
100+
});
101+
expect(getMemorySearchManagerMock).toHaveBeenNthCalledWith(2, {
102+
cfg,
103+
agentId: "main",
104+
purpose: "cli",
105+
});
82106
expect(log.info).toHaveBeenCalledWith(
83-
'qmd memory startup initialization armed for 2 agents: "ops", "main"',
107+
'qmd memory startup boot sync completed for 2 agents: "ops", "main"',
84108
);
85109
expect(log.info).not.toHaveBeenCalledWith(expect.stringContaining("deferred"));
86110
});
@@ -95,15 +119,15 @@ describe("startGatewayMemoryBackend", () => {
95119
const log = createGatewayLogMock();
96120
getMemorySearchManagerMock
97121
.mockResolvedValueOnce({ manager: null, error: "qmd missing" })
98-
.mockResolvedValueOnce({ manager: { search: vi.fn() } });
122+
.mockResolvedValueOnce({ manager: createQmdManagerMock() });
99123

100124
await startGatewayMemoryBackend({ cfg, log });
101125

102126
expect(log.warn).toHaveBeenCalledWith(
103127
'qmd memory startup initialization failed for agent "main": qmd missing',
104128
);
105129
expect(log.info).toHaveBeenCalledWith(
106-
'qmd memory startup initialization armed for 1 agent: "ops"',
130+
'qmd memory startup boot sync completed for 1 agent: "ops"',
107131
);
108132
});
109133

@@ -116,14 +140,18 @@ describe("startGatewayMemoryBackend", () => {
116140
],
117141
});
118142
const log = createGatewayLogMock();
119-
getMemorySearchManagerMock.mockResolvedValue({ manager: { search: vi.fn() } });
143+
getMemorySearchManagerMock.mockResolvedValue({ manager: createQmdManagerMock() });
120144

121145
await startGatewayMemoryBackend({ cfg, log });
122146

123147
expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1);
124-
expect(getMemorySearchManagerMock).toHaveBeenCalledWith({ cfg, agentId: "main" });
148+
expect(getMemorySearchManagerMock).toHaveBeenCalledWith({
149+
cfg,
150+
agentId: "main",
151+
purpose: "cli",
152+
});
125153
expect(log.info).toHaveBeenCalledWith(
126-
'qmd memory startup initialization armed for 1 agent: "main"',
154+
'qmd memory startup boot sync completed for 1 agent: "main"',
127155
);
128156
expect(log.warn).not.toHaveBeenCalled();
129157
});

src/gateway/server-startup-memory.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import {
88
import { getActiveMemorySearchManager } from "../plugins/memory-runtime.js";
99
import { normalizeAgentId } from "../routing/session-key.js";
1010

11-
function shouldStartQmdBackgroundWork(qmd: ResolvedQmdConfig): boolean {
12-
return qmd.update.onBoot || qmd.update.intervalMs > 0 || qmd.update.embedIntervalMs > 0;
11+
function shouldRunQmdStartupBootSync(qmd: ResolvedQmdConfig): boolean {
12+
return qmd.update.onBoot;
1313
}
1414

1515
function hasExplicitAgentMemorySearchConfig(cfg: OpenClawConfig, agentId: string): boolean {
@@ -53,7 +53,7 @@ export async function startGatewayMemoryBackend(params: {
5353
if (resolved.backend !== "qmd" || !resolved.qmd) {
5454
continue;
5555
}
56-
if (!shouldStartQmdBackgroundWork(resolved.qmd)) {
56+
if (!shouldRunQmdStartupBootSync(resolved.qmd)) {
5757
continue;
5858
}
5959
if (
@@ -67,18 +67,34 @@ export async function startGatewayMemoryBackend(params: {
6767
continue;
6868
}
6969

70-
const { manager, error } = await getActiveMemorySearchManager({ cfg: params.cfg, agentId });
70+
const { manager, error } = await getActiveMemorySearchManager({
71+
cfg: params.cfg,
72+
agentId,
73+
purpose: "cli",
74+
});
7175
if (!manager) {
7276
params.log.warn(
7377
`qmd memory startup initialization failed for agent "${agentId}": ${error ?? "unknown error"}`,
7478
);
7579
continue;
7680
}
81+
try {
82+
await manager.sync?.({ reason: "boot", force: true });
83+
} catch (err) {
84+
params.log.warn(`qmd memory startup boot sync failed for agent "${agentId}": ${String(err)}`);
85+
continue;
86+
} finally {
87+
await manager.close?.().catch((err) => {
88+
params.log.warn(
89+
`qmd memory startup manager close failed for agent "${agentId}": ${String(err)}`,
90+
);
91+
});
92+
}
7793
armedAgentIds.push(agentId);
7894
}
7995
if (armedAgentIds.length > 0) {
8096
params.log.info?.(
81-
`qmd memory startup initialization armed for ${formatAgentCount(armedAgentIds.length)}: ${armedAgentIds
97+
`qmd memory startup boot sync completed for ${formatAgentCount(armedAgentIds.length)}: ${armedAgentIds
8298
.map((agentId) => `"${agentId}"`)
8399
.join(", ")}`,
84100
);

src/plugins/memory-runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ function ensureMemoryRuntime(cfg?: OpenClawConfig) {
3333
export async function getActiveMemorySearchManager(params: {
3434
cfg: OpenClawConfig;
3535
agentId: string;
36-
purpose?: "default" | "status";
36+
purpose?: "default" | "status" | "cli";
3737
}) {
3838
const runtime = ensureMemoryRuntime(params.cfg);
3939
if (!runtime) {

src/plugins/memory-state.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export type MemoryPluginRuntime = {
9898
getMemorySearchManager(params: {
9999
cfg: OpenClawConfig;
100100
agentId: string;
101-
purpose?: "default" | "status";
101+
purpose?: "default" | "status" | "cli";
102102
}): Promise<{
103103
manager: RegisteredMemorySearchManager | null;
104104
error?: string;

0 commit comments

Comments
 (0)