fix(plugins): restore caller-owned workspace plugin-root projection

#124844 made resolvePluginMetadataSnapshot synthesize workspacePluginRootPresent
itself with a process-memoized fs.existsSync probe whenever a caller did not
supply it. That fact is owned by the prepared-model-runtime lease, which resolves
it only for real agent runs (resolveWorkspacePluginRootPresence) and passes it
explicitly, so the probe silently turned an opt-in fast path into the default for
unrelated control-plane callers.

Gateway startup config validation is one of them. It never asserts the fact, so it
began projecting the published lifecycle graph instead of loading a fresh one, and
the projection derives configFingerprint from that graph rather than a real load.
Startup convergence rewrites the persisted plugin index between the two reads that
form the migration checkpoint identity, so the pre-convergence read projected while
the post-convergence read loaded. The two pluginMigrationFingerprint values differed
and the gateway refused readiness with "OpenClaw plugin migration inputs changed
during startup convergence", failing every config-patch restart.

Deleting the probe restores one owner for the fact and a net-negative production
diff. The agent-side hunks of #124844 are untouched: they still pass the fact
explicitly from the lease.

Root cause: admission fact manufactured outside its lifecycle owner.
Owner boundary: src/agents/prepared-model-runtime-lease.ts owns workspace
plugin-root presence; src/plugins/plugin-metadata-snapshot.ts only consumes it.
Production LOC: -18.

Fixes red main ci-gate: QA Smoke memory-dreaming-sweep, matrix-restart-resume,
matrix-post-restart-room-continue.
This commit is contained in:
Peter Steinberger
2026-08-16 17:17:58 -07:00
parent 3d152540e3
commit f7b372a49f
2 changed files with 54 additions and 33 deletions
+49 -11
View File
@@ -1,7 +1,4 @@
// Verifies lifecycle snapshot loading, ownership facts, and immutable boundaries.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import type { PluginDiscoveryResult } from "./discovery.js";
@@ -190,6 +187,7 @@ describe("plugin metadata snapshot", () => {
config,
env: {},
workspaceDir: targetWorkspace,
workspacePluginRootPresent: false,
});
expect(resolved).not.toBe(source);
@@ -206,15 +204,55 @@ describe("plugin metadata snapshot", () => {
workspacePluginRootPresent: true,
});
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
});
const pluginWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-workspace-"));
try {
fs.mkdirSync(path.join(pluginWorkspace, ".openclaw", "extensions"), { recursive: true });
resolvePluginMetadataSnapshot({ config, env: {}, workspaceDir: pluginWorkspace });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2);
} finally {
fs.rmSync(pluginWorkspace, { force: true, recursive: true });
}
it("loads a fresh graph when no caller asserted workspace plugin-root absence", () => {
// Startup config validation never resolves workspace plugin-root presence, so it must keep
// loading. Projecting the published graph instead would serve whatever inventory happened to
// be current, and startup convergence rewrites that inventory between the two reads that
// form the migration checkpoint identity — the gateway then refuses to report ready.
const config = {};
const sourceWorkspace = "/workspace/source";
const targetWorkspace = "/workspace/target";
const staleIndex = makeIndex("stale");
staleIndex.policyHash = resolveInstalledPluginIndexPolicyHash(config);
staleIndex.workspaceDir = sourceWorkspace;
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "provided",
snapshot: staleIndex,
diagnostics: [],
});
loadPluginManifestRegistryForInstalledIndex.mockReturnValue(makeManifestRegistry("stale"));
const stale = loadPluginMetadataSnapshot({
config,
env: {},
index: staleIndex,
workspaceDir: sourceWorkspace,
});
setCurrentPluginMetadataSnapshot(stale, { config, env: {}, workspaceDir: sourceWorkspace });
// Convergence replaced the persisted inventory; a fresh load now sees a different graph.
const freshIndex = makeIndex("fresh");
freshIndex.policyHash = resolveInstalledPluginIndexPolicyHash(config);
freshIndex.workspaceDir = targetWorkspace;
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "provided",
snapshot: freshIndex,
diagnostics: [],
});
loadPluginManifestRegistryForInstalledIndex.mockReturnValue(makeManifestRegistry("fresh"));
const resolved = resolvePluginMetadataSnapshot({
config,
env: {},
workspaceDir: targetWorkspace,
});
expect(resolved.index.plugins.map((plugin) => plugin.pluginId)).toEqual(["fresh"]);
expect(resolved.configFingerprint).toBe(
loadPluginMetadataSnapshot({ config, env: {}, workspaceDir: targetWorkspace })
.configFingerprint,
);
});
it("rewalks collection-bearing manifest graphs after prototype mutation", () => {
+5 -22
View File
@@ -1,5 +1,3 @@
import fs from "node:fs";
import path from "node:path";
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
@@ -16,9 +14,7 @@ import {
resolveInstalledManifestRegistryIndexFingerprint,
} from "./manifest-registry-installed.js";
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
import { PluginLruCache } from "./plugin-cache-primitives.js";
import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
import { buildPluginMetadataProviderFacts } from "./plugin-metadata-provider-facts.js";
import { registerPluginMetadataSnapshotReaders } from "./plugin-metadata-snapshot.runtime.js";
import type {
@@ -45,8 +41,6 @@ const PLUGIN_METADATA_ENV_KEYS = [
"USERPROFILE",
"XDG_CONFIG_HOME",
] as const;
const workspacePluginRootPresence = new PluginLruCache<boolean>(128);
registerPluginMetadataProcessMemoLifecycleClear(() => workspacePluginRootPresence.clear());
export type {
PluginMetadataSnapshot,
PluginMetadataSnapshotOwnerMaps,
@@ -68,18 +62,6 @@ export function resolvePluginMetadataEnvFingerprint(env: NodeJS.ProcessEnv): str
});
}
function hasWorkspacePluginRoot(workspaceDir: string): boolean {
const cached = workspacePluginRootPresence.getResult(workspaceDir);
if (cached.hit) {
return cached.value;
}
// Plugin metadata is lifecycle-stable. Resolve this admission fact once per workspace so
// configless nested readers can reuse the prepared graph without freshness-polling the disk.
const present = fs.existsSync(path.join(workspaceDir, ".openclaw", "extensions"));
workspacePluginRootPresence.set(workspaceDir, present);
return present;
}
function throwReadonlyPluginMetadataMutation(): never {
throw new TypeError("Plugin metadata snapshots are immutable");
}
@@ -414,17 +396,18 @@ export function resolvePluginMetadataSnapshot(
const hasWorkspacePlugin = lifecycleSnapshot?.index.plugins.some(
(plugin) => plugin.origin === "workspace",
);
const workspacePluginRootPresent =
params.workspacePluginRootPresent ??
(targetWorkspace ? hasWorkspacePluginRoot(targetWorkspace) : undefined);
// Gateway metadata is lifecycle-stable. A workspace with no plugin root can reuse the
// published graph without polling every bundled/global artifact on its first turn.
// Only the run owner that resolved workspace plugin-root presence may claim it: this
// projection derives configFingerprint from the published graph instead of a real load,
// so synthesizing the fact here would make startup-migration identity depend on whether
// a lifecycle snapshot happened to be published at that moment.
if (
lifecycleSnapshot &&
targetWorkspace &&
targetWorkspace !== lifecycleSnapshot.workspaceDir &&
!hasWorkspacePlugin &&
workspacePluginRootPresent === false
params.workspacePluginRootPresent === false
) {
return projectPluginMetadataSnapshotWorkspace({
snapshot: lifecycleSnapshot,