diff --git a/src/plugins/plugin-metadata-snapshot.test.ts b/src/plugins/plugin-metadata-snapshot.test.ts index a423af5e2c20..5e64f0c86edf 100644 --- a/src/plugins/plugin-metadata-snapshot.test.ts +++ b/src/plugins/plugin-metadata-snapshot.test.ts @@ -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", () => { diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index 35e3c15ce09d..bfa698705400 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -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(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,