diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ebfdb4e34..b65b50bef20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Agents/OpenAI streams: yield via `setTimeout(0)` instead of `setImmediate` between bursty Responses chunks so abort timers can fire during the yield, keeping cancel-on-timeout responsive on hot streams. Refs #82462. - CLI/config: send SecretRef diagnostics to stderr so JSON command stdout remains parseable. - CLI/plugins: ship the bundled memory CLI as a package entry so package-installed `openclaw memory` commands register correctly. +- CLI/update: defer doctor-time plugin package installs during package swaps and seed post-core repair from the updated install registry, preventing duplicate reinstall failures. - Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing. - CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c. - Providers/Google: preserve and recover Gemini 3 tool-call thought signatures during native replay so function-calling turns no longer fail with missing `thought_signature` 400s. Fixes #72879. (#80358) Thanks @abnershang. diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 7a10bc0d925..d0bbe9eaab8 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1176,6 +1176,44 @@ describe("update-cli", () => { expect(updateCall?.skipIds?.has("demo")).toBe(true); }); + it("post-core resume mode prefers post-doctor disk install records over the stale parent snapshot", async () => { + const resultDir = createCaseDir("openclaw-post-core-disk-records"); + const recordsPath = path.join(resultDir, "plugin-install-records.json"); + await fs.mkdir(resultDir, { recursive: true }); + await fs.writeFile( + recordsPath, + `${JSON.stringify({ + stale: { + source: "npm", + spec: "@openclaw/stale@1.0.0", + installPath: "/tmp/stale-plugin", + }, + })}\n`, + "utf-8", + ); + const postDoctorRecords = { + codex: { + source: "npm", + spec: "@openclaw/codex@2026.5.17", + installPath: "/tmp/codex-plugin", + }, + } satisfies Record; + loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(postDoctorRecords); + + await withEnvAsync( + { + OPENCLAW_UPDATE_POST_CORE: "1", + OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable", + OPENCLAW_UPDATE_POST_CORE_INSTALL_RECORDS_PATH: recordsPath, + }, + async () => { + await updateCommand({ json: true, restart: false }); + }, + ); + + expect(syncPluginCall()?.config?.plugins?.installs).toEqual(postDoctorRecords); + }); + it("post-core resume mode persists the requested update channel with the updated process", async () => { vi.mocked(readConfigFileSnapshot).mockResolvedValue({ ...baseSnapshot, diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 6f3f7847fe2..f22084b22df 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -9,7 +9,10 @@ import { ensureCompletionCacheExists, } from "../../commands/doctor-completion.js"; import { doctorCommand } from "../../commands/doctor.js"; -import { UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV } from "../../commands/doctor/shared/update-phase.js"; +import { + UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV, + UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV, +} from "../../commands/doctor/shared/update-phase.js"; import { createPreUpdateConfigSnapshot } from "../../config/backup-rotation.js"; import { assertConfigWriteAllowedInCurrentMode, @@ -144,8 +147,6 @@ const POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_SOURC const POST_CORE_UPDATE_STARTED_AT_ENV = "OPENCLAW_UPDATE_POST_CORE_STARTED_AT_MS"; const POST_CORE_UPDATE_RESULT_POLL_MS = 100; const PRE_UPDATE_CONFIG_SNAPSHOT_MAX_AGE_MS = 6 * 60 * 60 * 1000; -const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV = - "OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE"; const SERVICE_REFRESH_PATH_ENV_KEYS = [ "OPENCLAW_HOME", "OPENCLAW_STATE_DIR", @@ -2733,6 +2734,14 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { postCoreConfigSnapshot, preUpdateSourceConfig, ); + const parentPluginInstallRecords = + await readPostCorePluginInstallRecordsFile(postCoreInstallRecordsPath); + // The updated doctor may have repaired plugin installs before this fresh process resumed. + const currentPluginInstallRecords = await loadInstalledPluginIndexInstallRecords(); + const pluginInstallRecords = + Object.keys(currentPluginInstallRecords).length > 0 + ? currentPluginInstallRecords + : parentPluginInstallRecords; const pluginUpdate = await runPostCorePluginUpdate({ root, @@ -2742,7 +2751,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { restoredAuthoredChannels: restoredPostCoreConfig.authoredChannels, opts, timeoutMs: updateStepTimeoutMs, - pluginInstallRecords: await readPostCorePluginInstallRecordsFile(postCoreInstallRecordsPath), + pluginInstallRecords, }); if (process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) { await writePostCorePluginUpdateResultFile( diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts index 9cbc7e2d498..c9dea95992a 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts @@ -374,6 +374,42 @@ describe("configured plugin install release step", () => { }); }); + it("defers package-manager plugin repair when an older updater supports post-doctor config writes", async () => { + mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ + changes: [], + warnings: [], + }); + + const { maybeRunConfiguredPluginInstallReleaseStep } = + await import("./release-configured-plugin-installs.js"); + const result = await maybeRunConfiguredPluginInstallReleaseStep({ + cfg: { + plugins: { + entries: { + discord: { enabled: true }, + }, + }, + }, + currentVersion: "2026.5.2-beta.1", + touchedVersion: "2026.5.1", + env: { + OPENCLAW_UPDATE_IN_PROGRESS: "1", + OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1", + }, + }); + + expect(readOnlyMissingPluginInstallRepairCall().env).toEqual({ + OPENCLAW_UPDATE_IN_PROGRESS: "1", + OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1", + }); + expect(result).toEqual({ + changes: [], + warnings: [], + completed: false, + touchedConfig: false, + }); + }); + it("repairs missing configured installs even when a prior update doctor touched config", async () => { mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ changes: ['Installed missing configured plugin "discord".'], diff --git a/src/commands/doctor/shared/update-phase.test.ts b/src/commands/doctor/shared/update-phase.test.ts index dbe0f274942..80fa0ef4d36 100644 --- a/src/commands/doctor/shared/update-phase.test.ts +++ b/src/commands/doctor/shared/update-phase.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV, UPDATE_IN_PROGRESS_ENV, + UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV, UPDATE_POST_CORE_CONVERGENCE_ENV, isLegacyPackageUpdateDoctorPass, isPostCoreConvergencePass, @@ -46,6 +47,12 @@ describe("update-phase env helpers", () => { [UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1", }), ).toBe(true); + expect( + shouldDeferConfiguredPluginInstallRepair({ + [UPDATE_IN_PROGRESS_ENV]: "1", + [UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1", + }), + ).toBe(true); expect( shouldDeferConfiguredPluginInstallRepair({ [UPDATE_IN_PROGRESS_ENV]: "1", @@ -67,6 +74,12 @@ describe("update-phase env helpers", () => { [UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1", }), ).toBe(false); + expect( + isLegacyPackageUpdateDoctorPass({ + [UPDATE_IN_PROGRESS_ENV]: "1", + [UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1", + }), + ).toBe(false); expect( isLegacyPackageUpdateDoctorPass({ [UPDATE_IN_PROGRESS_ENV]: "1", diff --git a/src/commands/doctor/shared/update-phase.ts b/src/commands/doctor/shared/update-phase.ts index c6667a940e2..4f427c4469e 100644 --- a/src/commands/doctor/shared/update-phase.ts +++ b/src/commands/doctor/shared/update-phase.ts @@ -4,6 +4,8 @@ export const UPDATE_IN_PROGRESS_ENV = "OPENCLAW_UPDATE_IN_PROGRESS"; export const UPDATE_POST_CORE_CONVERGENCE_ENV = "OPENCLAW_UPDATE_POST_CORE_CONVERGENCE"; export const UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV = "OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR"; +export const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV = + "OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE"; /** * True iff the caller is the doctor pass that runs WHILE the core package @@ -45,7 +47,8 @@ export function isUpdatePackageSwapInProgress(env: NodeJS.ProcessEnv): boolean { export function shouldDeferConfiguredPluginInstallRepair(env: NodeJS.ProcessEnv): boolean { return ( isUpdatePackageSwapInProgress(env) && - isTruthyEnvValue(env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]) + (isTruthyEnvValue(env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]) || + isTruthyEnvValue(env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV])) ); }