mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user