Memory/QMD: make status checks side-effect free

This commit is contained in:
Vignesh Natarajan
2026-02-14 15:41:16 -08:00
parent ceb934299b
commit c4dbcc3444
7 changed files with 76 additions and 9 deletions

View File

@@ -253,8 +253,9 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
}> = [];
for (const agentId of agentIds) {
const managerPurpose = opts.index ? "default" : "status";
await withManager<MemoryManager>({
getManager: () => getMemorySearchManager({ cfg, agentId }),
getManager: () => getMemorySearchManager({ cfg, agentId, purpose: managerPurpose }),
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
onCloseError: (err) =>
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),

View File

@@ -158,7 +158,7 @@ export async function scanStatus(
return null;
}
const agentId = agentStatus.defaultId ?? "main";
const { manager } = await getMemorySearchManager({ cfg, agentId });
const { manager } = await getMemorySearchManager({ cfg, agentId, purpose: "status" });
if (!manager) {
return null;
}

View File

@@ -189,6 +189,26 @@ describe("QmdMemoryManager", () => {
await manager?.close();
});
it("skips qmd command side effects in status mode initialization", 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 resolved = resolveMemoryBackendConfig({ cfg, agentId });
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved, mode: "status" });
expect(manager).toBeTruthy();
expect(spawnMock).not.toHaveBeenCalled();
await manager?.close();
});
it("can be configured to block startup on boot update", async () => {
cfg = {
...cfg,

View File

@@ -44,18 +44,21 @@ type SessionExporterConfig = {
collectionName: string;
};
type QmdManagerMode = "full" | "status";
export class QmdMemoryManager implements MemorySearchManager {
static async create(params: {
cfg: OpenClawConfig;
agentId: string;
resolved: ResolvedMemoryBackendConfig;
mode?: QmdManagerMode;
}): Promise<QmdMemoryManager | null> {
const resolved = params.resolved.qmd;
if (!resolved) {
return null;
}
const manager = new QmdMemoryManager({ cfg: params.cfg, agentId: params.agentId, resolved });
await manager.initialize();
await manager.initialize(params.mode ?? "full");
return manager;
}
@@ -143,7 +146,12 @@ export class QmdMemoryManager implements MemorySearchManager {
}
}
private async initialize(): Promise<void> {
private async initialize(mode: QmdManagerMode): Promise<void> {
this.bootstrapCollections();
if (mode === "status") {
return;
}
await fs.mkdir(this.xdgConfigHome, { recursive: true });
await fs.mkdir(this.xdgCacheHome, { recursive: true });
await fs.mkdir(path.dirname(this.indexPath), { recursive: true });
@@ -156,7 +164,6 @@ export class QmdMemoryManager implements MemorySearchManager {
// isolated while models are shared.
await this.symlinkSharedModels();
this.bootstrapCollections();
await this.ensureCollections();
if (this.qmd.update.onBoot) {

View File

@@ -53,6 +53,8 @@ const fallbackManager = {
close: vi.fn(async () => {}),
};
const mockMemoryIndexGet = vi.fn(async () => fallbackManager);
vi.mock("./qmd-manager.js", () => ({
QmdMemoryManager: {
create: vi.fn(async () => mockPrimary),
@@ -61,7 +63,7 @@ vi.mock("./qmd-manager.js", () => ({
vi.mock("./manager.js", () => ({
MemoryIndexManager: {
get: vi.fn(async () => fallbackManager),
get: mockMemoryIndexGet,
},
}));
@@ -83,6 +85,8 @@ beforeEach(() => {
fallbackManager.probeEmbeddingAvailability.mockClear();
fallbackManager.probeVectorAvailability.mockClear();
fallbackManager.close.mockClear();
mockMemoryIndexGet.mockReset();
mockMemoryIndexGet.mockResolvedValue(fallbackManager);
QmdMemoryManager.create.mockClear();
});
@@ -126,6 +130,32 @@ describe("getMemorySearchManager caching", () => {
expect(QmdMemoryManager.create).toHaveBeenCalledTimes(2);
});
it("does not cache status-only qmd managers", async () => {
const agentId = "status-agent";
const cfg = {
memory: { backend: "qmd", qmd: {} },
agents: { list: [{ id: agentId, default: true, workspace: "/tmp/workspace" }] },
} as const;
const first = await getMemorySearchManager({ cfg, agentId, purpose: "status" });
const second = await getMemorySearchManager({ cfg, agentId, purpose: "status" });
expect(first.manager).toBeTruthy();
expect(second.manager).toBeTruthy();
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(QmdMemoryManager.create).toHaveBeenCalledTimes(2);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(QmdMemoryManager.create).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ agentId, mode: "status" }),
);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(QmdMemoryManager.create).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ agentId, mode: "status" }),
);
});
it("does not evict a newer cached wrapper when closing an older failed wrapper", async () => {
const retryAgentId = "retry-agent-close";
const cfg = {

View File

@@ -19,13 +19,17 @@ export type MemorySearchManagerResult = {
export async function getMemorySearchManager(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: "default" | "status";
}): Promise<MemorySearchManagerResult> {
const resolved = resolveMemoryBackendConfig(params);
if (resolved.backend === "qmd" && resolved.qmd) {
const statusOnly = params.purpose === "status";
const cacheKey = buildQmdCacheKey(params.agentId, resolved.qmd);
const cached = QMD_MANAGER_CACHE.get(cacheKey);
if (cached) {
return { manager: cached };
if (!statusOnly) {
const cached = QMD_MANAGER_CACHE.get(cacheKey);
if (cached) {
return { manager: cached };
}
}
try {
const { QmdMemoryManager } = await import("./qmd-manager.js");
@@ -33,8 +37,12 @@ export async function getMemorySearchManager(params: {
cfg: params.cfg,
agentId: params.agentId,
resolved,
mode: statusOnly ? "status" : "full",
});
if (primary) {
if (statusOnly) {
return { manager: primary };
}
const wrapper = new FallbackMemoryManager(
{
primary,