diff --git a/CHANGELOG.md b/CHANGELOG.md index 68160d453..7f5be40df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -153,6 +153,7 @@ Docs: https://docs.openclaw.ai - Gateway/status self version reporting: make Gateway self version in `openclaw status` prefer runtime `VERSION` (while preserving explicit `OPENCLAW_VERSION` override), preventing stale post-upgrade app version output. (#32655) thanks @liuxiaopai-ai. - Memory/QMD index isolation: set `QMD_CONFIG_DIR` alongside `XDG_CONFIG_HOME` so QMD config state stays per-agent despite upstream XDG handling bugs, preventing cross-agent collection indexing and excess disk/CPU usage. (#27028) thanks @HenryLoenwind. - Memory/QMD collection safety: stop destructive collection rebinds when QMD `collection list` only reports names without path metadata, preventing `memory search` from dropping existing collections if re-add fails. (#36870) Thanks @Adnannnnnnna. +- Memory/QMD duplicate-document recovery: detect `UNIQUE constraint failed: documents.collection, documents.path` update failures, rebuild managed collections once, and retry update so periodic QMD syncs recover instead of failing every run; includes regression coverage to avoid over-matching unrelated unique constraints. (#27649) Thanks @MiscMich. - Memory/local embedding initialization hardening: add regression coverage for transient initialization retry and mixed `embedQuery` + `embedBatch` concurrent startup to lock single-flight initialization behavior. (#15639) thanks @SubtleSpark. - CLI/Coding-agent reliability: switch default `claude-cli` non-interactive args to `--permission-mode bypassPermissions`, auto-normalize legacy `--dangerously-skip-permissions` backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. Related to #28261. Landed from contributor PRs #28610 and #31149. Thanks @niceysam, @cryptomaltese and @vincentkoc. - ACP/ACPX session bootstrap: retry with `sessions new` when `sessions ensure` returns no session identifiers so ACP spawns avoid `NO_SESSION`/`ACP_TURN_FAILED` failures on affected agents. Related to #28786. Landed from contributor PR #31338. Thanks @Sid-Qin and @vincentkoc. diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 43f7c55be..452d39d2d 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -691,6 +691,98 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("rebuilds managed collections once when qmd update hits duplicate document constraint", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: true, + update: { interval: "0s", debounceMs: 0, onBoot: false }, + paths: [], + }, + }, + } as OpenClawConfig; + + let updateCalls = 0; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "update") { + updateCalls += 1; + const child = createMockChild({ autoClose: false }); + if (updateCalls === 1) { + emitAndClose( + child, + "stderr", + "SQLiteError: UNIQUE constraint failed: documents.collection, documents.path", + 1, + ); + return child; + } + queueMicrotask(() => { + child.closeWith(0); + }); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager({ mode: "status" }); + await expect(manager.sync({ reason: "manual" })).resolves.toBeUndefined(); + + const removeCalls = spawnMock.mock.calls + .map((call: unknown[]) => call[1] as string[]) + .filter((args: string[]) => args[0] === "collection" && args[1] === "remove") + .map((args) => args[2]); + const addCalls = spawnMock.mock.calls + .map((call: unknown[]) => call[1] as string[]) + .filter((args: string[]) => args[0] === "collection" && args[1] === "add") + .map((args) => args[args.indexOf("--name") + 1]); + + expect(updateCalls).toBe(2); + expect(removeCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]); + expect(addCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]); + expect(logWarnMock).toHaveBeenCalledWith( + expect.stringContaining("duplicate document constraint"), + ); + + await manager.close(); + }); + + it("does not rebuild collections for unrelated unique constraint failures", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: true, + update: { interval: "0s", debounceMs: 0, onBoot: false }, + paths: [], + }, + }, + } as OpenClawConfig; + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "update") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stderr", "SQLiteError: UNIQUE constraint failed: documents.docid", 1); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager({ mode: "status" }); + await expect(manager.sync({ reason: "manual" })).rejects.toThrow( + "SQLiteError: UNIQUE constraint failed: documents.docid", + ); + + const removeCalls = spawnMock.mock.calls + .map((call: unknown[]) => call[1] as string[]) + .filter((args: string[]) => args[0] === "collection" && args[1] === "remove"); + expect(removeCalls).toHaveLength(0); + + await manager.close(); + }); + it("does not rebuild collections for generic qmd update failures", async () => { cfg = { ...cfg, diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 454cad683..4452a7f34 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -215,6 +215,7 @@ export class QmdMemoryManager implements MemorySearchManager { private embedBackoffUntil: number | null = null; private embedFailureCount = 0; private attemptedNullByteCollectionRepair = false; + private attemptedDuplicateDocumentRepair = false; private constructor(params: { cfg: OpenClawConfig; @@ -601,17 +602,17 @@ export class QmdMemoryManager implements MemorySearchManager { ); } - private async tryRepairNullByteCollections(err: unknown, reason: string): Promise { - if (this.attemptedNullByteCollectionRepair) { - return false; - } - if (!this.shouldRepairNullByteCollectionError(err)) { - return false; - } - this.attemptedNullByteCollectionRepair = true; - log.warn( - `qmd update failed with suspected null-byte collection metadata (${reason}); rebuilding managed collections and retrying once`, + private shouldRepairDuplicateDocumentConstraint(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + const lower = message.toLowerCase(); + return ( + lower.includes("unique constraint failed") && + lower.includes("documents.collection") && + lower.includes("documents.path") ); + } + + private async rebuildManagedCollectionsForRepair(reason: string): Promise { for (const collection of this.qmd.collections) { try { await this.removeCollection(collection.name); @@ -630,6 +631,39 @@ export class QmdMemoryManager implements MemorySearchManager { } } } + log.warn(`qmd managed collections rebuilt for update repair (${reason})`); + } + + private async tryRepairNullByteCollections(err: unknown, reason: string): Promise { + if (this.attemptedNullByteCollectionRepair) { + return false; + } + if (!this.shouldRepairNullByteCollectionError(err)) { + return false; + } + this.attemptedNullByteCollectionRepair = true; + log.warn( + `qmd update failed with suspected null-byte collection metadata (${reason}); rebuilding managed collections and retrying once`, + ); + await this.rebuildManagedCollectionsForRepair(`null-byte metadata (${reason})`); + return true; + } + + private async tryRepairDuplicateDocumentConstraint( + err: unknown, + reason: string, + ): Promise { + if (this.attemptedDuplicateDocumentRepair) { + return false; + } + if (!this.shouldRepairDuplicateDocumentConstraint(err)) { + return false; + } + this.attemptedDuplicateDocumentRepair = true; + log.warn( + `qmd update failed with duplicate document constraint (${reason}); rebuilding managed collections and retrying once`, + ); + await this.rebuildManagedCollectionsForRepair(`duplicate-document constraint (${reason})`); return true; } @@ -962,7 +996,10 @@ export class QmdMemoryManager implements MemorySearchManager { discardOutput: true, }); } catch (err) { - if (!(await this.tryRepairNullByteCollections(err, reason))) { + if ( + !(await this.tryRepairNullByteCollections(err, reason)) && + !(await this.tryRepairDuplicateDocumentConstraint(err, reason)) + ) { throw err; } await this.runQmd(["update"], {