perf(plugins): cache manifest metadata loads

This commit is contained in:
Peter Steinberger
2026-04-28 20:34:06 +01:00
parent 98f5fd12df
commit 0608c1015b
4 changed files with 124 additions and 9 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- Channels/Discord: suppress duplicate gateway monitors when multiple enabled accounts resolve to the same bot token, preferring config tokens over default env fallback and reporting skipped duplicates as disabled. Supersedes #73608. Thanks @kagura-agent.
- Channels/Discord: ignore stale route-shaped conversation bindings after a Discord channel is reconfigured to another agent, while preserving explicit focus and subagent bindings. Fixes #73626. Thanks @ramitrkar-hash.
- Gateway/sessions: add conservative stuck-session recovery that releases only stale session lanes while active embedded runs, reply operations, and lane tasks remain serialized, so queued follow-ups can drain without aborting legitimate long-running turns. Refs #73581, #73655, #73652, #73705, #73647, #73602, #73592, and #73601. Thanks @WS-Q0758, @bryangauvin, @spenceryang1996-dot, @bmilne1981, @mattmcintyre, @Vksh07, and @Spolen23.
- Plugins: cache unchanged plugin manifest loads by file signature, reducing repeated JSON/JSON5 parsing and manifest normalization in bursty startup and runtime registry paths. Refs #73532 and #73647; carries forward #73678. Thanks @TheDutchRuler.
- NVIDIA/NIM: persist the `NVIDIA_API_KEY` provider marker and mark bundled NVIDIA Chat Completions models as string-content compatible, so NIM models load from `models.json` and OpenAI-compatible subagent calls send plain text content. Fixes #73013 and #50107; refs #73014. Thanks @bautrey, @iot2edge, @ifearghal, and @futhgar.
- Channels/Discord: let text-only configs drop the `GuildVoiceStates` gateway intent and expose a bounded `/gateway/bot` metadata timeout with rate-limited fallback logs, reducing idle CPU and warning floods. Fixes #73709 and #73585. Thanks @sanchezm86 and @trac3r00.
- Agents/sessions: mark same-turn `sessions_send` and A2A reply prompts with an inter-session `isUser=false` envelope before they reach the model, so foreign session output no longer lands as bare active user text. Fixes #73702; refs #73698, #73609, #73595, and #73622. Thanks @alvelda.

View File

@@ -1,3 +1,6 @@
import { buildChannelConfigSchema, GoogleChatConfigSchema } from "../runtime-api.js";
import {
buildChannelConfigSchema,
GoogleChatConfigSchema,
} from "openclaw/plugin-sdk/bundled-channel-config-schema";
export const GoogleChatChannelConfigSchema = buildChannelConfigSchema(GoogleChatConfigSchema);

View File

