fix(status): distinguish runtime-loaded plugins from installed inventory (#97479)

* fix(status): distinguish runtime-loaded plugins from installed inventory

/status plugins merged disk-scan plugin records with the active runtime
registry, so the detailed Loaded: list could include plugins that were
installed/config-enabled but never loaded at runtime. Record the runtime-
loaded plugin ids on the health snapshot, carry them through the merge, and
make Loaded: reflect runtime-confirmed plugins; show installed/discovered
plugins that are not active as a neutral "Installed (not active)" inventory
line rather than an error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(status): include pinned runtime registries in loaded ids

Build runtimeLoadedPluginIds from all live runtime registry surfaces via
collectLivePluginRegistries() (active plus any pinned channel / http-route /
session-extension registry) and render Loaded: from that id set directly, so a
plugin live only through a pinned surface still counts as loaded instead of
being dropped or misreported as "Installed (not active)".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Masato Hoshino
2026-06-28 10:44:56 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent c0883a531d
commit 949b1af433
5 changed files with 222 additions and 6 deletions
+19
View File
@@ -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;
@@ -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",
]);
});
});
+10 -1
View File
@@ -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,
};
}
+73
View File
@@ -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):");
});
});
+34 -4
View File
@@ -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}`,