




















@@ -91,6 +91,16 @@ function emitAndClose(child: MockChild, stream: "stdout" | "stderr", data: strin
9191});
9292}
939394+async function waitUntil(predicate: () => boolean, timeoutMs = 1_000): Promise<void> {
95+const startedAt = Date.now();
96+while (!predicate()) {
97+if (Date.now() - startedAt > timeoutMs) {
98+throw new Error("Timed out waiting for condition");
99+}
100+await new Promise((resolve) => scheduleNativeTimeout(resolve, 10));
101+}
102+}
103+94104function isMcporterCommand(cmd: unknown): boolean {
95105if (typeof cmd !== "string") {
96106return false;
@@ -107,13 +117,29 @@ function firstWatchOptions(): WatchOptions {
107117}
108118109119function firstEmbedLockCall(): EmbedLockCall {
110-const call = withFileLockMock.mock.calls[0] as EmbedLockCall | undefined;
120+const call = withFileLockMock.mock.calls.find((entry) =>
121+entry[0].endsWith(path.join("qmd", "embed.lock")),
122+) as EmbedLockCall | undefined;
111123if (!call) {
112124throw new Error("Expected qmd embed lock call");
113125}
114126return call;
115127}
116128129+function writeLockCalls(): EmbedLockCall[] {
130+return withFileLockMock.mock.calls.filter((entry) =>
131+entry[0].endsWith("qmd-write.lock"),
132+) as EmbedLockCall[];
133+}
134+135+function firstWriteLockCall(): EmbedLockCall {
136+const call = writeLockCalls()[0];
137+if (!call) {
138+throw new Error("Expected qmd store write lock call");
139+}
140+return call;
141+}
142+117143vi.mock("openclaw/plugin-sdk/memory-core-host-engine-foundation", async () => {
118144const actual = await vi.importActual<
119145typeof import("openclaw/plugin-sdk/memory-core-host-engine-foundation")
@@ -247,13 +273,15 @@ describe("QmdMemoryManager", () => {
247273async function createManager(params?: {
248274mode?: "full" | "status" | "cli";
249275cfg?: OpenClawConfig;
276+agentId?: string;
250277}) {
251278const cfgToUse = params?.cfg ?? cfg;
252-const resolved = resolveMemoryBackendConfig({ cfg: cfgToUse, agentId });
279+const selectedAgentId = params?.agentId ?? agentId;
280+const resolved = resolveMemoryBackendConfig({ cfg: cfgToUse, agentId: selectedAgentId });
253281const manager = trackManager(
254282await QmdMemoryManager.create({
255283cfg: cfgToUse,
256- agentId,
284+agentId: selectedAgentId,
257285 resolved,
258286mode: params?.mode ?? "status",
259287}),
@@ -273,7 +301,10 @@ describe("QmdMemoryManager", () => {
273301spawnMock.mockClear();
274302spawnMock.mockImplementation(() => createMockChild());
275303watchMock.mockClear();
276-withFileLockMock.mockClear();
304+withFileLockMock.mockReset();
305+withFileLockMock.mockImplementation(
306+async <T>(_filePath: string, _options: unknown, fn: () => Promise<T>) => await fn(),
307+);
277308logWarnMock.mockClear();
278309logDebugMock.mockClear();
279310logInfoMock.mockClear();
@@ -4089,6 +4120,112 @@ describe("QmdMemoryManager", () => {
40894120await second.manager.close();
40904121});
409141224123+it("serializes both the qmd update and embed writes on one per-store lock (issue #66339)", async () => {
4124+// Regression for #66339: the update AND embed phases both write the same
4125+// qmd index.sqlite. A foreground `memory search` dirty-sync and a background
4126+// gateway update/embed run in separate processes, which the in-process queues
4127+// cannot serialize, so the writers collided with SQLITE_BUSY. Both writes now
4128+// take one per-store cross-process write lock; embed additionally keeps the
4129+// global embed lock for ML-resource serialization.
4130+cfg = {
4131+ ...cfg,
4132+memory: {
4133+backend: "qmd",
4134+qmd: {
4135+includeDefaultMemory: false,
4136+searchMode: "query",
4137+update: { interval: "0s", debounceMs: 0, onBoot: false },
4138+paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
4139+},
4140+},
4141+} as OpenClawConfig;
4142+spawnMock.mockImplementation(() => createMockChild());
4143+4144+const { manager } = await createManager({ mode: "status" });
4145+await expect(manager.sync({ reason: "manual", force: true })).resolves.toBeUndefined();
4146+4147+const [lockPath, lockOptions, lockTask] = firstWriteLockCall();
4148+// Per-store lock beside the agent qmd dir, distinct from the global embed lock.
4149+expect(lockPath.endsWith("qmd-write.lock")).toBe(true);
4150+expect(lockPath.endsWith(path.join("qmd", "embed.lock"))).toBe(false);
4151+expect(lockOptions.retries.factor).toBe(1.2);
4152+expect(lockOptions.retries.maxTimeout).toBe(10_000);
4153+expect(lockOptions.retries.randomize).toBe(true);
4154+expect(lockOptions.retries.retries).toBeGreaterThanOrEqual(60);
4155+expect(lockOptions.stale).toBeGreaterThanOrEqual(5 * 60 * 1000);
4156+expect(typeof lockTask).toBe("function");
4157+4158+// A forced sync runs both the update and the embed write, so both acquire the
4159+// shared per-store write lock; the embed also still takes the global embed lock.
4160+expect(writeLockCalls().length).toBeGreaterThanOrEqual(2);
4161+const embedLockTaken = withFileLockMock.mock.calls.some((entry) =>
4162+entry[0].endsWith(path.join("qmd", "embed.lock")),
4163+);
4164+expect(embedLockTaken).toBe(true);
4165+4166+await manager.close();
4167+});
4168+4169+it("does not hold the per-store write lock while waiting for embed capacity", async () => {
4170+cfg = {
4171+ ...cfg,
4172+agents: {
4173+ ...cfg.agents,
4174+list: [
4175+{ id: agentId, default: true, workspace: workspaceDir },
4176+{ id: "other", workspace: workspaceDir },
4177+],
4178+},
4179+memory: {
4180+backend: "qmd",
4181+qmd: {
4182+includeDefaultMemory: false,
4183+searchMode: "query",
4184+update: { interval: "0s", debounceMs: 0, onBoot: false },
4185+paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
4186+},
4187+},
4188+} as OpenClawConfig;
4189+spawnMock.mockImplementation(() => createMockChild());
4190+4191+let releaseFirstEmbed!: () => void;
4192+const firstEmbedLocked = new Promise<void>((resolve) => {
4193+withFileLockMock.mockImplementation(
4194+async <T>(filePath: string, _options: unknown, fn: () => Promise<T>) => {
4195+if (filePath.endsWith(path.join("qmd", "embed.lock")) && !releaseFirstEmbed) {
4196+resolve();
4197+await new Promise<void>((release) => {
4198+releaseFirstEmbed = release;
4199+});
4200+}
4201+return await fn();
4202+},
4203+);
4204+});
4205+4206+const first = await createManager({ mode: "status" });
4207+const second = await createManager({ mode: "status", agentId: "other" });
4208+const firstSync = first.manager.sync({ reason: "manual", force: true });
4209+await firstEmbedLocked;
4210+4211+const secondSync = second.manager.sync({ reason: "manual", force: true });
4212+try {
4213+await waitUntil(() => writeLockCalls().length >= 2);
4214+4215+// The second manager may run its update, but its embed must not take a store
4216+// write lock while it is still queued behind the first embed.
4217+expect(writeLockCalls().length).toBe(2);
4218+} finally {
4219+releaseFirstEmbed();
4220+}
4221+4222+await Promise.all([firstSync, secondSync]);
4223+expect(writeLockCalls().length).toBeGreaterThanOrEqual(4);
4224+4225+await first.manager.close();
4226+await second.manager.close();
4227+});
4228+40924229it("serializes session exports across managers for the same agent", async () => {
40934230cfg = {
40944231 ...cfg,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。