fix: refresh plugin runtime mirrors in place

This commit is contained in:
Shakker
2026-04-28 10:42:27 +01:00
parent 7727e102a5
commit 92016b82ae
3 changed files with 131 additions and 25 deletions

View File

@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
- Plugins/startup: treat manifestless Claude bundles as valid installed-plugin registry entries instead of stale missing manifests, so workspace bundles no longer force repeated derived registry rebuilds or noisy `plugins.entries.workspace` warnings during Gateway startup. Fixes #73433. Thanks @AnneVoss.
- Agents/subagents: preserve `sessions_yield` as a paused subagent state and ignore its wait text while freezing completion output, so parent sessions wait for the final post-compaction answer instead of receiving intermediate progress or `(no output)`. Fixes #73413. Thanks @Ask-sola.
- Plugins/startup: precompute bundled runtime mirror fingerprints before taking the mirror lock and keep Docker bundled plugin runtime deps/mirrors in a Docker-managed volume instead of the Windows/WSL config bind mount, so cold starts avoid slow host-volume mirror writes. Fixes #73339. Thanks @1yihui.
- Plugins/runtime deps: refresh bundled runtime mirrors without deleting active import trees, so config-triggered restarts do not see transient missing plugin files during registration. Thanks @shakkernerd.
- Channels/LINE: persist inbound image, video, audio, and file downloads in `~/.openclaw/media/inbound/` instead of temporary files so agents can still read LINE media after `/tmp` cleanup. Fixes #73370. Thanks @hijirii and @wenxu007.
- CLI/plugins: keep bundled plugin installs out of `plugins.load.paths` while preserving install records, so install/inspect/doctor loops no longer warn about the current bundled plugin directory. Thanks @vincentkoc.
- CLI/plugins: scope `plugins inspect <id>` runtime loading to the matched plugin so single-plugin inspection does not load every plugin before checking the target. Thanks @shakkernerd.

View File

