diff --git a/CHANGELOG.md b/CHANGELOG.md index e390f550689..a29b00f9eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,6 @@ Docs: https://docs.openclaw.ai ### Changes -- Package Acceptance: expand published upgrade-survivor coverage across release-history baselines and reported-issue scenarios, including Gateway `/healthz` and `/readyz` probes, so stale plugin runtime-deps upgrade regressions are caught before release. Thanks @vincentkoc. - Voice Call/Google Meet: add Twilio Meet join phase logs around pre-connect DTMF, realtime stream setup, and initial greeting handoff for easier live-call debugging. Thanks @donkeykong91 and @PfanP. - macOS app: move recent session context rows into a Context submenu while keeping usage and cost details root-level, so the menu bar companion stays compact with many active sessions. Thanks @guti. - Gateway/SDK: add SDK-facing tools.invoke RPC with shared HTTP policy, typed approval/refusal results, and SDK helper support. Refs #74705. Thanks @BunsDev and @ai-hpc. @@ -17,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/runtime-deps: prune legacy version-scoped plugin runtime-deps roots during bundled dependency repair and cover the path in Package Acceptance's upgrade-survivor matrix, so upgrades from 2026.4.x no longer leave stale per-plugin runtime trees after doctor runs. Thanks @vincentkoc. - Google Meet: interrupt Realtime provider output when local barge-in clears playback, so command-pair audio stops model speech instead of only restarting Chrome playback. Fixes #73850. (#73834) Thanks @shhtheonlyperson. - Gateway/config: cap oversized plugin-owned schemas in the full `config.schema` response so large installed plugin sets cannot balloon Gateway RSS or crash schema clients. Thanks @vincentkoc. - Gateway/sessions: use bounded tail reads for sessions-list transcript usage fallbacks and cap bulk title/last-message hydration, keeping large session stores responsive when rows request derived previews. Thanks @vincentkoc. diff --git a/src/plugins/bundled-runtime-deps-roots.ts b/src/plugins/bundled-runtime-deps-roots.ts index f9187864ea1..d37a7fb7f77 100644 --- a/src/plugins/bundled-runtime-deps-roots.ts +++ b/src/plugins/bundled-runtime-deps-roots.ts @@ -13,6 +13,8 @@ import { const DEFAULT_UNKNOWN_RUNTIME_DEPS_ROOTS_TO_KEEP = 20; const DEFAULT_UNKNOWN_RUNTIME_DEPS_MIN_AGE_MS = 10 * 60_000; const PACKAGE_KEY_PATH_HASH_RE = /^openclaw-.+-([0-9a-f]{12})$/u; +const LEGACY_VERSIONED_RUNTIME_DEPS_ROOT_RE = + /^openclaw-\d{4}\.\d+\.\d+(?:-[0-9A-Za-z.]+)*-[A-Za-z][A-Za-z0-9_-]*$/u; export type BundledRuntimeDepsInstallRootPlan = { installRoot: string; @@ -143,6 +145,19 @@ export function pruneUnknownBundledRuntimeDepsRoots( let scanned = 0; let removed = 0; let skippedLocked = 0; + const removeRoot = (root: string): void => { + const lockDir = path.join(root, BUNDLED_RUNTIME_DEPS_LOCK_DIR); + if (fs.existsSync(lockDir) && !removeRuntimeDepsLockIfStale(lockDir, nowMs)) { + skippedLocked += 1; + return; + } + try { + fs.rmSync(root, { recursive: true, force: true }); + removed += 1; + } catch (error) { + params.warn?.(`failed to remove stale bundled runtime deps root ${root}: ${String(error)}`); + } + }; for (const baseDir of resolveBundledRuntimeDepsExternalBaseDirs(env)) { let entries: fs.Dirent[]; @@ -163,32 +178,39 @@ export function pruneUnknownBundledRuntimeDepsRoots( }) .filter((entry): entry is { root: string; mtimeMs: number } => entry !== null) .toSorted((left, right) => right.mtimeMs - left.mtimeMs); + const legacyVersionedRoots = entries + .filter( + (entry) => entry.isDirectory() && isLegacyVersionedBundledRuntimeDepsRootName(entry.name), + ) + .map((entry) => path.join(baseDir, entry.name)) + .toSorted((left, right) => left.localeCompare(right)); scanned += unknownRoots.length; + scanned += legacyVersionedRoots.length; for (const [index, entry] of unknownRoots.entries()) { const ageMs = nowMs - entry.mtimeMs; if (index < maxRootsToKeep && ageMs < minAgeMs) { continue; } - const lockDir = path.join(entry.root, BUNDLED_RUNTIME_DEPS_LOCK_DIR); - if (fs.existsSync(lockDir) && !removeRuntimeDepsLockIfStale(lockDir, nowMs)) { - skippedLocked += 1; - continue; - } - try { - fs.rmSync(entry.root, { recursive: true, force: true }); - removed += 1; - } catch (error) { - params.warn?.( - `failed to remove stale bundled runtime deps root ${entry.root}: ${String(error)}`, - ); - } + removeRoot(entry.root); + } + + for (const root of legacyVersionedRoots) { + removeRoot(root); } } return { scanned, removed, skippedLocked }; } +function isLegacyVersionedBundledRuntimeDepsRootName(name: string): boolean { + return ( + name.startsWith("openclaw-") && + readPackageKeyPathHash(name) === null && + LEGACY_VERSIONED_RUNTIME_DEPS_ROOT_RE.test(name) + ); +} + export function listSiblingExternalBundledRuntimeDepsRoots(params: { installRoot: string; env?: NodeJS.ProcessEnv; diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index af5c5ccaef9..e405ca1c440 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -3418,7 +3418,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { }, ); - it("prunes stale unknown external runtime roots while keeping newest and locked roots", () => { + it("prunes stale unknown and legacy versioned external runtime roots", () => { const stageDir = makeTempDir(); const nowMs = Date.parse("2026-04-29T08:00:00.000Z"); const makeRoot = (name: string, ageMs: number, locked = false) => { @@ -3440,7 +3440,9 @@ describe("ensureBundledPluginRuntimeDeps", () => { const newest = makeRoot("openclaw-unknown-newest", 1_000); const stale = makeRoot("openclaw-unknown-stale", 120_000); const locked = makeRoot("openclaw-unknown-locked", 120_000, true); - const versioned = makeRoot("openclaw-2026.4.25-versioned", 120_000); + const legacyVersioned = makeRoot("openclaw-2026.4.25-discord", 1_000); + const lockedLegacyVersioned = makeRoot("openclaw-2026.4.25-telegram", 1_000, true); + const modernVersioned = makeRoot("openclaw-2026.4.25-abcdef123456", 120_000); const result = pruneUnknownBundledRuntimeDepsRoots({ env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, @@ -3449,11 +3451,13 @@ describe("ensureBundledPluginRuntimeDeps", () => { minAgeMs: 60_000, }); - expect(result).toEqual({ scanned: 3, removed: 1, skippedLocked: 1 }); + expect(result).toEqual({ scanned: 5, removed: 2, skippedLocked: 2 }); expect(fs.existsSync(newest)).toBe(true); expect(fs.existsSync(stale)).toBe(false); expect(fs.existsSync(locked)).toBe(true); - expect(fs.existsSync(versioned)).toBe(true); + expect(fs.existsSync(legacyVersioned)).toBe(false); + expect(fs.existsSync(lockedLegacyVersioned)).toBe(true); + expect(fs.existsSync(modernVersioned)).toBe(true); }); it("uses the plugin-local stage for source-checkout runtime deps", () => { diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 9d7ea8aea03..656231383a3 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -25,6 +25,7 @@ import { import { isSourceCheckoutRoot, listSiblingExternalBundledRuntimeDepsRoots, + pruneUnknownBundledRuntimeDepsRoots, resolveBundledRuntimeDependencyInstallRootPlan, resolveBundledRuntimeDependencyPackageInstallRootPlan, resolveBundledRuntimeDependencyPackageRoot, @@ -397,6 +398,10 @@ export async function repairBundledRuntimeDepsPackagePlanAsync(params: { onProgress?: (message: string) => void; warn?: (message: string) => void; }): Promise { + pruneUnknownBundledRuntimeDepsRoots({ + env: params.env, + ...(params.warn ? { warn: params.warn } : {}), + }); const plan = createBundledRuntimeDepsPackagePlan(params); if (plan.missingSpecs.length === 0) { return { plan, repairedSpecs: [] };