fix(cli): load memory plugin for doctor/status when registry is cold (#76393)

Summary:
- The PR adds a scoped standalone memory-slot plugin load for doctor/status memory resolution, updates memory-runtime regression tests, and adds a changelog fix entry.
- Reproducibility: yes. source-reproducible: current main's doctor/status path reads getMemoryRuntime after on ...  registers that runtime only during plugin activation. I did not run a live macOS LaunchAgent reproduction.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(cli): load memory plugin for doctor/status when registry is cold

Validation:
- ClawSweeper review passed for head a6a1967316.
- Required merge gates passed before the squash merge.

Prepared head SHA: a6a1967316
Review: https://github.com/openclaw/openclaw/pull/76393#issuecomment-4365255585

Co-authored-by: Neerav Makwana <261249544+neeravmakwana@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
Neerav Makwana
2026-05-03 03:38:55 -04:00
committed by GitHub
parent 39bc94e4dd
commit b6cbd9225c
3 changed files with 144 additions and 11 deletions

View File

@@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai
- Agents/sessions: preserve delivered trailing assistant replies during session-file repair so Telegram/WebChat history is not rewritten to drop already-delivered responses. Fixes #76329. Thanks @obviyus.
- Gateway/chat history: preserve oversized transcript turns as explicit omitted-message placeholders while avoiding large JSONL parse stalls. Thanks @Marvinthebored and @vincentkoc.
- Gateway/models: keep read-only model-list responses on registry-compatible fallbacks and metadata defaults, so empty or minimal persisted model files do not hide built-ins or custom model capabilities. Thanks @Marvinthebored.
- CLI/doctor: load the configured memory-slot plugin when resolving memory diagnostics so bundled `memory-core` no longer triggers a false “no active memory plugin” warning on standalone `doctor` / `status` runs. Fixes #76367. Thanks @neeravmakwana.
- Gateway: preserve stack diagnostics when `chat.send` or agent attachment parsing/staging fails, improving image-send failure triage. Refs #63432. (#75135) Thanks @keen0206.
- Heartbeats/Codex: stop sending the legacy `HEARTBEAT_OK` prompt instruction when heartbeat turns have the structured `heartbeat_respond` tool, while keeping the text sentinel for legacy automatic heartbeat replies. Thanks @pashpashpash.
- Agent runtimes: fail explicit plugin runtime selections honestly when the requested harness is unavailable instead of silently falling back to the embedded PI runtime. Thanks @pashpashpash.

View File

@@ -4,6 +4,11 @@ const resolveRuntimePluginRegistryMock =
vi.fn<typeof import("./loader.js").resolveRuntimePluginRegistry>();
const getLoadedRuntimePluginRegistryMock =
vi.fn<typeof import("./active-runtime-registry.js").getLoadedRuntimePluginRegistry>();
const ensureStandaloneRuntimePluginRegistryLoadedMock = vi.hoisted(() =>
vi.fn<
typeof import("./runtime/standalone-runtime-registry-loader.js").ensureStandaloneRuntimePluginRegistryLoaded
>(),
);
const applyPluginAutoEnableMock =
vi.fn<typeof import("../config/plugin-auto-enable.js").applyPluginAutoEnable>();
const getMemoryRuntimeMock = vi.fn<typeof import("./memory-state.js").getMemoryRuntime>();
@@ -30,6 +35,10 @@ vi.mock("./active-runtime-registry.js", () => ({
getLoadedRuntimePluginRegistry: getLoadedRuntimePluginRegistryMock,
}));
vi.mock("./runtime/standalone-runtime-registry-loader.js", () => ({
ensureStandaloneRuntimePluginRegistryLoaded: ensureStandaloneRuntimePluginRegistryLoadedMock,
}));
vi.mock("./memory-state.js", () => ({
getMemoryRuntime: () => getMemoryRuntimeMock(),
}));
@@ -61,12 +70,25 @@ function createMemoryRuntimeFixture() {
};
}
function expectMemoryRuntimeLoaded(rawConfig: unknown, autoEnabledConfig: unknown) {
function expectMemoryRuntimeLoaded(
rawConfig: unknown,
autoEnabledConfig: unknown,
pluginIds: readonly string[] = ["memory-core"],
) {
void rawConfig;
void autoEnabledConfig;
expect(getLoadedRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
requiredPluginIds: ["memory-core"],
requiredPluginIds: pluginIds,
}),
);
expect(ensureStandaloneRuntimePluginRegistryLoadedMock).toHaveBeenCalledWith(
expect.objectContaining({
requiredPluginIds: pluginIds,
loadOptions: expect.objectContaining({
onlyPluginIds: pluginIds,
workspaceDir: "/resolved-workspace",
}),
}),
);
}
@@ -84,7 +106,10 @@ function setAutoEnabledMemoryRuntime() {
changes: [],
autoEnabledReasons: {},
});
getMemoryRuntimeMock.mockReturnValueOnce(undefined).mockReturnValue(runtime);
getMemoryRuntimeMock
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(undefined)
.mockReturnValue(runtime);
return { rawConfig, autoEnabledConfig, runtime };
}
@@ -92,6 +117,7 @@ function expectNoMemoryRuntimeBootstrap() {
expect(applyPluginAutoEnableMock).not.toHaveBeenCalled();
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(getLoadedRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(ensureStandaloneRuntimePluginRegistryLoadedMock).not.toHaveBeenCalled();
}
async function expectAutoEnabledMemoryRuntimeCase(params: {
@@ -130,6 +156,7 @@ describe("memory runtime auto-enable loading", () => {
} = await import("./memory-runtime.js"));
resolveRuntimePluginRegistryMock.mockReset();
getLoadedRuntimePluginRegistryMock.mockReset();
ensureStandaloneRuntimePluginRegistryLoadedMock.mockReset();
applyPluginAutoEnableMock.mockReset();
getMemoryRuntimeMock.mockReset();
resolveAgentWorkspaceDirMock.mockReset();
@@ -179,18 +206,17 @@ describe("memory runtime auto-enable loading", () => {
changes: [],
autoEnabledReasons: {},
});
getMemoryRuntimeMock.mockReturnValueOnce(undefined).mockReturnValue(runtime);
getMemoryRuntimeMock
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(undefined)
.mockReturnValue(runtime);
await getActiveMemorySearchManager({
cfg: rawConfig as never,
agentId: "main",
});
expect(getLoadedRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
requiredPluginIds: ["memory-lancedb"],
}),
);
expectMemoryRuntimeLoaded(rawConfig, rawConfig, ["memory-lancedb"]);
});
it("does not fall back to broad plugin loading when the memory slot is disabled", async () => {
@@ -218,6 +244,80 @@ describe("memory runtime auto-enable loading", () => {
expect(applyPluginAutoEnableMock).not.toHaveBeenCalled();
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(getLoadedRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(ensureStandaloneRuntimePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("does not standalone-load the memory plugin when plugins are globally disabled", async () => {
const rawConfig = {
plugins: {
enabled: false,
},
};
getMemoryRuntimeMock.mockReturnValue(undefined);
await expect(
getActiveMemorySearchManager({
cfg: rawConfig as never,
agentId: "main",
}),
).resolves.toEqual({ manager: null, error: "memory plugin unavailable" });
expectNoMemoryRuntimeBootstrap();
});
it.each([
{
name: "denied",
plugins: {
deny: ["memory-core"],
slots: {
memory: "memory-core",
},
},
},
{
name: "entry-disabled",
plugins: {
entries: {
"memory-core": { enabled: false },
},
slots: {
memory: "memory-core",
},
},
},
] as const)("does not standalone-load a $name memory slot plugin", async ({ plugins }) => {
getMemoryRuntimeMock.mockReturnValue(undefined);
await expect(
getActiveMemorySearchManager({
cfg: { plugins } as never,
agentId: "main",
}),
).resolves.toEqual({ manager: null, error: "memory plugin unavailable" });
expectNoMemoryRuntimeBootstrap();
});
it("does not standalone-load plugins when the memory runtime is already registered", () => {
const rawConfig = {
plugins: {
slots: {
memory: "memory-core",
},
},
};
const runtime = createMemoryRuntimeFixture();
getLoadedRuntimePluginRegistryMock.mockReturnValue({} as never);
getMemoryRuntimeMock.mockReturnValueOnce(undefined).mockReturnValue(runtime);
resolveActiveMemoryBackendConfig({
cfg: rawConfig as never,
agentId: "main",
});
expect(getLoadedRuntimePluginRegistryMock).toHaveBeenCalled();
expect(ensureStandaloneRuntimePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it.each([

View File

@@ -1,11 +1,31 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveUserPath } from "../utils.js";
import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js";
import { normalizePluginsConfig } from "./config-state.js";
import { getMemoryRuntime } from "./memory-state.js";
import { ensureStandaloneRuntimePluginRegistryLoaded } from "./runtime/standalone-runtime-registry-loader.js";
function resolveMemoryRuntimePluginIds(config: OpenClawConfig): string[] {
const memorySlot = normalizePluginsConfig(config.plugins).slots.memory;
return typeof memorySlot === "string" && memorySlot.trim().length > 0 ? [memorySlot] : [];
const plugins = normalizePluginsConfig(config.plugins);
const memorySlot = plugins.slots.memory;
if (!plugins.enabled || typeof memorySlot !== "string" || memorySlot.trim().length === 0) {
return [];
}
const pluginId = memorySlot.trim();
if (plugins.deny.includes(pluginId) || plugins.entries[pluginId]?.enabled === false) {
return [];
}
return [pluginId];
}
function resolveMemoryRuntimeWorkspaceDir(cfg: OpenClawConfig): string | undefined {
const agentId = resolveDefaultAgentId(cfg);
const dir = resolveAgentWorkspaceDir(cfg, agentId);
if (typeof dir !== "string" || !dir.trim()) {
return undefined;
}
return resolveUserPath(dir);
}
function ensureMemoryRuntime(cfg?: OpenClawConfig) {
@@ -18,6 +38,18 @@ function ensureMemoryRuntime(cfg?: OpenClawConfig) {
return getMemoryRuntime();
}
getLoadedRuntimePluginRegistry({ requiredPluginIds: onlyPluginIds });
if (getMemoryRuntime()) {
return getMemoryRuntime();
}
const workspaceDir = resolveMemoryRuntimeWorkspaceDir(cfg);
ensureStandaloneRuntimePluginRegistryLoaded({
requiredPluginIds: onlyPluginIds,
loadOptions: {
config: cfg,
onlyPluginIds,
workspaceDir,
},
});
return getMemoryRuntime();
}