diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index b4be20dfae4..12211f9b29b 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -8,6 +8,8 @@ import { } from "./runtime-postbuild-shared.mjs"; const GENERATED_BUNDLED_SKILLS_DIR = "bundled-skills"; +const TRANSIENT_COPY_ERROR_CODES = new Set(["EEXIST", "ENOENT", "ENOTEMPTY", "EBUSY"]); +const COPY_RETRY_DELAYS_MS = [10, 25, 50]; export function rewritePackageExtensions(entries) { if (!Array.isArray(entries)) { @@ -82,6 +84,39 @@ function resolveBundledSkillTarget(rawPath) { }; } +function isTransientCopyError(error) { + return ( + !!error && + typeof error === "object" && + typeof error.code === "string" && + TRANSIENT_COPY_ERROR_CODES.has(error.code) + ); +} + +function sleepSync(ms) { + if (!Number.isFinite(ms) || ms <= 0) { + return; + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function copySkillPathWithRetry(params) { + const maxAttempts = COPY_RETRY_DELAYS_MS.length + 1; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + removePathIfExists(params.targetPath); + fs.mkdirSync(path.dirname(params.targetPath), { recursive: true }); + fs.cpSync(params.sourcePath, params.targetPath, params.copyOptions); + return; + } catch (error) { + if (!isTransientCopyError(error) || attempt === maxAttempts - 1) { + throw error; + } + sleepSync(COPY_RETRY_DELAYS_MS[attempt] ?? 0); + } + } +} + function copyDeclaredPluginSkillPaths(params) { const skills = Array.isArray(params.manifest.skills) ? params.manifest.skills : []; const copiedSkills = []; @@ -104,21 +139,23 @@ function copyDeclaredPluginSkillPaths(params) { continue; } const targetPath = ensurePathInsideRoot(params.distPluginDir, target.outputPath); - removePathIfExists(targetPath); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); const shouldExcludeNestedNodeModules = /^node_modules(?:\/|$)/u.test( normalizeManifestRelativePath(raw), ); - fs.cpSync(sourcePath, targetPath, { - dereference: true, - force: true, - recursive: true, - filter: (candidatePath) => { - if (!shouldExcludeNestedNodeModules || candidatePath === sourcePath) { - return true; - } - const relativeCandidate = path.relative(sourcePath, candidatePath).replaceAll("\\", "/"); - return !relativeCandidate.split("/").includes("node_modules"); + copySkillPathWithRetry({ + sourcePath, + targetPath, + copyOptions: { + dereference: true, + force: true, + recursive: true, + filter: (candidatePath) => { + if (!shouldExcludeNestedNodeModules || candidatePath === sourcePath) { + return true; + } + const relativeCandidate = path.relative(sourcePath, candidatePath).replaceAll("\\", "/"); + return !relativeCandidate.split("/").includes("node_modules"); + }, }, }); copiedSkills.push(target.manifestPath); diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 8f4187a8937..48fe75cf02b 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { copyBundledPluginMetadata, rewritePackageExtensions, @@ -237,6 +237,47 @@ describe("copyBundledPluginMetadata", () => { expect(fs.existsSync(staleNodeModulesDir)).toBe(false); }); + it("retries transient skill copy races from concurrent runtime postbuilds", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-retry-"); + const pluginDir = path.join(repoRoot, "extensions", "diffs"); + fs.mkdirSync(path.join(pluginDir, "skills", "diffs"), { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "skills", "diffs", "SKILL.md"), "# Diffs\n", "utf8"); + writeJson(path.join(pluginDir, "openclaw.plugin.json"), { + id: "diffs", + configSchema: { type: "object" }, + skills: ["./skills"], + }); + writeJson(path.join(pluginDir, "package.json"), { + name: "@openclaw/diffs", + openclaw: { extensions: ["./index.ts"] }, + }); + + const realCpSync = fs.cpSync.bind(fs); + let attempts = 0; + const cpSyncSpy = vi.spyOn(fs, "cpSync").mockImplementation((...args) => { + attempts += 1; + if (attempts === 1) { + const error = Object.assign(new Error("race"), { code: "EEXIST" }); + throw error; + } + return realCpSync(...args); + }); + + try { + copyBundledPluginMetadata({ repoRoot }); + } finally { + cpSyncSpy.mockRestore(); + } + + expect(attempts).toBe(2); + expect( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", "diffs", "skills", "diffs", "SKILL.md"), + "utf8", + ), + ).toContain("Diffs"); + }); + it("removes generated outputs for plugins no longer present in source", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-removed-"); const staleBundledSkillDir = path.join(