From 645c7b189757d5d6427dd6cb40431aaa15da5ced Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 13 Apr 2026 10:58:46 +0100 Subject: [PATCH] fix: harden qmd service startup --- CHANGELOG.md | 2 +- .../src/memory/qmd-manager.test.ts | 1 + .../memory-core/src/memory/qmd-manager.ts | 10 +++++++ .../src/memory/search-manager.test.ts | 27 +++++++++++++++++++ .../memory-core/src/memory/search-manager.ts | 21 ++++++++++++++- 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 153352951b3..e70093a53a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,7 +77,7 @@ Docs: https://docs.openclaw.ai - iMessage: retry transient `watch.subscribe` startup failures before tearing down the monitor, and sanitize startup error logging so brief local transport stalls do not immediately bounce the channel or leak raw imsg RPC payloads into logs. (#65393) Thanks @vincentkoc. - CLI/audio providers: report env-authenticated providers as configured in `openclaw infer audio providers --json`, while keeping trusted workspace provider env lookup defaults stable during auth setup. (#65491) - Plugins/install: reinstall bundled runtime packages when the matching platform native optional child is missing, so packaged Windows installs can recover dependencies that were packed on another host OS. -- Memory/QMD: preserve explicit `memory.qmd.command` paths when resolving the QMD backend, so service and gateway environments can use Homebrew installs without falling back to builtin search. +- Memory/QMD: preserve explicit `memory.qmd.command` paths, create missing agent workspaces before QMD probes, and keep the current Node binary on QMD subprocess PATH so service and gateway environments do not fall back to builtin search unnecessarily. ## 2026.4.11 diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 3e841bb0259..920b70fe1e4 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -2752,6 +2752,7 @@ describe("QmdMemoryManager", () => { "/agents/main/qmd/xdg-config/qmd", ); expect(normalizePath(spawnOpts?.env?.XDG_CACHE_HOME)).toContain("/agents/main/qmd/xdg-cache"); + expect(spawnOpts?.env?.PATH?.split(path.delimiter)).toContain(path.dirname(process.execPath)); await manager.close(); }); diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 9c599829e35..47add225ff7 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -81,6 +81,15 @@ const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([ "__pycache__", ]); +function buildQmdProcessPath(rawPath: string | undefined): string { + const nodeBinDir = path.dirname(process.execPath); + const entries = rawPath?.split(path.delimiter).filter(Boolean) ?? []; + if (entries.includes(nodeBinDir)) { + return rawPath ?? nodeBinDir; + } + return [...entries, nodeBinDir].join(path.delimiter); +} + type McporterState = { coldStartWarned: boolean; daemonStart: Promise | null; @@ -308,6 +317,7 @@ export class QmdMemoryManager implements MemorySearchManager { this.env = { ...process.env, + PATH: buildQmdProcessPath(process.env.PATH), XDG_CONFIG_HOME: this.xdgConfigHome, // QMD resolves index.yml relative to QMD_CONFIG_DIR rather than XDG_CONFIG_HOME. // Point it at the nested qmd config directory so per-agent collections are visible. diff --git a/extensions/memory-core/src/memory/search-manager.test.ts b/extensions/memory-core/src/memory/search-manager.test.ts index 790e191c81b..d86e96e73ab 100644 --- a/extensions/memory-core/src/memory/search-manager.test.ts +++ b/extensions/memory-core/src/memory/search-manager.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; import type { checkQmdBinaryAvailability as checkQmdBinaryAvailabilityFn } from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -256,6 +259,30 @@ describe("getMemorySearchManager caching", () => { }); }); + it("creates a missing agent workspace before probing qmd availability", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-workspace-")); + const workspace = path.join(tempRoot, "missing", "workspace"); + const agentId = "missing-workspace"; + const cfg = { + memory: { backend: "qmd", qmd: {} }, + agents: { list: [{ id: agentId, default: true, workspace }] }, + } as OpenClawConfig; + + try { + await getMemorySearchManager({ cfg, agentId }); + + const stat = await fs.stat(workspace); + expect(stat.isDirectory()).toBe(true); + expect(checkQmdBinaryAvailability).toHaveBeenCalledWith({ + command: "qmd", + env: process.env, + cwd: workspace, + }); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + it("returns a cached qmd manager without probing the binary again", async () => { const agentId = "cached-qmd"; const cfg = createQmdCfg(agentId); diff --git a/extensions/memory-core/src/memory/search-manager.ts b/extensions/memory-core/src/memory/search-manager.ts index 6edad209029..23bf834678f 100644 --- a/extensions/memory-core/src/memory/search-manager.ts +++ b/extensions/memory-core/src/memory/search-manager.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { createSubsystemLogger, @@ -68,10 +69,20 @@ export async function getMemorySearchManager(params: { } } + const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId); + try { + await fs.mkdir(workspaceDir, { recursive: true }); + } catch (err) { + log.warn( + `qmd workspace unavailable (${workspaceDir}); falling back to builtin: ${formatErrorMessage(err)}`, + ); + return await getBuiltinMemorySearchManager(params); + } + const qmdBinary = await checkQmdBinaryAvailability({ command: resolved.qmd.command, env: process.env, - cwd: resolveAgentWorkspaceDir(params.cfg, params.agentId), + cwd: workspaceDir, }); if (!qmdBinary.available) { log.warn( @@ -112,6 +123,14 @@ export async function getMemorySearchManager(params: { } } + return await getBuiltinMemorySearchManager(params); +} + +async function getBuiltinMemorySearchManager(params: { + cfg: OpenClawConfig; + agentId: string; + purpose?: "default" | "status"; +}): Promise { try { const { MemoryIndexManager } = await loadManagerRuntime(); const manager = await MemoryIndexManager.get(params);