mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 17:01:53 +03:00
Memory: harden QMD startup, timeouts, and fallback recovery
This commit is contained in:
committed by
Vignesh
parent
0deb8b0da1
commit
ce715c4c56
@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204.
|
- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204.
|
||||||
- Gateway/CLI: when `gateway.bind=lan`, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.
|
- Gateway/CLI: when `gateway.bind=lan`, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.
|
||||||
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
|
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
|
||||||
|
- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, and retry QMD after fallback failures. (#9690, #9705)
|
||||||
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
|
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
|
||||||
- Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi.
|
- Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi.
|
||||||
- Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek.
|
- Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek.
|
||||||
|
|||||||
+12
-4
@@ -127,12 +127,18 @@ out to QMD for retrieval. Key points:
|
|||||||
|
|
||||||
- The gateway writes a self-contained QMD home under
|
- The gateway writes a self-contained QMD home under
|
||||||
`~/.openclaw/agents/<agentId>/qmd/` (config + cache + sqlite DB).
|
`~/.openclaw/agents/<agentId>/qmd/` (config + cache + sqlite DB).
|
||||||
- Collections are rewritten from `memory.qmd.paths` (plus default workspace
|
- Collections are created via `qmd collection add` from `memory.qmd.paths`
|
||||||
memory files) into `index.yml`, then `qmd update` + `qmd embed` run on boot and
|
(plus default workspace memory files), then `qmd update` + `qmd embed` run
|
||||||
on a configurable interval (`memory.qmd.update.interval`, default 5 m).
|
on boot and on a configurable interval (`memory.qmd.update.interval`,
|
||||||
|
default 5 m).
|
||||||
|
- Boot refresh now runs in the background by default so chat startup is not
|
||||||
|
blocked; set `memory.qmd.update.waitForBootSync = true` to keep the previous
|
||||||
|
blocking behavior.
|
||||||
- Searches run via `qmd query --json`. If QMD fails or the binary is missing,
|
- Searches run via `qmd query --json`. If QMD fails or the binary is missing,
|
||||||
OpenClaw automatically falls back to the builtin SQLite manager so memory tools
|
OpenClaw automatically falls back to the builtin SQLite manager so memory tools
|
||||||
keep working.
|
keep working.
|
||||||
|
- OpenClaw does not expose QMD embed batch-size tuning today; batch behavior is
|
||||||
|
controlled by QMD itself.
|
||||||
- **First search may be slow**: QMD may download local GGUF models (reranker/query
|
- **First search may be slow**: QMD may download local GGUF models (reranker/query
|
||||||
expansion) on the first `qmd query` run.
|
expansion) on the first `qmd query` run.
|
||||||
- OpenClaw sets `XDG_CONFIG_HOME`/`XDG_CACHE_HOME` automatically when it runs QMD.
|
- OpenClaw sets `XDG_CONFIG_HOME`/`XDG_CACHE_HOME` automatically when it runs QMD.
|
||||||
@@ -170,7 +176,9 @@ out to QMD for retrieval. Key points:
|
|||||||
stable `name`).
|
stable `name`).
|
||||||
- `sessions`: opt into session JSONL indexing (`enabled`, `retentionDays`,
|
- `sessions`: opt into session JSONL indexing (`enabled`, `retentionDays`,
|
||||||
`exportDir`).
|
`exportDir`).
|
||||||
- `update`: controls refresh cadence (`interval`, `debounceMs`, `onBoot`, `embedInterval`).
|
- `update`: controls refresh cadence and maintenance execution:
|
||||||
|
(`interval`, `debounceMs`, `onBoot`, `waitForBootSync`, `embedInterval`,
|
||||||
|
`commandTimeoutMs`, `updateTimeoutMs`, `embedTimeoutMs`).
|
||||||
- `limits`: clamp recall payload (`maxResults`, `maxSnippetChars`,
|
- `limits`: clamp recall payload (`maxResults`, `maxSnippetChars`,
|
||||||
`maxInjectedChars`, `timeoutMs`).
|
`maxInjectedChars`, `timeoutMs`).
|
||||||
- `scope`: same schema as [`session.sendPolicy`](/gateway/configuration#session).
|
- `scope`: same schema as [`session.sendPolicy`](/gateway/configuration#session).
|
||||||
|
|||||||
@@ -271,7 +271,11 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"memory.qmd.update.interval": "QMD Update Interval",
|
"memory.qmd.update.interval": "QMD Update Interval",
|
||||||
"memory.qmd.update.debounceMs": "QMD Update Debounce (ms)",
|
"memory.qmd.update.debounceMs": "QMD Update Debounce (ms)",
|
||||||
"memory.qmd.update.onBoot": "QMD Update on Startup",
|
"memory.qmd.update.onBoot": "QMD Update on Startup",
|
||||||
|
"memory.qmd.update.waitForBootSync": "QMD Wait for Boot Sync",
|
||||||
"memory.qmd.update.embedInterval": "QMD Embed Interval",
|
"memory.qmd.update.embedInterval": "QMD Embed Interval",
|
||||||
|
"memory.qmd.update.commandTimeoutMs": "QMD Command Timeout (ms)",
|
||||||
|
"memory.qmd.update.updateTimeoutMs": "QMD Update Timeout (ms)",
|
||||||
|
"memory.qmd.update.embedTimeoutMs": "QMD Embed Timeout (ms)",
|
||||||
"memory.qmd.limits.maxResults": "QMD Max Results",
|
"memory.qmd.limits.maxResults": "QMD Max Results",
|
||||||
"memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars",
|
"memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars",
|
||||||
"memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars",
|
"memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars",
|
||||||
@@ -602,8 +606,14 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"memory.qmd.update.debounceMs":
|
"memory.qmd.update.debounceMs":
|
||||||
"Minimum delay between successive QMD refresh runs (default: 15000).",
|
"Minimum delay between successive QMD refresh runs (default: 15000).",
|
||||||
"memory.qmd.update.onBoot": "Run QMD update once on gateway startup (default: true).",
|
"memory.qmd.update.onBoot": "Run QMD update once on gateway startup (default: true).",
|
||||||
|
"memory.qmd.update.waitForBootSync":
|
||||||
|
"Block startup until the boot QMD refresh finishes (default: false).",
|
||||||
"memory.qmd.update.embedInterval":
|
"memory.qmd.update.embedInterval":
|
||||||
"How often QMD embeddings are refreshed (duration string, default: 60m). Set to 0 to disable periodic embed.",
|
"How often QMD embeddings are refreshed (duration string, default: 60m). Set to 0 to disable periodic embed.",
|
||||||
|
"memory.qmd.update.commandTimeoutMs":
|
||||||
|
"Timeout for QMD maintenance commands like collection list/add (default: 30000).",
|
||||||
|
"memory.qmd.update.updateTimeoutMs": "Timeout for `qmd update` runs (default: 120000).",
|
||||||
|
"memory.qmd.update.embedTimeoutMs": "Timeout for `qmd embed` runs (default: 120000).",
|
||||||
"memory.qmd.limits.maxResults": "Max QMD results returned to the agent loop (default: 6).",
|
"memory.qmd.limits.maxResults": "Max QMD results returned to the agent loop (default: 6).",
|
||||||
"memory.qmd.limits.maxSnippetChars": "Max characters per snippet pulled from QMD (default: 700).",
|
"memory.qmd.limits.maxSnippetChars": "Max characters per snippet pulled from QMD (default: 700).",
|
||||||
"memory.qmd.limits.maxInjectedChars": "Max total characters injected from QMD hits per turn.",
|
"memory.qmd.limits.maxInjectedChars": "Max total characters injected from QMD hits per turn.",
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ export type MemoryQmdUpdateConfig = {
|
|||||||
interval?: string;
|
interval?: string;
|
||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
onBoot?: boolean;
|
onBoot?: boolean;
|
||||||
|
waitForBootSync?: boolean;
|
||||||
embedInterval?: string;
|
embedInterval?: string;
|
||||||
|
commandTimeoutMs?: number;
|
||||||
|
updateTimeoutMs?: number;
|
||||||
|
embedTimeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MemoryQmdLimitsConfig = {
|
export type MemoryQmdLimitsConfig = {
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ const MemoryQmdUpdateSchema = z
|
|||||||
interval: z.string().optional(),
|
interval: z.string().optional(),
|
||||||
debounceMs: z.number().int().nonnegative().optional(),
|
debounceMs: z.number().int().nonnegative().optional(),
|
||||||
onBoot: z.boolean().optional(),
|
onBoot: z.boolean().optional(),
|
||||||
|
waitForBootSync: z.boolean().optional(),
|
||||||
embedInterval: z.string().optional(),
|
embedInterval: z.string().optional(),
|
||||||
|
commandTimeoutMs: z.number().int().nonnegative().optional(),
|
||||||
|
updateTimeoutMs: z.number().int().nonnegative().optional(),
|
||||||
|
embedTimeoutMs: z.number().int().nonnegative().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ describe("resolveMemoryBackendConfig", () => {
|
|||||||
expect(resolved.qmd?.collections.length).toBeGreaterThanOrEqual(3);
|
expect(resolved.qmd?.collections.length).toBeGreaterThanOrEqual(3);
|
||||||
expect(resolved.qmd?.command).toBe("qmd");
|
expect(resolved.qmd?.command).toBe("qmd");
|
||||||
expect(resolved.qmd?.update.intervalMs).toBeGreaterThan(0);
|
expect(resolved.qmd?.update.intervalMs).toBeGreaterThan(0);
|
||||||
|
expect(resolved.qmd?.update.waitForBootSync).toBe(false);
|
||||||
|
expect(resolved.qmd?.update.commandTimeoutMs).toBe(30_000);
|
||||||
|
expect(resolved.qmd?.update.updateTimeoutMs).toBe(120_000);
|
||||||
|
expect(resolved.qmd?.update.embedTimeoutMs).toBe(120_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses quoted qmd command paths", () => {
|
it("parses quoted qmd command paths", () => {
|
||||||
@@ -67,4 +71,26 @@ describe("resolveMemoryBackendConfig", () => {
|
|||||||
const workspaceRoot = resolveAgentWorkspaceDir(cfg, "main");
|
const workspaceRoot = resolveAgentWorkspaceDir(cfg, "main");
|
||||||
expect(custom?.path).toBe(path.resolve(workspaceRoot, "notes"));
|
expect(custom?.path).toBe(path.resolve(workspaceRoot, "notes"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves qmd update timeout overrides", () => {
|
||||||
|
const cfg = {
|
||||||
|
agents: { defaults: { workspace: "/tmp/memory-test" } },
|
||||||
|
memory: {
|
||||||
|
backend: "qmd",
|
||||||
|
qmd: {
|
||||||
|
update: {
|
||||||
|
waitForBootSync: true,
|
||||||
|
commandTimeoutMs: 12_000,
|
||||||
|
updateTimeoutMs: 480_000,
|
||||||
|
embedTimeoutMs: 360_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
|
||||||
|
expect(resolved.qmd?.update.waitForBootSync).toBe(true);
|
||||||
|
expect(resolved.qmd?.update.commandTimeoutMs).toBe(12_000);
|
||||||
|
expect(resolved.qmd?.update.updateTimeoutMs).toBe(480_000);
|
||||||
|
expect(resolved.qmd?.update.embedTimeoutMs).toBe(360_000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ export type ResolvedQmdUpdateConfig = {
|
|||||||
intervalMs: number;
|
intervalMs: number;
|
||||||
debounceMs: number;
|
debounceMs: number;
|
||||||
onBoot: boolean;
|
onBoot: boolean;
|
||||||
|
waitForBootSync: boolean;
|
||||||
embedIntervalMs: number;
|
embedIntervalMs: number;
|
||||||
|
commandTimeoutMs: number;
|
||||||
|
updateTimeoutMs: number;
|
||||||
|
embedTimeoutMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResolvedQmdLimitsConfig = {
|
export type ResolvedQmdLimitsConfig = {
|
||||||
@@ -61,6 +65,9 @@ const DEFAULT_QMD_INTERVAL = "5m";
|
|||||||
const DEFAULT_QMD_DEBOUNCE_MS = 15_000;
|
const DEFAULT_QMD_DEBOUNCE_MS = 15_000;
|
||||||
const DEFAULT_QMD_TIMEOUT_MS = 4_000;
|
const DEFAULT_QMD_TIMEOUT_MS = 4_000;
|
||||||
const DEFAULT_QMD_EMBED_INTERVAL = "60m";
|
const DEFAULT_QMD_EMBED_INTERVAL = "60m";
|
||||||
|
const DEFAULT_QMD_COMMAND_TIMEOUT_MS = 30_000;
|
||||||
|
const DEFAULT_QMD_UPDATE_TIMEOUT_MS = 120_000;
|
||||||
|
const DEFAULT_QMD_EMBED_TIMEOUT_MS = 120_000;
|
||||||
const DEFAULT_QMD_LIMITS: ResolvedQmdLimitsConfig = {
|
const DEFAULT_QMD_LIMITS: ResolvedQmdLimitsConfig = {
|
||||||
maxResults: 6,
|
maxResults: 6,
|
||||||
maxSnippetChars: 700,
|
maxSnippetChars: 700,
|
||||||
@@ -140,6 +147,13 @@ function resolveDebounceMs(raw: number | undefined): number {
|
|||||||
return DEFAULT_QMD_DEBOUNCE_MS;
|
return DEFAULT_QMD_DEBOUNCE_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTimeoutMs(raw: number | undefined, fallback: number): number {
|
||||||
|
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
|
||||||
|
return Math.floor(raw);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveLimits(raw?: MemoryQmdConfig["limits"]): ResolvedQmdLimitsConfig {
|
function resolveLimits(raw?: MemoryQmdConfig["limits"]): ResolvedQmdLimitsConfig {
|
||||||
const parsed: ResolvedQmdLimitsConfig = { ...DEFAULT_QMD_LIMITS };
|
const parsed: ResolvedQmdLimitsConfig = { ...DEFAULT_QMD_LIMITS };
|
||||||
if (raw?.maxResults && raw.maxResults > 0) {
|
if (raw?.maxResults && raw.maxResults > 0) {
|
||||||
@@ -258,7 +272,20 @@ export function resolveMemoryBackendConfig(params: {
|
|||||||
intervalMs: resolveIntervalMs(qmdCfg?.update?.interval),
|
intervalMs: resolveIntervalMs(qmdCfg?.update?.interval),
|
||||||
debounceMs: resolveDebounceMs(qmdCfg?.update?.debounceMs),
|
debounceMs: resolveDebounceMs(qmdCfg?.update?.debounceMs),
|
||||||
onBoot: qmdCfg?.update?.onBoot !== false,
|
onBoot: qmdCfg?.update?.onBoot !== false,
|
||||||
|
waitForBootSync: qmdCfg?.update?.waitForBootSync === true,
|
||||||
embedIntervalMs: resolveEmbedIntervalMs(qmdCfg?.update?.embedInterval),
|
embedIntervalMs: resolveEmbedIntervalMs(qmdCfg?.update?.embedInterval),
|
||||||
|
commandTimeoutMs: resolveTimeoutMs(
|
||||||
|
qmdCfg?.update?.commandTimeoutMs,
|
||||||
|
DEFAULT_QMD_COMMAND_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
updateTimeoutMs: resolveTimeoutMs(
|
||||||
|
qmdCfg?.update?.updateTimeoutMs,
|
||||||
|
DEFAULT_QMD_UPDATE_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
embedTimeoutMs: resolveTimeoutMs(
|
||||||
|
qmdCfg?.update?.embedTimeoutMs,
|
||||||
|
DEFAULT_QMD_EMBED_TIMEOUT_MS,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
limits: resolveLimits(qmdCfg?.limits),
|
limits: resolveLimits(qmdCfg?.limits),
|
||||||
scope: qmdCfg?.scope ?? DEFAULT_QMD_SCOPE,
|
scope: qmdCfg?.scope ?? DEFAULT_QMD_SCOPE,
|
||||||
|
|||||||
+214
-24
@@ -4,30 +4,35 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("node:child_process", () => {
|
type MockChild = EventEmitter & {
|
||||||
const spawn = vi.fn((_cmd: string, _args: string[]) => {
|
stdout: EventEmitter;
|
||||||
const stdout = new EventEmitter();
|
stderr: EventEmitter;
|
||||||
const stderr = new EventEmitter();
|
kill: (signal?: NodeJS.Signals) => void;
|
||||||
const child = new EventEmitter() as {
|
closeWith: (code?: number | null) => void;
|
||||||
stdout: EventEmitter;
|
};
|
||||||
stderr: EventEmitter;
|
|
||||||
kill: () => void;
|
function createMockChild(params?: { autoClose?: boolean; closeDelayMs?: number }): MockChild {
|
||||||
emit: (event: string, code: number) => boolean;
|
const stdout = new EventEmitter();
|
||||||
};
|
const stderr = new EventEmitter();
|
||||||
child.stdout = stdout;
|
const child = new EventEmitter() as MockChild;
|
||||||
child.stderr = stderr;
|
child.stdout = stdout;
|
||||||
child.kill = () => {
|
child.stderr = stderr;
|
||||||
|
child.closeWith = (code = 0) => {
|
||||||
|
child.emit("close", code);
|
||||||
|
};
|
||||||
|
child.kill = () => {
|
||||||
|
// Let timeout rejection win in tests that simulate hung QMD commands.
|
||||||
|
};
|
||||||
|
if (params?.autoClose !== false) {
|
||||||
|
const delayMs = params?.closeDelayMs ?? 0;
|
||||||
|
setTimeout(() => {
|
||||||
child.emit("close", 0);
|
child.emit("close", 0);
|
||||||
};
|
}, delayMs);
|
||||||
setImmediate(() => {
|
}
|
||||||
stdout.emit("data", "");
|
return child;
|
||||||
stderr.emit("data", "");
|
}
|
||||||
child.emit("close", 0);
|
|
||||||
});
|
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
|
||||||
return child;
|
|
||||||
});
|
|
||||||
return { spawn };
|
|
||||||
});
|
|
||||||
|
|
||||||
import { spawn as mockedSpawn } from "node:child_process";
|
import { spawn as mockedSpawn } from "node:child_process";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
@@ -44,7 +49,8 @@ describe("QmdMemoryManager", () => {
|
|||||||
const agentId = "main";
|
const agentId = "main";
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
spawnMock.mockClear();
|
spawnMock.mockReset();
|
||||||
|
spawnMock.mockImplementation(() => createMockChild());
|
||||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-manager-test-"));
|
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-manager-test-"));
|
||||||
workspaceDir = path.join(tmpRoot, "workspace");
|
workspaceDir = path.join(tmpRoot, "workspace");
|
||||||
await fs.mkdir(workspaceDir, { recursive: true });
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
@@ -97,6 +103,190 @@ describe("QmdMemoryManager", () => {
|
|||||||
await manager.close();
|
await manager.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs boot update in background by default", async () => {
|
||||||
|
cfg = {
|
||||||
|
...cfg,
|
||||||
|
memory: {
|
||||||
|
backend: "qmd",
|
||||||
|
qmd: {
|
||||||
|
includeDefaultMemory: false,
|
||||||
|
update: { interval: "0s", debounceMs: 60_000, onBoot: true },
|
||||||
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
let releaseUpdate: (() => void) | null = null;
|
||||||
|
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||||
|
if (args[0] === "update") {
|
||||||
|
const child = createMockChild({ autoClose: false });
|
||||||
|
releaseUpdate = () => child.closeWith(0);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
return createMockChild();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
||||||
|
const createPromise = QmdMemoryManager.create({ cfg, agentId, resolved });
|
||||||
|
const race = await Promise.race([
|
||||||
|
createPromise.then(() => "created" as const),
|
||||||
|
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 80)),
|
||||||
|
]);
|
||||||
|
expect(race).toBe("created");
|
||||||
|
|
||||||
|
if (!releaseUpdate) {
|
||||||
|
throw new Error("update child missing");
|
||||||
|
}
|
||||||
|
releaseUpdate();
|
||||||
|
const manager = await createPromise;
|
||||||
|
await manager?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be configured to block startup on boot update", async () => {
|
||||||
|
cfg = {
|
||||||
|
...cfg,
|
||||||
|
memory: {
|
||||||
|
backend: "qmd",
|
||||||
|
qmd: {
|
||||||
|
includeDefaultMemory: false,
|
||||||
|
update: {
|
||||||
|
interval: "0s",
|
||||||
|
debounceMs: 60_000,
|
||||||
|
onBoot: true,
|
||||||
|
waitForBootSync: true,
|
||||||
|
},
|
||||||
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
let releaseUpdate: (() => void) | null = null;
|
||||||
|
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||||
|
if (args[0] === "update") {
|
||||||
|
const child = createMockChild({ autoClose: false });
|
||||||
|
releaseUpdate = () => child.closeWith(0);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
return createMockChild();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
||||||
|
const createPromise = QmdMemoryManager.create({ cfg, agentId, resolved });
|
||||||
|
const race = await Promise.race([
|
||||||
|
createPromise.then(() => "created" as const),
|
||||||
|
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 80)),
|
||||||
|
]);
|
||||||
|
expect(race).toBe("timeout");
|
||||||
|
|
||||||
|
if (!releaseUpdate) {
|
||||||
|
throw new Error("update child missing");
|
||||||
|
}
|
||||||
|
releaseUpdate();
|
||||||
|
const manager = await createPromise;
|
||||||
|
await manager?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("times out collection bootstrap commands", async () => {
|
||||||
|
cfg = {
|
||||||
|
...cfg,
|
||||||
|
memory: {
|
||||||
|
backend: "qmd",
|
||||||
|
qmd: {
|
||||||
|
includeDefaultMemory: false,
|
||||||
|
update: {
|
||||||
|
interval: "0s",
|
||||||
|
debounceMs: 60_000,
|
||||||
|
onBoot: false,
|
||||||
|
commandTimeoutMs: 15,
|
||||||
|
},
|
||||||
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||||
|
if (args[0] === "collection" && args[1] === "list") {
|
||||||
|
return createMockChild({ autoClose: false });
|
||||||
|
}
|
||||||
|
return createMockChild();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
||||||
|
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
|
||||||
|
expect(manager).toBeTruthy();
|
||||||
|
await manager?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("times out qmd update during sync when configured", async () => {
|
||||||
|
cfg = {
|
||||||
|
...cfg,
|
||||||
|
memory: {
|
||||||
|
backend: "qmd",
|
||||||
|
qmd: {
|
||||||
|
includeDefaultMemory: false,
|
||||||
|
update: {
|
||||||
|
interval: "0s",
|
||||||
|
debounceMs: 0,
|
||||||
|
onBoot: false,
|
||||||
|
updateTimeoutMs: 20,
|
||||||
|
},
|
||||||
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||||
|
if (args[0] === "update") {
|
||||||
|
return createMockChild({ autoClose: false });
|
||||||
|
}
|
||||||
|
return createMockChild();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
||||||
|
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
|
||||||
|
expect(manager).toBeTruthy();
|
||||||
|
if (!manager) {
|
||||||
|
throw new Error("manager missing");
|
||||||
|
}
|
||||||
|
await expect(manager.sync({ reason: "manual" })).rejects.toThrow(
|
||||||
|
"qmd update timed out after 20ms",
|
||||||
|
);
|
||||||
|
await manager.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and continues when qmd embed times out", async () => {
|
||||||
|
cfg = {
|
||||||
|
...cfg,
|
||||||
|
memory: {
|
||||||
|
backend: "qmd",
|
||||||
|
qmd: {
|
||||||
|
includeDefaultMemory: false,
|
||||||
|
update: {
|
||||||
|
interval: "0s",
|
||||||
|
debounceMs: 0,
|
||||||
|
onBoot: false,
|
||||||
|
embedTimeoutMs: 20,
|
||||||
|
},
|
||||||
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||||
|
if (args[0] === "embed") {
|
||||||
|
return createMockChild({ autoClose: false });
|
||||||
|
}
|
||||||
|
return createMockChild();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
||||||
|
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
|
||||||
|
expect(manager).toBeTruthy();
|
||||||
|
if (!manager) {
|
||||||
|
throw new Error("manager missing");
|
||||||
|
}
|
||||||
|
await expect(manager.sync({ reason: "manual" })).resolves.toBeUndefined();
|
||||||
|
await manager.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("scopes by channel for agent-prefixed session keys", async () => {
|
it("scopes by channel for agent-prefixed session keys", async () => {
|
||||||
cfg = {
|
cfg = {
|
||||||
...cfg,
|
...cfg,
|
||||||
|
|||||||
+43
-15
@@ -28,6 +28,7 @@ import type { ResolvedMemoryBackendConfig, ResolvedQmdConfig } from "./backend-c
|
|||||||
const log = createSubsystemLogger("memory");
|
const log = createSubsystemLogger("memory");
|
||||||
|
|
||||||
const SNIPPET_HEADER_RE = /@@\s*-([0-9]+),([0-9]+)/;
|
const SNIPPET_HEADER_RE = /@@\s*-([0-9]+),([0-9]+)/;
|
||||||
|
const SEARCH_PENDING_UPDATE_WAIT_MS = 500;
|
||||||
|
|
||||||
type QmdQueryResult = {
|
type QmdQueryResult = {
|
||||||
docid?: string;
|
docid?: string;
|
||||||
@@ -145,7 +146,16 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
await this.ensureCollections();
|
await this.ensureCollections();
|
||||||
|
|
||||||
if (this.qmd.update.onBoot) {
|
if (this.qmd.update.onBoot) {
|
||||||
await this.runUpdate("boot", true);
|
const bootRun = this.runUpdate("boot", true);
|
||||||
|
if (this.qmd.update.waitForBootSync) {
|
||||||
|
await bootRun.catch((err) => {
|
||||||
|
log.warn(`qmd boot update failed: ${String(err)}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
void bootRun.catch((err) => {
|
||||||
|
log.warn(`qmd boot update failed: ${String(err)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this.qmd.update.intervalMs > 0) {
|
if (this.qmd.update.intervalMs > 0) {
|
||||||
this.updateTimer = setInterval(() => {
|
this.updateTimer = setInterval(() => {
|
||||||
@@ -172,7 +182,9 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
// fall back to best-effort idempotent `qmd collection add`.
|
// fall back to best-effort idempotent `qmd collection add`.
|
||||||
const existing = new Set<string>();
|
const existing = new Set<string>();
|
||||||
try {
|
try {
|
||||||
const result = await this.runQmd(["collection", "list", "--json"]);
|
const result = await this.runQmd(["collection", "list", "--json"], {
|
||||||
|
timeoutMs: this.qmd.update.commandTimeoutMs,
|
||||||
|
});
|
||||||
const parsed = JSON.parse(result.stdout) as unknown;
|
const parsed = JSON.parse(result.stdout) as unknown;
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
for (const entry of parsed) {
|
for (const entry of parsed) {
|
||||||
@@ -195,15 +207,20 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.runQmd([
|
await this.runQmd(
|
||||||
"collection",
|
[
|
||||||
"add",
|
"collection",
|
||||||
collection.path,
|
"add",
|
||||||
"--name",
|
collection.path,
|
||||||
collection.name,
|
"--name",
|
||||||
"--mask",
|
collection.name,
|
||||||
collection.pattern,
|
"--mask",
|
||||||
]);
|
collection.pattern,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
timeoutMs: this.qmd.update.commandTimeoutMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
// Idempotency: qmd exits non-zero if the collection name already exists.
|
// Idempotency: qmd exits non-zero if the collection name already exists.
|
||||||
@@ -229,7 +246,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
await this.pendingUpdate?.catch(() => undefined);
|
await this.waitForPendingUpdateBeforeSearch();
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
this.qmd.limits.maxResults,
|
this.qmd.limits.maxResults,
|
||||||
opts?.maxResults ?? this.qmd.limits.maxResults,
|
opts?.maxResults ?? this.qmd.limits.maxResults,
|
||||||
@@ -376,7 +393,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async runUpdate(reason: string, force?: boolean): Promise<void> {
|
private async runUpdate(reason: string, force?: boolean): Promise<void> {
|
||||||
if (this.pendingUpdate && !force) {
|
if (this.pendingUpdate) {
|
||||||
return this.pendingUpdate;
|
return this.pendingUpdate;
|
||||||
}
|
}
|
||||||
if (this.shouldSkipUpdate(force)) {
|
if (this.shouldSkipUpdate(force)) {
|
||||||
@@ -386,7 +403,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
if (this.sessionExporter) {
|
if (this.sessionExporter) {
|
||||||
await this.exportSessions();
|
await this.exportSessions();
|
||||||
}
|
}
|
||||||
await this.runQmd(["update"], { timeoutMs: 120_000 });
|
await this.runQmd(["update"], { timeoutMs: this.qmd.update.updateTimeoutMs });
|
||||||
const embedIntervalMs = this.qmd.update.embedIntervalMs;
|
const embedIntervalMs = this.qmd.update.embedIntervalMs;
|
||||||
const shouldEmbed =
|
const shouldEmbed =
|
||||||
Boolean(force) ||
|
Boolean(force) ||
|
||||||
@@ -394,7 +411,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
(embedIntervalMs > 0 && Date.now() - this.lastEmbedAt > embedIntervalMs);
|
(embedIntervalMs > 0 && Date.now() - this.lastEmbedAt > embedIntervalMs);
|
||||||
if (shouldEmbed) {
|
if (shouldEmbed) {
|
||||||
try {
|
try {
|
||||||
await this.runQmd(["embed"], { timeoutMs: 120_000 });
|
await this.runQmd(["embed"], { timeoutMs: this.qmd.update.embedTimeoutMs });
|
||||||
this.lastEmbedAt = Date.now();
|
this.lastEmbedAt = Date.now();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(`qmd embed failed (${reason}): ${String(err)}`);
|
log.warn(`qmd embed failed (${reason}): ${String(err)}`);
|
||||||
@@ -807,4 +824,15 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
}
|
}
|
||||||
return Date.now() - this.lastUpdateAt < debounceMs;
|
return Date.now() - this.lastUpdateAt < debounceMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async waitForPendingUpdateBeforeSearch(): Promise<void> {
|
||||||
|
const pending = this.pendingUpdate;
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.race([
|
||||||
|
pending.catch(() => undefined),
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, SEARCH_PENDING_UPDATE_WAIT_MS)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,34 @@ vi.mock("./qmd-manager.js", () => ({
|
|||||||
|
|
||||||
vi.mock("./manager.js", () => ({
|
vi.mock("./manager.js", () => ({
|
||||||
MemoryIndexManager: {
|
MemoryIndexManager: {
|
||||||
get: vi.fn(async () => null),
|
get: vi.fn(async () => ({
|
||||||
|
search: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
path: "MEMORY.md",
|
||||||
|
startLine: 1,
|
||||||
|
endLine: 1,
|
||||||
|
score: 1,
|
||||||
|
snippet: "fallback",
|
||||||
|
source: "memory",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })),
|
||||||
|
status: vi.fn(() => ({
|
||||||
|
backend: "builtin" as const,
|
||||||
|
provider: "openai",
|
||||||
|
model: "text-embedding-3-small",
|
||||||
|
requestedProvider: "openai",
|
||||||
|
files: 0,
|
||||||
|
chunks: 0,
|
||||||
|
dirty: false,
|
||||||
|
workspaceDir: "/tmp",
|
||||||
|
dbPath: "/tmp/index.sqlite",
|
||||||
|
})),
|
||||||
|
sync: vi.fn(async () => {}),
|
||||||
|
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
|
||||||
|
probeVectorAvailability: vi.fn(async () => true),
|
||||||
|
close: vi.fn(async () => {}),
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -62,4 +89,29 @@ describe("getMemorySearchManager caching", () => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||||
expect(QmdMemoryManager.create).toHaveBeenCalledTimes(1);
|
expect(QmdMemoryManager.create).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("evicts failed qmd wrapper so next call retries qmd", async () => {
|
||||||
|
const retryAgentId = "retry-agent";
|
||||||
|
const cfg = {
|
||||||
|
memory: { backend: "qmd", qmd: {} },
|
||||||
|
agents: { list: [{ id: retryAgentId, default: true, workspace: "/tmp/workspace" }] },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
mockPrimary.search.mockRejectedValueOnce(new Error("qmd query failed"));
|
||||||
|
const first = await getMemorySearchManager({ cfg, agentId: retryAgentId });
|
||||||
|
expect(first.manager).toBeTruthy();
|
||||||
|
if (!first.manager) {
|
||||||
|
throw new Error("manager missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackResults = await first.manager.search("hello");
|
||||||
|
expect(fallbackResults).toHaveLength(1);
|
||||||
|
expect(fallbackResults[0]?.path).toBe("MEMORY.md");
|
||||||
|
|
||||||
|
const second = await getMemorySearchManager({ cfg, agentId: retryAgentId });
|
||||||
|
expect(second.manager).toBeTruthy();
|
||||||
|
expect(second.manager).not.toBe(first.manager);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||||
|
expect(QmdMemoryManager.create).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ class FallbackMemoryManager implements MemorySearchManager {
|
|||||||
this.lastError = err instanceof Error ? err.message : String(err);
|
this.lastError = err instanceof Error ? err.message : String(err);
|
||||||
log.warn(`qmd memory failed; switching to builtin index: ${this.lastError}`);
|
log.warn(`qmd memory failed; switching to builtin index: ${this.lastError}`);
|
||||||
await this.deps.primary.close?.().catch(() => {});
|
await this.deps.primary.close?.().catch(() => {});
|
||||||
|
// Evict the failed wrapper so the next request can retry QMD with a fresh manager.
|
||||||
|
this.onClose?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const fallback = await this.ensureFallback();
|
const fallback = await this.ensureFallback();
|
||||||
|
|||||||
Reference in New Issue
Block a user