diff --git a/src/memory/search-manager.test.ts b/src/memory/search-manager.test.ts index 938fd0bc8b5..650ca4a52bf 100644 --- a/src/memory/search-manager.test.ts +++ b/src/memory/search-manager.test.ts @@ -195,7 +195,7 @@ describe("getMemorySearchManager caching", () => { expect(createQmdManagerMock).toHaveBeenCalledTimes(2); }); - it("uses lightweight cached managers for status-only qmd requests", async () => { + it("reuses cached qmd managers for status-only requests", async () => { const agentId = "status-agent"; const cfg = createQmdCfg(agentId); @@ -209,18 +209,48 @@ describe("getMemorySearchManager caching", () => { provider: "qmd", model: "qmd", requestedProvider: "qmd", - custom: { - qmd: { - lightweightStatus: true, - }, - }, }); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(createQmdManagerMock).not.toHaveBeenCalled(); + expect(createQmdManagerMock).toHaveBeenCalledTimes(1); expect(mockMemoryIndexGet).not.toHaveBeenCalled(); expect(second.manager).toBe(first.manager); }); + it("reuses cached full qmd manager for status-only requests", async () => { + const agentId = "status-reuses-full-agent"; + const cfg = createQmdCfg(agentId); + + const full = await getMemorySearchManager({ cfg, agentId }); + const status = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); + + requireManager(full); + requireManager(status); + expect(status.manager).not.toBe(full.manager); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(createQmdManagerMock).toHaveBeenCalledTimes(1); + await status.manager?.close?.(); + expect(mockPrimary.close).not.toHaveBeenCalled(); + + const fullAgain = await getMemorySearchManager({ cfg, agentId }); + expect(fullAgain.manager).toBe(full.manager); + }); + + it("evicts closed cached status managers so later status requests get a fresh manager", async () => { + const agentId = "status-eviction-agent"; + const cfg = createQmdCfg(agentId); + + const first = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); + const firstManager = requireManager(first); + await firstManager.close?.(); + + const second = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); + requireManager(second); + + expect(second.manager).not.toBe(firstManager); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(createQmdManagerMock).toHaveBeenCalledTimes(2); + }); + it("does not evict a newer cached wrapper when closing an older failed wrapper", async () => { const retryAgentId = "retry-agent-close"; const { diff --git a/src/memory/search-manager.ts b/src/memory/search-manager.ts index 9d1b7edf9e1..cd8570e2e5c 100644 --- a/src/memory/search-manager.ts +++ b/src/memory/search-manager.ts @@ -1,8 +1,4 @@ -import os from "node:os"; -import path from "node:path"; -import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveStateDir } from "../config/paths.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import type { ResolvedQmdConfig } from "./backend-config.js"; @@ -57,13 +53,13 @@ export async function getMemorySearchManager(params: { return { manager: cached }; } if (statusOnly) { - const manager = new QmdStatusOnlyManager({ - cfg: params.cfg, - agentId: params.agentId, - resolved: resolved.qmd, - }); - QMD_MANAGER_CACHE.set(cacheKey, manager); - return { manager }; + const fullCached = QMD_MANAGER_CACHE.get(`${baseCacheKey}:full`); + if (fullCached) { + // Status callers often close the manager they receive. Wrap the live + // full manager with a no-op close so health/status probes do not tear + // down the active QMD manager for the process. + return { manager: new BorrowedMemoryManager(fullCached) }; + } } try { const { QmdMemoryManager } = await import("./qmd-manager.js"); @@ -75,8 +71,11 @@ export async function getMemorySearchManager(params: { }); if (primary) { if (statusOnly) { - QMD_MANAGER_CACHE.set(cacheKey, primary); - return { manager: primary }; + const wrapper = new CachedStatusMemoryManager(primary, () => { + QMD_MANAGER_CACHE.delete(cacheKey); + }); + QMD_MANAGER_CACHE.set(cacheKey, wrapper); + return { manager: wrapper }; } const wrapper = new FallbackMemoryManager( { @@ -109,87 +108,95 @@ export async function getMemorySearchManager(params: { } } -class QmdStatusOnlyManager implements MemorySearchManager { - private readonly workspaceDir: string; - private readonly indexPath: string; - private readonly sourceSet: Set<"memory" | "sessions">; +class BorrowedMemoryManager implements MemorySearchManager { + constructor(private readonly inner: MemorySearchManager) {} - constructor( - private readonly params: { - cfg: OpenClawConfig; - agentId: string; - resolved: ResolvedQmdConfig; - }, + async search( + query: string, + opts?: { maxResults?: number; minScore?: number; sessionKey?: string }, ) { - this.workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId); - const stateDir = resolveStateDir(process.env, os.homedir); - this.indexPath = path.join( - stateDir, - "agents", - params.agentId, - "qmd", - "xdg-cache", - "qmd", - "index.sqlite", - ); - this.sourceSet = new Set( - params.resolved.collections.map((collection) => - collection.kind === "sessions" ? "sessions" : "memory", - ), - ); + return await this.inner.search(query, opts); } - async search(): Promise { - throw new Error("memory search unavailable in status-only mode"); - } - - async readFile(): Promise { - throw new Error("memory read unavailable in status-only mode"); + async readFile(params: { relPath: string; from?: number; lines?: number }) { + return await this.inner.readFile(params); } status() { - return { - backend: "qmd" as const, - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - files: 0, - chunks: 0, - dirty: false, - workspaceDir: this.workspaceDir, - dbPath: this.indexPath, - sources: Array.from(this.sourceSet), - vector: { enabled: true, available: true }, - batch: { - enabled: false, - failures: 0, - limit: 0, - wait: false, - concurrency: 0, - pollIntervalMs: 0, - timeoutMs: 0, - }, - custom: { - qmd: { - collections: this.params.resolved.collections.length, - lastUpdateAt: null, - lightweightStatus: true, - }, - }, - }; + return this.inner.status(); } - async sync(): Promise {} + async sync(params?: { + reason?: string; + force?: boolean; + sessionFiles?: string[]; + progress?: (update: MemorySyncProgressUpdate) => void; + }) { + await this.inner.sync?.(params); + } async probeEmbeddingAvailability(): Promise { - return { ok: true }; + return await this.inner.probeEmbeddingAvailability(); } - async probeVectorAvailability(): Promise { - return true; + async probeVectorAvailability() { + return await this.inner.probeVectorAvailability(); } - async close(): Promise {} + async close() {} +} + +class CachedStatusMemoryManager implements MemorySearchManager { + private closed = false; + + constructor( + private readonly inner: MemorySearchManager, + private readonly onClose: () => void, + ) {} + + async search( + query: string, + opts?: { maxResults?: number; minScore?: number; sessionKey?: string }, + ) { + return await this.inner.search(query, opts); + } + + async readFile(params: { relPath: string; from?: number; lines?: number }) { + return await this.inner.readFile(params); + } + + status() { + return this.inner.status(); + } + + async sync(params?: { + reason?: string; + force?: boolean; + sessionFiles?: string[]; + progress?: (update: MemorySyncProgressUpdate) => void; + }) { + await this.inner.sync?.(params); + } + + async probeEmbeddingAvailability(): Promise { + return await this.inner.probeEmbeddingAvailability(); + } + + async probeVectorAvailability() { + return await this.inner.probeVectorAvailability(); + } + + async close() { + if (this.closed) { + return; + } + this.closed = true; + try { + await this.inner.close?.(); + } finally { + this.onClose(); + } + } } export async function closeAllMemorySearchManagers(): Promise {