@@ -0,0 +1,54 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { refreshBundledPluginRuntimeMirrorRoot } from "./bundled-runtime-mirror.js";
const tempRoots: string[] = [];
function makeTempRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-runtime-mirror-"));
tempRoots.push(root);
return root;
}
afterEach(() => {
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
describe("refreshBundledPluginRuntimeMirrorRoot", () => {
it("refreshes stale mirrors without deleting the active target root", () => {
const root = makeTempRoot();
const sourceRoot = path.join(root, "source");
const targetRoot = path.join(root, "target");
fs.mkdirSync(sourceRoot, { recursive: true });
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(path.join(sourceRoot, "index.js"), "export const value = 'v1';\n", "utf8");
expect(
refreshBundledPluginRuntimeMirrorRoot({
pluginId: "demo",
sourceRoot,
targetRoot,
}),
).toBe(true);
fs.writeFileSync(path.join(sourceRoot, "index.js"), "export const value = 'v2';\n", "utf8");
fs.writeFileSync(path.join(targetRoot, "inflight-import.js"), "still readable\n", "utf8");
expect(
refreshBundledPluginRuntimeMirrorRoot({
pluginId: "demo",
sourceRoot,
targetRoot,
}),
).toBe(true);
expect(fs.readFileSync(path.join(targetRoot, "index.js"), "utf8")).toContain("v2");
expect(fs.readFileSync(path.join(targetRoot, "inflight-import.js"), "utf8")).toBe(
"still readable\n",
);
});
});

View File

@@ -31,22 +31,9 @@ export function refreshBundledPluginRuntimeMirrorRoot(params: {
if (isBundledRuntimeMirrorRootFresh(params.targetRoot, metadata)) {
return false;
}
const tempDir = fs.mkdtempSync(
path.join(
params.tempDirParent ?? path.dirname(params.targetRoot),
`.plugin-${sanitizeBundledRuntimeMirrorTempId(params.pluginId)}-`,
),
);
const stagedRoot = path.join(tempDir, "plugin");
try {
copyBundledPluginRuntimeRoot(params.sourceRoot, stagedRoot);
writeBundledRuntimeMirrorMetadata(stagedRoot, metadata);
fs.rmSync(params.targetRoot, { recursive: true, force: true });
fs.renameSync(stagedRoot, params.targetRoot);
return true;
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
copyBundledPluginRuntimeRoot(params.sourceRoot, params.targetRoot);
writeBundledRuntimeMirrorMetadata(params.targetRoot, metadata);
return true;
}
export function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: string): void {
@@ -54,24 +41,29 @@ export function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: str
return;
}
fs.mkdirSync(targetRoot, { recursive: true, mode: 0o755 });
const mirroredNames = new Set<string>();
for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) {
if (shouldIgnoreBundledRuntimeMirrorEntry(entry.name)) {
continue;
}
if (!entry.isDirectory() && !entry.isSymbolicLink() && !entry.isFile()) {
continue;
}
mirroredNames.add(entry.name);
const sourcePath = path.join(sourceRoot, entry.name);
const targetPath = path.join(targetRoot, entry.name);
if (entry.isDirectory()) {
removeBundledRuntimeMirrorPathIfTypeChanged(targetPath, "directory");
copyBundledPluginRuntimeRoot(sourcePath, targetPath);
continue;
}
if (entry.isSymbolicLink()) {
fs.symlinkSync(fs.readlinkSync(sourcePath), targetPath);
removeBundledRuntimeMirrorPathIfTypeChanged(targetPath, "symlink");
replaceBundledRuntimeMirrorSymlinkAtomic(fs.readlinkSync(sourcePath), targetPath);
continue;
}
if (!entry.isFile()) {
continue;
}
fs.copyFileSync(sourcePath, targetPath);
removeBundledRuntimeMirrorPathIfTypeChanged(targetPath, "file");
copyBundledRuntimeMirrorFileAtomic(sourcePath, targetPath);
try {
const sourceMode = fs.statSync(sourcePath).mode;
fs.chmodSync(targetPath, sourceMode | 0o600);
@@ -79,6 +71,69 @@ export function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: str
// Readable copied files are enough for plugin loading.
}
}
pruneStaleBundledRuntimeMirrorEntries(targetRoot, mirroredNames);
}
function pruneStaleBundledRuntimeMirrorEntries(targetRoot: string, mirroredNames: Set<string>): void {
for (const entry of fs.readdirSync(targetRoot, { withFileTypes: true })) {
if (shouldIgnoreBundledRuntimeMirrorEntry(entry.name)) {
continue;
}
if (mirroredNames.has(entry.name)) {
continue;
}
fs.rmSync(path.join(targetRoot, entry.name), { recursive: true, force: true });
}
}
function removeBundledRuntimeMirrorPathIfTypeChanged(
targetPath: string,
expectedType: "directory" | "file" | "symlink",
): void {
let stat: fs.Stats;
try {
stat = fs.lstatSync(targetPath);
} catch {
return;
}
const matches =
expectedType === "directory"
? stat.isDirectory()
: expectedType === "symlink"
? stat.isSymbolicLink()
: stat.isFile();
if (!matches) {
fs.rmSync(targetPath, { recursive: true, force: true });
}
}
function replaceBundledRuntimeMirrorSymlinkAtomic(linkTarget: string, targetPath: string): void {
fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o755 });
const tempPath = createBundledRuntimeMirrorTempPath(targetPath);
try {
fs.symlinkSync(linkTarget, tempPath);
fs.renameSync(tempPath, targetPath);
} finally {
fs.rmSync(tempPath, { force: true });
}
}
function copyBundledRuntimeMirrorFileAtomic(sourcePath: string, targetPath: string): void {
fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o755 });
const tempPath = createBundledRuntimeMirrorTempPath(targetPath);
try {
fs.copyFileSync(sourcePath, tempPath);
fs.renameSync(tempPath, targetPath);
} finally {
fs.rmSync(tempPath, { force: true });
}
}
function createBundledRuntimeMirrorTempPath(targetPath: string): string {
return path.join(
path.dirname(targetPath),
`.openclaw-mirror-${process.pid}-${process.hrtime.bigint()}-${path.basename(targetPath)}.tmp`,
);
}
export function precomputeBundledRuntimeMirrorMetadata(params: {
@@ -235,7 +290,3 @@ function resolveBundledRuntimeMirrorSourceRootId(sourceRoot: string): string {
function shouldIgnoreBundledRuntimeMirrorEntry(name: string): boolean {
return name === "node_modules" || name === BUNDLED_RUNTIME_MIRROR_METADATA_FILE;
}
function sanitizeBundledRuntimeMirrorTempId(pluginId: string): string {
return pluginId.replaceAll(/[^a-zA-Z0-9._-]/g, "_");
}