From be756b9a8974d63725c865e655a776fc1781a7b3 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Fri, 20 Feb 2026 19:55:03 -0800 Subject: [PATCH] Memory: fix async sync close race --- CHANGELOG.md | 1 + src/memory/manager.async-search.test.ts | 76 +++++++++++++++++++------ src/memory/manager.ts | 9 +++ 3 files changed, 69 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 040d95a694f..709d7f6df88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - macOS/Build: default release packaging to `BUNDLE_ID=ai.openclaw.mac` in `scripts/package-mac-dist.sh`, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit. - Gateway/Pairing: clear persisted paired-device state when the gateway client closes with `device token mismatch` (`1008`) so reconnect flows can cleanly re-enter pairing. (#22071) Thanks @mbelinky. - Memory/QMD: respect per-agent `memorySearch.enabled=false` during gateway QMD startup initialization, split multi-collection QMD searches into per-collection queries (`search`/`vsearch`/`query`) to avoid sparse-term drops, prefer collection-hinted doc resolution to avoid stale-hash collisions, retry boot updates on transient lock/timeout failures, skip `qmd embed` in BM25-only `search` mode (including `memory index --force`), and serialize embed runs globally with failure backoff to prevent CPU storms on multi-agent hosts. (#20581, #21590, #20513, #20001, #21266, #21583, #20346, #19493) Thanks @danielrevivo, @zanderkrause, @sunyan034-cmd, @tilleulenspiegel, @dae-oss, @adamlongcreativellc, @jonathanadams96, and @kiliansitel. +- Memory/Builtin: prevent automatic sync races with manager shutdown by skipping post-close sync starts and waiting for in-flight sync before closing SQLite, so `onSearch`/`onSessionStart` no longer fail with `database is not open` in ephemeral CLI flows. (#20556, #7464) Thanks @FuzzyTG and @henrybottter. - Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson. - Providers/Copilot: drop persisted assistant `thinking` blocks for Claude models (while preserving turn structure/tool blocks) so follow-up requests no longer fail on invalid `thinkingSignature` payloads. (#19459) Thanks @jackheuberger. - Providers/Copilot: add `claude-sonnet-4.6` and `claude-sonnet-4.5` to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn. diff --git a/src/memory/manager.async-search.test.ts b/src/memory/manager.async-search.test.ts index fdf5a978090..2d366c6e9ee 100644 --- a/src/memory/manager.async-search.test.ts +++ b/src/memory/manager.async-search.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import type { MemoryIndexManager } from "./index.js"; +import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js"; import { createMemoryManagerOrThrow } from "./test-manager.js"; @@ -23,7 +23,27 @@ describe("memory search async sync", () => { let indexPath: string; let manager: MemoryIndexManager | null = null; + const buildConfig = (): OpenClawConfig => + ({ + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "text-embedding-3-small", + store: { path: indexPath }, + sync: { watch: false, onSessionStart: false, onSearch: true }, + query: { minScore: 0 }, + remote: { batch: { enabled: true, wait: true } }, + }, + }, + list: [{ id: "main", default: true }], + }, + }) as OpenClawConfig; + beforeEach(async () => { + embedBatch.mockReset(); + embedBatch.mockImplementation(async (input: string[]) => input.map(() => [0.2, 0.2, 0.2])); workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-async-")); indexPath = path.join(workspaceDir, "index.sqlite"); await fs.mkdir(path.join(workspaceDir, "memory")); @@ -40,22 +60,7 @@ describe("memory search async sync", () => { }); it("does not await sync when searching", async () => { - const cfg = { - agents: { - defaults: { - workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "text-embedding-3-small", - store: { path: indexPath }, - sync: { watch: false, onSessionStart: false, onSearch: true }, - query: { minScore: 0 }, - remote: { batch: { enabled: true, wait: true } }, - }, - }, - list: [{ id: "main", default: true }], - }, - } as OpenClawConfig; + const cfg = buildConfig(); manager = await createMemoryManagerOrThrow(cfg); @@ -70,4 +75,41 @@ describe("memory search async sync", () => { await activeManager.search("hello"); expect(syncMock).toHaveBeenCalledTimes(1); }); + + it("waits for in-flight search sync during close", async () => { + const cfg = buildConfig(); + let releaseSync: (() => void) | null = null; + const syncGate = new Promise((resolve) => { + releaseSync = resolve; + }); + embedBatch.mockImplementation(async (input: string[]) => { + await syncGate; + return input.map(() => [0.3, 0.2, 0.1]); + }); + + manager = await createMemoryManagerOrThrow(cfg); + await manager.search("hello"); + + let closed = false; + const closePromise = manager.close().then(() => { + closed = true; + }); + + await Promise.resolve(); + expect(closed).toBe(false); + + releaseSync?.(); + await closePromise; + manager = null; + + const reopened = await getMemorySearchManager({ cfg, agentId: "main", purpose: "status" }); + expect(reopened.manager).not.toBeNull(); + if (!reopened.manager) { + throw new Error("reopened manager missing"); + } + const status = reopened.manager.status(); + expect(status.files).toBeGreaterThan(0); + expect(status.dirty).toBe(false); + await reopened.manager.close?.(); + }); }); diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 1082e5ee52a..358a25c8969 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -379,6 +379,9 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem force?: boolean; progress?: (update: MemorySyncProgressUpdate) => void; }): Promise { + if (this.closed) { + return; + } if (this.syncing) { return this.syncing; } @@ -602,6 +605,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem return; } this.closed = true; + const pendingSync = this.syncing; if (this.watchTimer) { clearTimeout(this.watchTimer); this.watchTimer = null; @@ -622,6 +626,11 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem this.sessionUnsubscribe(); this.sessionUnsubscribe = null; } + if (pendingSync) { + try { + await pendingSync; + } catch {} + } this.db.close(); INDEX_CACHE.delete(this.cacheKey); }