fix(memory): avoid watchers for memory CLI commands

This commit is contained in:
Peter Steinberger
2026-04-27 11:50:37 +01:00
parent c9b9887583
commit 9dcd53c0b6
12 changed files with 131 additions and 21 deletions

View File

@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Memory-core: run one-shot memory CLI commands through transient builtin and QMD managers so `memory index`, `memory status --index`, and `memory search` no longer start long-lived file watchers that can hit macOS `EMFILE` limits. Fixes #59101; carries forward #49851. Thanks @mbear469210-coder and @maoyuanxue.
- Memory-core: re-resolve the active runtime config whenever `memory_search` or `memory_get` executes, so provider changes made by `config.patch` stop leaving stale embedding backends behind in existing tool instances. Fixes #61098. Thanks @BradGroux and @Linux2010.
- WebChat: keep bare `/new` and `/reset` startup instructions out of visible chat history while preserving `/reset <note>` as user-visible transcript text. Fixes #72369. Thanks @collynes and @haishmg.
- CLI/doctor: remove dangling channel config, heartbeat targets, and channel model overrides when stale plugin repair removes a missing channel plugin, preventing Gateway boot loops after failed plugin reinstalls. Fixes #65293. Thanks @yidecode.

View File

@@ -680,7 +680,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
}> = [];
for (const agentId of agentIds) {
const managerPurpose = opts.index ? "default" : "status";
const managerPurpose = opts.index ? "cli" : "status";
await withMemoryManagerForAgent({
cfg,
agentId,
@@ -1025,6 +1025,7 @@ export async function runMemoryIndex(opts: MemoryCommandOptions) {
await withMemoryManagerForAgent({
cfg,
agentId,
purpose: "cli",
run: async (manager) => {
try {
const syncFn = manager.sync ? manager.sync.bind(manager) : undefined;
@@ -1177,6 +1178,7 @@ export async function runMemorySearch(
await withMemoryManagerForAgent({
cfg,
agentId,
purpose: "cli",
run: async (manager) => {
const sessionKey = buildCliMemorySearchSessionKey(agentId);
let results: Awaited<ReturnType<typeof manager.search>>;

View File

@@ -558,6 +558,11 @@ describe("memory cli", () => {
expectCliSync(sync);
expect(probeEmbeddingAvailability).toHaveBeenCalled();
expect(getMemorySearchManager).toHaveBeenCalledWith({
cfg: {},
agentId: "main",
purpose: "cli",
});
expect(close).toHaveBeenCalled();
});
@@ -570,6 +575,11 @@ describe("memory cli", () => {
await runMemoryCli(["index"]);
expectCliSync(sync);
expect(getMemorySearchManager).toHaveBeenCalledWith({
cfg: {},
agentId: "main",
purpose: "cli",
});
expect(close).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
});
@@ -785,6 +795,11 @@ describe("memory cli", () => {
minScore: undefined,
sessionKey: "agent:main:cli:direct:memory-search",
});
expect(getMemorySearchManager).toHaveBeenCalledWith({
cfg: {},
agentId: "main",
purpose: "cli",
});
expect(log).toHaveBeenCalledWith("No matches.");
expect(close).toHaveBeenCalled();
});

View File

@@ -7,5 +7,6 @@ export type {
export {
closeAllMemorySearchManagers,
getMemorySearchManager,
type MemorySearchManagerPurpose,
type MemorySearchManagerResult,
} from "./search-manager.js";

View File

@@ -62,6 +62,7 @@ const FTS_TABLE = "chunks_fts";
const EMBEDDING_CACHE_TABLE = "embedding_cache";
const MEMORY_INDEX_MANAGER_CACHE_KEY = Symbol.for("openclaw.memoryIndexManagerCache");
const log = createSubsystemLogger("memory");
type MemoryIndexManagerPurpose = "default" | "status" | "cli";
const { cache: INDEX_CACHE, pending: INDEX_CACHE_PENDING } =
resolveSingletonManagedCache<MemoryIndexManager>(MEMORY_INDEX_MANAGER_CACHE_KEY);
@@ -155,7 +156,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
static async get(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: "default" | "status";
purpose?: MemoryIndexManagerPurpose;
}): Promise<MemoryIndexManager | null> {
const { cfg, agentId } = params;
const settings = resolveMemorySearchConfig(cfg, agentId);
@@ -163,14 +164,15 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
return null;
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const purpose = params.purpose === "status" ? "status" : "default";
const purpose =
params.purpose === "status" || params.purpose === "cli" ? params.purpose : "default";
const key = `${agentId}:${workspaceDir}:${JSON.stringify(settings)}:${purpose}`;
const statusOnly = params.purpose === "status";
const transient = purpose === "status" || purpose === "cli";
return await getOrCreateManagedCacheEntry({
cache: INDEX_CACHE,
pending: INDEX_CACHE_PENDING,
key,
bypassCache: statusOnly,
bypassCache: transient,
create: async () =>
new MemoryIndexManager({
cacheKey: key,
@@ -190,7 +192,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
workspaceDir: string;
settings: ResolvedMemorySearchConfig;
providerResult?: EmbeddingProviderResult;
purpose?: "default" | "status";
purpose?: MemoryIndexManagerPurpose;
}) {
super();
this.cacheKey = params.cacheKey;
@@ -221,15 +223,15 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
if (meta?.vectorDims) {
this.vector.dims = meta.vectorDims;
}
const statusOnly = params.purpose === "status";
if (!statusOnly) {
const transient = params.purpose === "status" || params.purpose === "cli";
if (!transient) {
this.ensureWatcher();
this.ensureSessionListener();
this.ensureIntervalSync();
}
this.dirty = resolveInitialMemoryDirty({
hasMemorySource: this.sources.has("memory"),
statusOnly,
statusOnly: params.purpose === "status",
hasIndexedMeta: Boolean(meta),
});
this.batch = this.resolveBatchConfig();

View File

@@ -190,6 +190,17 @@ describe("memory watcher config", () => {
).toBe(false);
});
it("does not start watchers for one-shot CLI managers", async () => {
await setupWatcherWorkspace({ name: "notes.md", contents: "hello" });
const cfg = createWatcherConfig();
const result = await getMemorySearchManager({ cfg, agentId: "main", purpose: "cli" });
expect(result.manager).not.toBeNull();
manager = result.manager as unknown as MemoryIndexManager;
expect(watchMock).not.toHaveBeenCalled();
});
it("watches multimodal extra directories with filtered extensions", async () => {
await setupWatcherWorkspace({ name: "PHOTO.PNG", contents: "png" });
const cfg = createWatcherConfig({

View File

@@ -171,7 +171,10 @@ describe("QmdMemoryManager", () => {
return manager;
}
async function createManager(params?: { mode?: "full" | "status"; cfg?: OpenClawConfig }) {
async function createManager(params?: {
mode?: "full" | "status" | "cli";
cfg?: OpenClawConfig;
}) {
const cfgToUse = params?.cfg ?? cfg;
const resolved = resolveMemoryBackendConfig({ cfg: cfgToUse, agentId });
const manager = trackManager(
@@ -486,6 +489,30 @@ describe("QmdMemoryManager", () => {
await manager?.close();
});
it("initializes one-shot CLI mode without watchers or background updates", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "5m", debounceMs: 60_000, onBoot: true },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
const { manager } = await createManager({ mode: "cli" });
expect(watchMock).not.toHaveBeenCalled();
const updateCalls = spawnMock.mock.calls
.map((call: unknown[]) => call[1] as string[])
.filter((args: string[]) => args[0] === "update" || args[0] === "embed");
expect(updateCalls).toEqual([]);
await manager?.close();
});
it("can be configured to block startup on boot update", async () => {
cfg = {
...cfg,

View File

@@ -210,7 +210,7 @@ type ManagedCollection = {
kind: "memory" | "custom" | "sessions";
};
type QmdManagerMode = "full" | "status";
type QmdManagerMode = "full" | "status" | "cli";
type QmdManagerRuntimeConfig = {
workspaceDir: string;
syncSettings: ReturnType<typeof resolveMemorySearchSyncConfig>;
@@ -414,6 +414,10 @@ export class QmdMemoryManager implements MemorySearchManager {
await this.symlinkSharedModels();
await this.ensureCollections();
if (mode === "cli") {
return;
}
this.ensureWatcher();
if (this.qmd.update.onBoot) {

View File

@@ -643,6 +643,49 @@ describe("getMemorySearchManager caching", () => {
expect(mockPrimary.close).toHaveBeenCalledTimes(2);
});
it("does not reuse cached full qmd managers for one-shot CLI requests", async () => {
const agentId = "cli-agent";
const cfg = createQmdCfg(agentId);
const fullPrimary = createManagerMock({
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
withMemorySourceCounts: true,
});
const cliPrimary = createManagerMock({
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
withMemorySourceCounts: true,
});
createQmdManagerMock
.mockImplementationOnce(async () => fullPrimary as unknown as QmdManagerInstance)
.mockImplementationOnce(async () => cliPrimary as unknown as QmdManagerInstance);
const full = await getMemorySearchManager({ cfg, agentId });
const cli = await getMemorySearchManager({ cfg, agentId, purpose: "cli" });
const fullManager = requireManager(full);
const cliManager = requireManager(cli);
expect(cliManager).toBe(cliPrimary);
expect(cliManager).not.toBe(fullManager);
expect(createQmdManagerMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ agentId, mode: "full" }),
);
expect(createQmdManagerMock.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({ agentId, mode: "cli" }),
);
await cli.manager?.close?.();
expect(cliPrimary.close).toHaveBeenCalledTimes(1);
expect(fullPrimary.close).not.toHaveBeenCalled();
const fullAgain = await getMemorySearchManager({ cfg, agentId });
expect(fullAgain.manager).toBe(fullManager);
});
it("does not cache builtin managers for status-only requests", async () => {
const agentId = "builtin-status-agent";
const cfg = createBuiltinCfg(agentId);

View File

@@ -92,10 +92,12 @@ export type MemorySearchManagerResult = {
error?: string;
};
export type MemorySearchManagerPurpose = "default" | "status" | "cli";
export async function getMemorySearchManager(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: "default" | "status";
purpose?: MemorySearchManagerPurpose;
}): Promise<MemorySearchManagerResult> {
const resolved = resolveMemoryBackendConfig(params);
if (resolved.backend === "qmd" && resolved.qmd) {
@@ -103,12 +105,12 @@ export async function getMemorySearchManager(params: {
const normalizedAgentId = normalizeAgentId(params.agentId);
const runtimeConfig = resolveQmdManagerRuntimeConfig(params.cfg, normalizedAgentId);
const { workspaceDir } = runtimeConfig;
const statusOnly = params.purpose === "status";
const transient = params.purpose === "status" || params.purpose === "cli";
const scopeKey = buildQmdManagerScopeKey(normalizedAgentId);
const identityKey = buildQmdManagerIdentityKey(normalizedAgentId, qmdResolved, runtimeConfig);
const createPrimaryQmdManager = async (
mode: "full" | "status",
mode: "full" | "status" | "cli",
): Promise<Maybe<MemorySearchManager>> => {
try {
await fs.mkdir(workspaceDir, { recursive: true });
@@ -183,17 +185,19 @@ export async function getMemorySearchManager(params: {
const cached = QMD_MANAGER_CACHE.get(scopeKey);
const cachedMatchesIdentity = cached?.identityKey === identityKey;
if (cachedMatchesIdentity) {
if (statusOnly) {
if (params.purpose === "status") {
// 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(cached.manager) };
}
return { manager: cached.manager };
if (params.purpose !== "cli") {
return { manager: cached.manager };
}
}
if (statusOnly) {
const manager = await createPrimaryQmdManager("status");
if (transient) {
const manager = await createPrimaryQmdManager(params.purpose === "cli" ? "cli" : "status");
return manager ? { manager } : await getBuiltinMemorySearchManager(params);
}
@@ -236,7 +240,7 @@ export async function getMemorySearchManager(params: {
async function getBuiltinMemorySearchManager(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: "default" | "status";
purpose?: MemorySearchManagerPurpose;
}): Promise<MemorySearchManagerResult> {
try {
const { MemoryIndexManager } = await loadManagerRuntime();

View File

@@ -20,7 +20,7 @@ async function loadGetMemorySearchManager(): Promise<MemoryIndexModule["getMemor
export async function getRequiredMemoryIndexManager(params: {
cfg: OpenClawConfig;
agentId?: string;
purpose?: "default" | "status";
purpose?: "default" | "status" | "cli";
}): Promise<MemoryIndexManager> {
await ensureEmbeddingMocksLoaded();
const getMemorySearchManager = await loadGetMemorySearchManager();

View File

@@ -81,7 +81,7 @@ export async function getMemoryManagerContext(params: {
export async function getMemoryManagerContextWithPurpose(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: "default" | "status";
purpose?: "default" | "status" | "cli";
}): Promise<
| {
manager: NonNullable<MemorySearchManagerResult["manager"]>;