mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:00:42 +00:00
fix(memory): avoid watchers for memory CLI commands
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -7,5 +7,6 @@ export type {
|
||||
export {
|
||||
closeAllMemorySearchManagers,
|
||||
getMemorySearchManager,
|
||||
type MemorySearchManagerPurpose,
|
||||
type MemorySearchManagerResult,
|
||||
} from "./search-manager.js";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]>;
|
||||
|
||||
Reference in New Issue
Block a user