diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d36307107c..8871b41745e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/commitments: keep inferred follow-ups internal when heartbeat target is none, strip raw source text from stored commitments, disable tools during due-commitment heartbeat turns, bound hidden extraction queue growth, expire stale commitments, and add QA/Docker safety coverage. Thanks @vignesh07. -- Plugins/runtime-deps: accept already materialized package-level runtime-deps supersets as converged, so later lazy plugin activation no longer prunes and relaunches `pnpm install` after gateway startup pre-staging. Fixes #75283. Thanks @brokemac79. +- Plugins/runtime-deps: accept already materialized package-level runtime-deps supersets as converged, so later lazy plugin activation no longer prunes and relaunches `pnpm install` after gateway startup pre-staging, reducing event-loop pressure from repeated runtime-deps repair on packaged installs. Fixes #75283; refs #75297 and #72338. Thanks @brokemac79, @lisandromachado, and @midhunmonachan. - TTS/providers: keep bundled speech-provider compat fallback available when plugins are globally disabled, so cold gateway and CLI startup can still resolve fallback speech providers instead of leaving explicit TTS provider selection with no registered providers. Refs #75265. Thanks @sliekens. - Discord: collapse repeated native slash-command deploy rate-limit startup logs into one non-fatal warning while keeping per-request REST timing in verbose output. Thanks @discord. - Providers/OpenAI Codex: preserve existing wrapped Codex streams during OpenAI attribution so PI OAuth bearer injection reaches ChatGPT/Codex Responses, and strip native Codex-only unsupported payload fields without touching custom compatible endpoints. (#75111) Thanks @keshavbotagent. diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 86bf7a13596..016d42ea3c2 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -2305,6 +2305,100 @@ describe("ensureBundledPluginRuntimeDeps", () => { expect(result).toEqual({ installedSpecs: [] }); }); + it("does not scan every bundled manifest when the requested package-level deps are already materialized", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.29" }), + ); + const alphaRoot = writeBundledPluginPackage({ + packageRoot, + pluginId: "alpha", + deps: { "alpha-runtime": "1.0.0" }, + enabledByDefault: true, + }); + const betaRoot = writeBundledPluginPackage({ + packageRoot, + pluginId: "beta", + deps: { "beta-runtime": "2.0.0" }, + enabledByDefault: true, + }); + const betaManifestPath = path.join(betaRoot, "openclaw.plugin.json"); + const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; + const installRoot = resolveBundledRuntimeDependencyInstallRoot(alphaRoot, { env }); + writeInstalledPackage(installRoot, "alpha-runtime", "1.0.0"); + writeInstalledPackage(installRoot, "beta-runtime", "2.0.0"); + writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"]); + const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); + + const result = ensureBundledPluginRuntimeDeps({ + env, + pluginId: "alpha", + pluginRoot: alphaRoot, + installDeps: () => { + throw new Error("already materialized package-level deps should not reinstall"); + }, + }); + + expect(result).toEqual({ installedSpecs: [] }); + expect( + readFileSyncSpy.mock.calls.filter( + (call) => path.resolve(String(call[0])) === betaManifestPath, + ), + ).toHaveLength(0); + }); + + it("does not skip missing manifest runtime deps when package deps are materialized", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.29" }), + ); + const pluginRoot = writeBundledPluginPackage({ + packageRoot, + pluginId: "memory-core", + deps: { chokidar: "5.0.0", typebox: "1.1.34" }, + runtimeDependencies: { + localMemoryEmbedding: ["node-llama-cpp@3.18.1"], + }, + }); + const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); + writeInstalledPackage(installRoot, "chokidar", "5.0.0"); + writeInstalledPackage(installRoot, "typebox", "1.1.34"); + writeGeneratedRuntimeDepsManifest(installRoot, ["chokidar@5.0.0", "typebox@1.1.34"]); + const calls: BundledRuntimeDepsInstallParams[] = []; + + const result = ensureBundledPluginRuntimeDeps({ + env, + config: { + agents: { + defaults: { + memorySearch: { provider: "local" }, + }, + }, + }, + installDeps: (params) => { + calls.push(params); + }, + pluginId: "memory-core", + pluginRoot, + }); + + expect(result).toEqual({ + installedSpecs: ["chokidar@5.0.0", "node-llama-cpp@3.18.1", "typebox@1.1.34"], + }); + expect(calls).toEqual([ + { + installRoot, + missingSpecs: ["chokidar@5.0.0", "node-llama-cpp@3.18.1", "typebox@1.1.34"], + installSpecs: ["chokidar@5.0.0", "node-llama-cpp@3.18.1", "typebox@1.1.34"], + }, + ]); + }); + it("accepts generated package-level runtime-deps supersets without reinstalling", () => { const packageRoot = makeTempDir(); const stageDir = makeTempDir(); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index c3c15c74b9d..be7174e4b3a 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -59,7 +59,10 @@ import { parseInstallableRuntimeDep, type RuntimeDepEntry, } from "./bundled-runtime-deps-specs.js"; -import { normalizePluginsConfigWithResolver } from "./config-normalization-shared.js"; +import { + normalizePluginsConfigWithResolver, + type NormalizePluginId, +} from "./config-normalization-shared.js"; export { createBundledRuntimeDepsInstallArgs, @@ -205,6 +208,37 @@ function createBundledRuntimeDepsPlan(params: { }; } +function arePackageLevelRuntimeDepsAlreadyMaterialized(params: { + installRoot: string; + packageRoot: string; + pluginDeps: readonly RuntimeDepEntry[]; +}): boolean { + const installSpecs = createBundledRuntimeDepsInstallSpecs({ + deps: [...params.pluginDeps, ...collectMirroredPackageRuntimeDeps(params.packageRoot)], + }); + return installSpecs.length > 0 && isRuntimeDepsPlanMaterialized(params.installRoot, installSpecs); +} + +function collectPackageLevelRuntimeDepsForPlugin(params: { + extensionsDir: string; + pluginId: string; + pluginDepEntries: readonly RuntimeDepEntry[]; + config?: OpenClawConfig; + manifestCache: BundledPluginRuntimeDepsManifestCache; + normalizePluginId?: NormalizePluginId; +}): { deps: readonly RuntimeDepEntry[]; conflicts: readonly RuntimeDepConflict[] } { + if (!params.config) { + return { deps: params.pluginDepEntries, conflicts: [] }; + } + return collectBundledPluginRuntimeDeps({ + extensionsDir: params.extensionsDir, + config: params.config, + pluginIds: new Set([params.pluginId]), + manifestCache: params.manifestCache, + ...(params.normalizePluginId ? { normalizePluginId: params.normalizePluginId } : {}), + }); +} + export function scanBundledPluginRuntimeDeps(params: { packageRoot: string; config?: OpenClawConfig; @@ -342,6 +376,25 @@ export function ensureBundledPluginRuntimeDeps(params: { packageRoot && path.resolve(installRoot) !== path.resolve(params.pluginRoot); let deps = pluginDepEntries; if (usePackageLevelPlan && packageRoot) { + const requestedPluginPlan = collectPackageLevelRuntimeDepsForPlugin({ + extensionsDir, + pluginId: params.pluginId, + pluginDepEntries, + ...(params.config ? { config: params.config } : {}), + manifestCache, + ...(normalizePluginId ? { normalizePluginId } : {}), + }); + if ( + requestedPluginPlan.conflicts.length === 0 && + arePackageLevelRuntimeDepsAlreadyMaterialized({ + installRoot, + packageRoot, + pluginDeps: requestedPluginPlan.deps, + }) + ) { + removeLegacyRuntimeDepsManifest(installRoot); + return createBundledRuntimeDepsEnsureResult([]); + } const packagePlan = collectBundledPluginRuntimeDeps({ extensionsDir, ...(params.config ? { config: params.config } : {}),