From e7c131d6de928c11d71b832506490e64b1d8cedf Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 01:24:56 +0100 Subject: [PATCH] fix: roll back plugin index for update channel writes --- CHANGELOG.md | 1 + src/cli/plugins-install-record-commit.ts | 7 ++-- src/cli/update-cli/update-command.ts | 11 +++--- src/commands/channels/add.ts | 45 +++++++++++++----------- src/commands/channels/capabilities.ts | 27 +++++++------- src/commands/channels/remove.ts | 27 +++++++------- src/commands/channels/resolve.ts | 27 +++++++------- 7 files changed, 78 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03ab92a0462..151a39f78bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai image models keep their `input: ["text", "image"]` capability. Fixes #33185. Thanks @Kobe9312 and @vincentkoc. - Plugins/install: restore the previous plugin index records if a concurrent config write conflict interrupts install, update, or uninstall metadata commits. Thanks @shakkernerd. +- Plugins/update: restore previous plugin index records if core update or channel setup hits a concurrent config write conflict after plugin metadata changes. Thanks @shakkernerd. - Sessions: keep embedded runtime context out of the visible user prompt by sending it as a hidden next-turn custom message, and teach doctor to repair affected 2026.4.24 transcripts with duplicated prompt-rewrite branches. diff --git a/src/cli/plugins-install-record-commit.ts b/src/cli/plugins-install-record-commit.ts index cf143675062..65de5144bf0 100644 --- a/src/cli/plugins-install-record-commit.ts +++ b/src/cli/plugins-install-record-commit.ts @@ -2,16 +2,19 @@ import { replaceConfigFile } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { + loadInstalledPluginIndexInstallRecords, PLUGIN_INSTALLS_CONFIG_PATH, writePersistedInstalledPluginIndexInstallRecords, } from "../plugins/installed-plugin-index-records.js"; export async function commitPluginInstallRecordsWithConfig(params: { - previousInstallRecords: Record; + previousInstallRecords?: Record; nextInstallRecords: Record; nextConfig: OpenClawConfig; baseHash?: string; }): Promise { + const previousInstallRecords = + params.previousInstallRecords ?? (await loadInstalledPluginIndexInstallRecords()); await writePersistedInstalledPluginIndexInstallRecords(params.nextInstallRecords); try { await replaceConfigFile({ @@ -21,7 +24,7 @@ export async function commitPluginInstallRecordsWithConfig(params: { }); } catch (error) { try { - await writePersistedInstalledPluginIndexInstallRecords(params.previousInstallRecords); + await writePersistedInstalledPluginIndexInstallRecords(previousInstallRecords); } catch (rollbackError) { throw new Error( "Failed to commit plugin install records and could not restore the previous plugin index", diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index cdd68489465..a4487b7a03a 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -44,9 +44,7 @@ import { import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js"; import { loadInstalledPluginIndexInstallRecords, - PLUGIN_INSTALLS_CONFIG_PATH, withoutPluginInstallRecords, - writePersistedInstalledPluginIndexInstallRecords, withPluginInstallRecords, } from "../../plugins/installed-plugin-index-records.js"; import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../../plugins/update.js"; @@ -64,6 +62,7 @@ import { terminateStaleGatewayPids, waitForGatewayHealthyRestart, } from "../daemon-cli/restart-health.js"; +import { commitPluginInstallRecordsWithConfig } from "../plugins-install-record-commit.js"; import { listPersistedBundledPluginLocationBridges } from "../plugins-location-bridges.js"; import { refreshPluginRegistryAfterConfigMutation } from "../plugins-registry-refresh.js"; import { createUpdateProgress, printResult } from "./progress.js"; @@ -628,17 +627,19 @@ async function updatePluginsAfterCoreUpdate(params: { pluginConfig = npmResult.config; if (syncResult.changed || npmResult.changed) { - await writePersistedInstalledPluginIndexInstallRecords(pluginConfig.plugins?.installs ?? {}); + const nextInstallRecords = pluginConfig.plugins?.installs ?? {}; const nextConfig = withoutPluginInstallRecords(pluginConfig); - await replaceConfigFile({ + await commitPluginInstallRecordsWithConfig({ + previousInstallRecords: pluginInstallRecords, + nextInstallRecords, nextConfig, baseHash: params.configSnapshot.hash, - writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] }, }); await refreshPluginRegistryAfterConfigMutation({ config: nextConfig, reason: "source-changed", workspaceDir: params.root, + installRecords: nextInstallRecords, logger: pluginLogger, }); } diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 507512a2b9f..426d40f5471 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -5,13 +5,10 @@ import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/ import type { ChannelSetupPlugin } from "../../channels/plugins/setup-wizard-types.js"; import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.public.js"; +import { commitPluginInstallRecordsWithConfig } from "../../cli/plugins-install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js"; -import { - PLUGIN_INSTALLS_CONFIG_PATH, - withoutPluginInstallRecords, - writePersistedInstalledPluginIndexInstallRecords, -} from "../../plugins/installed-plugin-index-records.js"; +import { withoutPluginInstallRecords } from "../../plugins/installed-plugin-index-records.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; @@ -251,19 +248,22 @@ export async function channelsAddCommand( ? withoutPluginInstallRecords(nextConfig) : nextConfig; if (shouldMovePluginInstalls) { - await writePersistedInstalledPluginIndexInstallRecords(nextConfig.plugins?.installs ?? {}); + await commitPluginInstallRecordsWithConfig({ + nextInstallRecords: nextConfig.plugins?.installs ?? {}, + nextConfig: writtenConfig, + ...(baseHash !== undefined ? { baseHash } : {}), + }); + } else { + await replaceConfigFile({ + nextConfig: writtenConfig, + ...(baseHash !== undefined ? { baseHash } : {}), + }); } - await replaceConfigFile({ - nextConfig: writtenConfig, - ...(baseHash !== undefined ? { baseHash } : {}), - ...(shouldMovePluginInstalls - ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } - : {}), - }); if (shouldMovePluginInstalls) { await refreshPluginRegistryAfterConfigMutation({ config: writtenConfig, reason: "source-changed", + installRecords: nextConfig.plugins?.installs ?? {}, logger: { warn: (message) => runtime.log(message) }, }); } @@ -402,19 +402,22 @@ export async function channelsAddCommand( ? withoutPluginInstallRecords(nextConfig) : nextConfig; if (shouldMovePluginInstalls) { - await writePersistedInstalledPluginIndexInstallRecords(nextConfig.plugins?.installs ?? {}); + await commitPluginInstallRecordsWithConfig({ + nextInstallRecords: nextConfig.plugins?.installs ?? {}, + nextConfig: writtenConfig, + ...(baseHash !== undefined ? { baseHash } : {}), + }); + } else { + await replaceConfigFile({ + nextConfig: writtenConfig, + ...(baseHash !== undefined ? { baseHash } : {}), + }); } - await replaceConfigFile({ - nextConfig: writtenConfig, - ...(baseHash !== undefined ? { baseHash } : {}), - ...(shouldMovePluginInstalls - ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } - : {}), - }); if (shouldMovePluginInstalls || pluginRegistrySourceChanged) { await refreshPluginRegistryAfterConfigMutation({ config: writtenConfig, reason: "source-changed", + ...(shouldMovePluginInstalls ? { installRecords: nextConfig.plugins?.installs ?? {} } : {}), logger: { warn: (message) => runtime.log(message) }, }); } diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index c1bc327f6d3..91e1df2a581 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -10,6 +10,7 @@ import type { ChannelCapabilitiesDisplayLine, ChannelPlugin, } from "../../channels/plugins/types.public.js"; +import { commitPluginInstallRecordsWithConfig } from "../../cli/plugins-install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { readConfigFileSnapshot, @@ -18,11 +19,7 @@ import { } from "../../config/config.js"; import { danger } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import { - PLUGIN_INSTALLS_CONFIG_PATH, - withoutPluginInstallRecords, - writePersistedInstalledPluginIndexInstallRecords, -} from "../../plugins/installed-plugin-index-records.js"; +import { withoutPluginInstallRecords } from "../../plugins/installed-plugin-index-records.js"; import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { normalizeLowercaseStringOrEmpty, @@ -259,21 +256,25 @@ export async function channelsCapabilitiesCommand( const shouldMovePluginInstalls = Boolean( cfg.plugins?.installs && Object.keys(cfg.plugins.installs).length > 0, ); + const nextInstallRecords = cfg.plugins?.installs ?? {}; if (shouldMovePluginInstalls) { - await writePersistedInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); cfg = withoutPluginInstallRecords(cfg); + await commitPluginInstallRecordsWithConfig({ + nextInstallRecords, + nextConfig: cfg, + baseHash: (await sourceSnapshotPromise)?.hash, + }); + } else { + await replaceConfigFile({ + nextConfig: cfg, + baseHash: (await sourceSnapshotPromise)?.hash, + }); } - await replaceConfigFile({ - nextConfig: cfg, - baseHash: (await sourceSnapshotPromise)?.hash, - ...(shouldMovePluginInstalls - ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } - : {}), - }); if (shouldMovePluginInstalls || resolved.pluginInstalled) { await refreshPluginRegistryAfterConfigMutation({ config: cfg, reason: "source-changed", + ...(shouldMovePluginInstalls ? { installRecords: nextInstallRecords } : {}), logger: { warn: (message) => runtime.log(message) }, }); } diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index 6b57817c9cf..44a92588e67 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -4,13 +4,10 @@ import { listChannelPlugins, normalizeChannelId, } from "../../channels/plugins/index.js"; +import { commitPluginInstallRecordsWithConfig } from "../../cli/plugins-install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js"; -import { - PLUGIN_INSTALLS_CONFIG_PATH, - withoutPluginInstallRecords, - writePersistedInstalledPluginIndexInstallRecords, -} from "../../plugins/installed-plugin-index-records.js"; +import { withoutPluginInstallRecords } from "../../plugins/installed-plugin-index-records.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -180,21 +177,25 @@ export async function channelsRemoveCommand( const shouldMovePluginInstalls = Boolean( next.plugins?.installs && Object.keys(next.plugins.installs).length > 0, ); + const nextInstallRecords = next.plugins?.installs ?? {}; if (shouldMovePluginInstalls) { - await writePersistedInstalledPluginIndexInstallRecords(next.plugins?.installs ?? {}); next = withoutPluginInstallRecords(next); + await commitPluginInstallRecordsWithConfig({ + nextInstallRecords, + nextConfig: next, + ...(baseHash !== undefined ? { baseHash } : {}), + }); + } else { + await replaceConfigFile({ + nextConfig: next, + ...(baseHash !== undefined ? { baseHash } : {}), + }); } - await replaceConfigFile({ - nextConfig: next, - ...(baseHash !== undefined ? { baseHash } : {}), - ...(shouldMovePluginInstalls - ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } - : {}), - }); if (shouldMovePluginInstalls || resolvedPluginState?.pluginInstalled) { await refreshPluginRegistryAfterConfigMutation({ config: next, reason: "source-changed", + ...(shouldMovePluginInstalls ? { installRecords: nextInstallRecords } : {}), logger: { warn: (message) => runtime.log(message) }, }); } diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index 884811475e3..bc9217314ca 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -5,15 +5,12 @@ import type { } from "../../channels/plugins/types.adapters.js"; import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js"; import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; +import { commitPluginInstallRecordsWithConfig } from "../../cli/plugins-install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../../config/config.js"; import { danger } from "../../globals.js"; import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; -import { - PLUGIN_INSTALLS_CONFIG_PATH, - withoutPluginInstallRecords, - writePersistedInstalledPluginIndexInstallRecords, -} from "../../plugins/installed-plugin-index-records.js"; +import { withoutPluginInstallRecords } from "../../plugins/installed-plugin-index-records.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { normalizeLowercaseStringOrEmpty, @@ -148,21 +145,25 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti const shouldMovePluginInstalls = Boolean( cfg.plugins?.installs && Object.keys(cfg.plugins.installs).length > 0, ); + const nextInstallRecords = cfg.plugins?.installs ?? {}; if (shouldMovePluginInstalls) { - await writePersistedInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); cfg = withoutPluginInstallRecords(cfg); + await commitPluginInstallRecordsWithConfig({ + nextInstallRecords, + nextConfig: cfg, + baseHash: (await sourceSnapshotPromise)?.hash, + }); + } else { + await replaceConfigFile({ + nextConfig: cfg, + baseHash: (await sourceSnapshotPromise)?.hash, + }); } - await replaceConfigFile({ - nextConfig: cfg, - baseHash: (await sourceSnapshotPromise)?.hash, - ...(shouldMovePluginInstalls - ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } - : {}), - }); if (shouldMovePluginInstalls || resolvedExplicit.pluginInstalled) { await refreshPluginRegistryAfterConfigMutation({ config: cfg, reason: "source-changed", + ...(shouldMovePluginInstalls ? { installRecords: nextInstallRecords } : {}), logger: { warn: (message) => runtime.log(message) }, }); }