diff --git a/src/plugins/active-runtime-registry.ts b/src/plugins/active-runtime-registry.ts index e79415255a8..073031afe12 100644 --- a/src/plugins/active-runtime-registry.ts +++ b/src/plugins/active-runtime-registry.ts @@ -3,6 +3,7 @@ import { normalizeSortedUniqueStringEntries } from "@openclaw/normalization-core import { resolveCompatibleRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js"; import type { PluginRegistry } from "./registry-types.js"; import { + collectLivePluginRegistries, getActivePluginChannelRegistry, getActivePluginHttpRouteRegistry, getActivePluginRegistry, @@ -15,6 +16,24 @@ export function getActiveRuntimePluginRegistry(): PluginRegistry | null { return getActivePluginRegistry(); } +// Plugin ids confirmed loaded across every live runtime registry surface +// (active plus any pinned http-route/channel/session-extension registry), via +// the canonical collectLivePluginRegistries() set. A plugin can stay live via a +// pinned surface that diverged from the active registry, so reading "loaded" +// from the active registry alone would mislabel it. No-op when the surfaces are +// synced to the active registry (the common case). +export function listLoadedRuntimePluginIdsAcrossSurfaces(): string[] { + const loaded: string[] = []; + for (const registry of collectLivePluginRegistries()) { + for (const plugin of registry.plugins ?? []) { + if (plugin.status === "loaded") { + loaded.push(plugin.id); + } + } + } + return normalizeSortedUniqueStringEntries(loaded); +} + function normalizeRequiredPluginIds(ids?: readonly string[]): string[] | undefined { if (ids === undefined) { return undefined; diff --git a/src/status/status-plugin-health.runtime.test.ts b/src/status/status-plugin-health.runtime.test.ts index c534b025fc0..a8164e7e6fc 100644 --- a/src/status/status-plugin-health.runtime.test.ts +++ b/src/status/status-plugin-health.runtime.test.ts @@ -11,7 +11,13 @@ import { } from "../plugin-state/plugin-state-store.js"; import { createRuntimeHealthRecordEnvelope } from "../plugin-state/runtime-health-store.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; -import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; +import { + pinActivePluginChannelRegistry, + pinActivePluginHttpRouteRegistry, + pinActivePluginSessionExtensionRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "../plugins/runtime.js"; import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; import { collectRuntimePluginHealthSnapshot } from "./status-plugin-health.runtime.js"; @@ -245,4 +251,83 @@ describe("runtime plugin health snapshot", () => { ]); expect(resolveReadOnlyChannelPluginsForConfigMock).not.toHaveBeenCalled(); }); + + it("records only runtime status:loaded plugins as runtime-loaded", () => { + const registry = createEmptyPluginRegistry(); + registry.plugins.push( + { id: "runtime-ok", status: "loaded", enabled: true } as never, + { id: "runtime-broken", status: "error", enabled: true } as never, + { id: "runtime-off", status: "disabled", enabled: false } as never, + ); + setActivePluginRegistry(registry, "runtime-loaded-ids", "default", "/tmp/ws"); + + expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual(["runtime-ok"]); + }); + + it("includes loaded plugins from a pinned channel registry diverged from active", () => { + const active = createEmptyPluginRegistry(); + active.plugins.push({ id: "active-ok", status: "loaded", enabled: true } as never); + setActivePluginRegistry(active, "active", "default", "/tmp/ws"); + + const channel = createEmptyPluginRegistry(); + channel.plugins.push({ id: "channel-only", status: "loaded", enabled: true } as never); + pinActivePluginChannelRegistry(channel); + + expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual([ + "active-ok", + "channel-only", + ]); + }); + + it("includes loaded plugins from a pinned http-route registry diverged from active", () => { + const active = createEmptyPluginRegistry(); + active.plugins.push({ id: "active-ok", status: "loaded", enabled: true } as never); + setActivePluginRegistry(active, "active", "default", "/tmp/ws"); + + const httpRoute = createEmptyPluginRegistry(); + httpRoute.plugins.push({ id: "route-only", status: "loaded", enabled: true } as never); + pinActivePluginHttpRouteRegistry(httpRoute); + + expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual([ + "active-ok", + "route-only", + ]); + }); + + it("includes loaded plugins from a pinned session-extension registry diverged from active", () => { + const active = createEmptyPluginRegistry(); + active.plugins.push({ id: "active-ok", status: "loaded", enabled: true } as never); + setActivePluginRegistry(active, "active", "default", "/tmp/ws"); + + const sessionExtension = createEmptyPluginRegistry(); + sessionExtension.plugins.push({ + id: "session-only", + status: "loaded", + enabled: true, + } as never); + pinActivePluginSessionExtensionRegistry(sessionExtension); + + expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual([ + "active-ok", + "session-only", + ]); + }); + + it("dedupes runtime-loaded ids shared across registry surfaces", () => { + const active = createEmptyPluginRegistry(); + active.plugins.push({ id: "shared", status: "loaded", enabled: true } as never); + setActivePluginRegistry(active, "active", "default", "/tmp/ws"); + + const channel = createEmptyPluginRegistry(); + channel.plugins.push( + { id: "shared", status: "loaded", enabled: true } as never, + { id: "channel-only", status: "loaded", enabled: true } as never, + ); + pinActivePluginChannelRegistry(channel); + + expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual([ + "channel-only", + "shared", + ]); + }); }); diff --git a/src/status/status-plugin-health.runtime.ts b/src/status/status-plugin-health.runtime.ts index afa017fe8d3..18c1343afbc 100644 --- a/src/status/status-plugin-health.runtime.ts +++ b/src/status/status-plugin-health.runtime.ts @@ -4,7 +4,10 @@ import { resolveReadOnlyChannelPluginsForConfig } from "../channels/plugins/read // ordinary status tests do not eagerly load plugin registry internals. import type { OpenClawConfig } from "../config/types.openclaw.js"; import { listContextEngineQuarantines } from "../context-engine/registry.js"; -import { getActiveRuntimePluginRegistry } from "../plugins/active-runtime-registry.js"; +import { + getActiveRuntimePluginRegistry, + listLoadedRuntimePluginIdsAcrossSurfaces, +} from "../plugins/active-runtime-registry.js"; import { dedupeChannelPluginFailures, dedupePluginDiagnostics, @@ -157,6 +160,11 @@ export function collectRuntimePluginHealthSnapshot(): StatusPluginHealthSnapshot const registry = getActiveRuntimePluginRegistry(); const diagnostics = (registry?.diagnostics ?? []).map(normalizeDiagnostic); const plugins = (registry?.plugins ?? []).map(normalizeSnapshotPlugin); + // Confirmed runtime-loaded ids across all live registry surfaces (so a plugin + // still live via a pinned channel/http-route registry counts) let detailed + // status separate actually-loaded plugins from disk-scan inventory the merged + // snapshot also marks "loaded". + const runtimeLoadedPluginIds = listLoadedRuntimePluginIdsAcrossSurfaces(); return { plugins, diagnostics, @@ -168,6 +176,7 @@ export function collectRuntimePluginHealthSnapshot(): StatusPluginHealthSnapshot channelPluginFailures: collectChannelPluginFailures({ diagnostics, }), + runtimeLoadedPluginIds, }; } diff --git a/src/status/status-plugin-health.test.ts b/src/status/status-plugin-health.test.ts index a994f0c0f11..b6a938f85ee 100644 --- a/src/status/status-plugin-health.test.ts +++ b/src/status/status-plugin-health.test.ts @@ -250,4 +250,77 @@ describe("plugin health status formatting", () => { expect(text).toContain("- WARN legacy-plugin [hook-only]: uses a compatibility shim"); expect(text).toContain("Full inventory: /plugins list"); }); + + it("separates runtime-loaded plugins from installed-but-not-active inventory", () => { + const text = formatDetailedPluginHealth({ + plugins: [ + { id: "runtime-ok", status: "loaded", enabled: true }, + // Disk scan marks this "loaded" from config, but the runtime registry + // never loaded it (absent from runtimeLoadedPluginIds). + { id: "installed-idle", status: "loaded", enabled: true }, + { id: "broken", status: "error", enabled: true, failurePhase: "load", error: "boom" }, + { id: "off", status: "disabled", enabled: false }, + ], + diagnostics: [], + contextEngineQuarantines: [], + runtimeLoadedPluginIds: ["runtime-ok"], + }); + + expect(text).toContain("Loaded: 1 (runtime-ok)"); + expect(text).toContain("Installed (not active): 1 (installed-idle)"); + expect(text).toContain("Disabled: 1"); + // The errored plugin stays in Errors and the disabled plugin in Disabled; + // neither leaks into the installed inventory line. + expect(text).toContain("- broken [load]: boom"); + expect(text).not.toContain("Loaded: 2"); + }); + + it("falls back to status-loaded when runtime provenance is absent", () => { + const text = formatDetailedPluginHealth({ + plugins: [ + { id: "a", status: "loaded", enabled: true }, + { id: "b", status: "loaded", enabled: true }, + ], + diagnostics: [], + contextEngineQuarantines: [], + }); + + expect(text).toContain("Loaded: 2 (a, b)"); + expect(text).not.toContain("Installed (not active):"); + }); + + it("keeps installed-only loaded plugins out of Loaded after merge", () => { + const snapshot = mergeStatusPluginHealthSnapshots( + { + plugins: [{ id: "installed-idle", status: "loaded", enabled: true }], + diagnostics: [], + contextEngineQuarantines: [], + }, + { + plugins: [{ id: "runtime-ok", status: "loaded", enabled: true }], + diagnostics: [], + contextEngineQuarantines: [], + runtimeLoadedPluginIds: ["runtime-ok"], + }, + ); + + expect(snapshot.runtimeLoadedPluginIds).toEqual(["runtime-ok"]); + const text = formatDetailedPluginHealth(snapshot); + expect(text).toContain("Loaded: 1 (runtime-ok)"); + expect(text).toContain("Installed (not active): 1 (installed-idle)"); + }); + + it("lists runtime-loaded plugins even when absent from the merged plugin records", () => { + // A plugin live only via a pinned runtime surface is in runtimeLoadedPluginIds + // but not in snapshot.plugins; it must still show under Loaded, not be dropped. + const text = formatDetailedPluginHealth({ + plugins: [{ id: "active-ok", status: "loaded", enabled: true }], + diagnostics: [], + contextEngineQuarantines: [], + runtimeLoadedPluginIds: ["active-ok", "pinned-only"], + }); + + expect(text).toContain("Loaded: 2 (active-ok, pinned-only)"); + expect(text).not.toContain("Installed (not active):"); + }); }); diff --git a/src/status/status-plugin-health.ts b/src/status/status-plugin-health.ts index eec3c64ee8f..2c9e21f981b 100644 --- a/src/status/status-plugin-health.ts +++ b/src/status/status-plugin-health.ts @@ -59,6 +59,12 @@ export type StatusPluginHealthSnapshot = { runtimeToolQuarantines?: RuntimeToolQuarantineRecord[]; compatibilityNotices?: PluginCompatibilityHealthNotice[]; channelPluginFailures?: ChannelPluginFailureRecord[]; + // Plugin ids confirmed loaded in the active runtime registry (status "loaded"). + // Lets detailed status separate runtime-loaded plugins from installed/discovered + // inventory (the disk scan marks config-enabled plugins "loaded" before runtime + // load). Absent on hand-built/compact snapshots, where detailed rendering falls + // back to the merged status filter. + runtimeLoadedPluginIds?: string[]; }; /** Keeps the first record per key; later duplicates are dropped. */ @@ -146,6 +152,9 @@ export function mergeStatusPluginHealthSnapshots( ...(installed.compatibilityNotices ?? []), ...(runtime.compatibilityNotices ?? []), ]), + // Runtime-loaded provenance is a runtime-side fact; the installed disk scan + // cannot confirm it, so it never contributes here. + runtimeLoadedPluginIds: runtime.runtimeLoadedPluginIds, }; } @@ -235,10 +244,22 @@ function byLocale(left: string, right: string): number { } export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot): string { - const loaded = snapshot.plugins - .filter((plugin) => plugin.status === "loaded") - .map((plugin) => plugin.id) - .toSorted(byLocale); + const statusLoaded = snapshot.plugins.filter((plugin) => plugin.status === "loaded"); + // "Loaded" must mean runtime-confirmed loaded. When the snapshot carries runtime + // provenance, render that authoritative id set directly (it spans all live + // registry surfaces, so a plugin live only via a pinned surface still lists even + // when it is absent from the merged records); installed-but-not-active is then + // the status-loaded records the runtime did not load. Fall back to the raw + // status when provenance is absent (hand-built/compact snapshots). + const runtimeLoadedIds = snapshot.runtimeLoadedPluginIds; + const runtimeLoaded = runtimeLoadedIds ? new Set(runtimeLoadedIds) : undefined; + const loaded = (runtimeLoadedIds ?? statusLoaded.map((plugin) => plugin.id)).toSorted(byLocale); + const installedNotActive = runtimeLoaded + ? statusLoaded + .filter((plugin) => !runtimeLoaded.has(plugin.id)) + .map((plugin) => plugin.id) + .toSorted(byLocale) + : []; const disabled = snapshot.plugins.filter((plugin) => plugin.status === "disabled").length; const errors = snapshot.plugins .filter((plugin) => plugin.status === "error") @@ -266,6 +287,15 @@ export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot) `Disabled: ${disabled}`, ]; + if (installedNotActive.length > 0) { + // Installed/discovered plugins not loaded in the runtime registry. Neutral + // inventory, not an error: the gateway only starts the plugins its startup + // plan requires, so configured-but-not-started is a normal steady state. + lines.push( + `Installed (not active): ${installedNotActive.length} (${formatPluginList(installedNotActive, 8)})`, + ); + } + if (errors.length > 0) { lines.push( `Errors: ${errors.length}`,