@@ -2,7 +2,11 @@ import fs from "node:fs";
import path from "node:path";
import JSON5 from "json5";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadPluginManifest, MAX_PLUGIN_MANIFEST_BYTES } from "./manifest.js";
import {
clearPluginManifestLoadCache,
loadPluginManifest,
MAX_PLUGIN_MANIFEST_BYTES,
} from "./manifest.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
@@ -13,6 +17,7 @@ function makeTempDir() {
afterEach(() => {
vi.restoreAllMocks();
clearPluginManifestLoadCache();
cleanupTrackedTempDirs(tempDirs);
});
@@ -53,6 +58,26 @@ describe("loadPluginManifest JSON5 tolerance", () => {
expect(json5Parse).not.toHaveBeenCalled();
});
it("reuses unchanged manifest loads by file signature", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "openclaw.plugin.json"),
JSON.stringify({
id: "cached-json",
configSchema: { type: "object" },
}),
"utf-8",
);
const readFileSync = vi.spyOn(fs, "readFileSync");
const first = loadPluginManifest(dir, false);
const second = loadPluginManifest(dir, false);
expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
expect(readFileSync).toHaveBeenCalledTimes(1);
});
it("parses a manifest with trailing commas", () => {
const dir = makeTempDir();
const json5Content = `{

View File

@@ -33,6 +33,20 @@ import type { PluginKind } from "./plugin-kind.types.js";
export const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json";
export const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const;
export const MAX_PLUGIN_MANIFEST_BYTES = 256 * 1024;
const MAX_PLUGIN_MANIFEST_LOAD_CACHE_ENTRIES = 512;
type PluginManifestLoadCacheEntry = {
result: PluginManifestLoadResult;
size: number;
mtimeMs: number;
ctimeMs: number;
};
const pluginManifestLoadCache = new Map<string, PluginManifestLoadCacheEntry>();
export function clearPluginManifestLoadCache(): void {
pluginManifestLoadCache.clear();
}
export type PluginManifestChannelConfig = {
schema: JsonSchemaObject;
@@ -1148,6 +1162,62 @@ export function resolvePluginManifestPath(rootDir: string): string {
return path.join(rootDir, PLUGIN_MANIFEST_FILENAME);
}
function buildPluginManifestLoadCacheKey(params: {
manifestPath: string;
rejectHardlinks: boolean;
rootRealPath?: string;
stats: fs.Stats;
}): string {
return JSON.stringify([
path.resolve(params.manifestPath),
params.rejectHardlinks,
params.rootRealPath ?? "",
params.stats.dev,
params.stats.ino,
params.stats.size,
params.stats.mtimeMs,
params.stats.ctimeMs,
]);
}
function getCachedPluginManifestLoadResult(
key: string,
stats: fs.Stats,
): PluginManifestLoadResult | undefined {
const entry = pluginManifestLoadCache.get(key);
if (
!entry ||
entry.size !== stats.size ||
entry.mtimeMs !== stats.mtimeMs ||
entry.ctimeMs !== stats.ctimeMs
) {
return undefined;
}
pluginManifestLoadCache.delete(key);
pluginManifestLoadCache.set(key, entry);
return entry.result;
}
function setCachedPluginManifestLoadResult(
key: string,
stats: fs.Stats,
result: PluginManifestLoadResult,
): void {
pluginManifestLoadCache.set(key, {
result,
size: stats.size,
mtimeMs: stats.mtimeMs,
ctimeMs: stats.ctimeMs,
});
if (pluginManifestLoadCache.size <= MAX_PLUGIN_MANIFEST_LOAD_CACHE_ENTRIES) {
return;
}
const oldestKey = pluginManifestLoadCache.keys().next().value;
if (typeof oldestKey === "string") {
pluginManifestLoadCache.delete(oldestKey);
}
}
function parsePluginKind(raw: unknown): PluginKind | PluginKind[] | undefined {
if (typeof raw === "string") {
return raw as PluginKind;
@@ -1186,28 +1256,44 @@ export function loadPluginManifest(
}),
});
}
const stats = opened.stat;
const cacheKey = buildPluginManifestLoadCacheKey({
manifestPath,
rejectHardlinks,
...(rootRealPath !== undefined ? { rootRealPath } : {}),
stats,
});
const cached = getCachedPluginManifestLoadResult(cacheKey, stats);
if (cached) {
fs.closeSync(opened.fd);
return cached;
}
const cacheResult = (result: PluginManifestLoadResult): PluginManifestLoadResult => {
setCachedPluginManifestLoadResult(cacheKey, stats, result);
return result;
};
let raw: unknown;
try {
raw = parseJsonWithJson5Fallback(fs.readFileSync(opened.fd, "utf-8"));
} catch (err) {
return {
return cacheResult({
ok: false,
error: `failed to parse plugin manifest: ${String(err)}`,
manifestPath,
};
});
} finally {
fs.closeSync(opened.fd);
}
if (!isRecord(raw)) {
return { ok: false, error: "plugin manifest must be an object", manifestPath };
return cacheResult({ ok: false, error: "plugin manifest must be an object", manifestPath });
}
const id = normalizeOptionalString(raw.id) ?? "";
if (!id) {
return { ok: false, error: "plugin manifest requires id", manifestPath };
return cacheResult({ ok: false, error: "plugin manifest requires id", manifestPath });
}
const configSchema = isRecord(raw.configSchema) ? raw.configSchema : null;
if (!configSchema) {
return { ok: false, error: "plugin manifest requires configSchema", manifestPath };
return cacheResult({ ok: false, error: "plugin manifest requires configSchema", manifestPath });
}
const kind = parsePluginKind(raw.kind);
@@ -1260,7 +1346,7 @@ export function loadPluginManifest(
uiHints = raw.uiHints as Record<string, PluginConfigUiHint>;
}
return {
return cacheResult({
ok: true,
manifest: {
id,
@@ -1302,7 +1388,7 @@ export function loadPluginManifest(
channelConfigs,
},
manifestPath,
};
});
}
// package.json "openclaw" metadata (used for setup/catalog)