From 067375cee3e2e6a8f73b589557ba0891f06d4f41 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 09:30:03 +0100 Subject: [PATCH] fix: retry update channel persistence --- src/cli/update-cli.test.ts | 84 +++++++++++++++++++++++++++- src/cli/update-cli/update-command.ts | 45 ++++++++++++++- 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index b40ad853493..14e9666c488 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -61,6 +61,15 @@ vi.mock("../infra/openclaw-root.js", () => ({ })); vi.mock("../config/config.js", () => ({ + ConfigMutationConflictError: class ConfigMutationConflictError extends Error { + readonly currentHash: string | null; + + constructor(message: string, params: { currentHash: string | null }) { + super(message); + this.name = "ConfigMutationConflictError"; + this.currentHash = params.currentHash; + } + }, readConfigFileSnapshot: vi.fn(), replaceConfigFile: vi.fn(), resolveGatewayPort: vi.fn(() => 18789), @@ -226,7 +235,8 @@ vi.mock("../runtime.js", () => ({ const { runGatewayUpdate } = await import("../infra/update-runner.js"); const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js"); -const { readConfigFileSnapshot, replaceConfigFile } = await import("../config/config.js"); +const { ConfigMutationConflictError, readConfigFileSnapshot, replaceConfigFile } = + await import("../config/config.js"); const { checkUpdateStatus, fetchNpmPackageTargetStatus, fetchNpmTagVersion, resolveNpmChannelTag } = await import("../infra/update-check.js"); const { runCommandWithTimeout } = await import("../process/exec.js"); @@ -757,6 +767,78 @@ describe("update-cli", () => { ); }); + it("post-core resume mode retries update channel persistence after config hash drift", async () => { + vi.mocked(readConfigFileSnapshot) + .mockResolvedValueOnce({ + ...baseSnapshot, + parsed: { update: { channel: "stable" } }, + resolved: { update: { channel: "stable" } } as OpenClawConfig, + sourceConfig: { update: { channel: "stable" } } as OpenClawConfig, + runtimeConfig: { update: { channel: "stable" } } as OpenClawConfig, + config: { update: { channel: "stable" } } as OpenClawConfig, + hash: "stable-hash", + }) + .mockResolvedValueOnce({ + ...baseSnapshot, + parsed: { + meta: { lastTouchedVersion: "2026.4.30" }, + update: { channel: "stable" }, + }, + resolved: { + meta: { lastTouchedVersion: "2026.4.30" }, + update: { channel: "stable" }, + } as OpenClawConfig, + sourceConfig: { + meta: { lastTouchedVersion: "2026.4.30" }, + update: { channel: "stable" }, + } as OpenClawConfig, + runtimeConfig: { + meta: { lastTouchedVersion: "2026.4.30" }, + update: { channel: "stable" }, + } as OpenClawConfig, + config: { + meta: { lastTouchedVersion: "2026.4.30" }, + update: { channel: "stable" }, + } as OpenClawConfig, + hash: "newer-hash", + }); + vi.mocked(replaceConfigFile) + .mockRejectedValueOnce( + new ConfigMutationConflictError("config changed since last load", { + currentHash: "newer-hash", + }), + ) + .mockResolvedValueOnce({} as Awaited>); + + await withEnvAsync( + { + OPENCLAW_UPDATE_POST_CORE: "1", + OPENCLAW_UPDATE_POST_CORE_CHANNEL: "dev", + OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "dev", + }, + async () => { + await updateCommand({ restart: false }); + }, + ); + + expect(replaceConfigFile).toHaveBeenCalledTimes(2); + expect(replaceConfigFile).toHaveBeenLastCalledWith({ + nextConfig: { + meta: { lastTouchedVersion: "2026.4.30" }, + update: { channel: "dev" }, + }, + baseHash: "newer-hash", + }); + expect(syncPluginsForUpdateChannel).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + meta: expect.objectContaining({ lastTouchedVersion: "2026.4.30" }), + update: expect.objectContaining({ channel: "dev" }), + }), + }), + ); + }); + it("passes the update timeout budget into post-core plugin updates", async () => { await withEnvAsync( { diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index e03814943a3..7bdfb47d03e 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -9,12 +9,14 @@ import { } from "../../commands/doctor-completion.js"; import { doctorCommand } from "../../commands/doctor.js"; import { + ConfigMutationConflictError, readConfigFileSnapshot, replaceConfigFile, resolveGatewayPort, } from "../../config/config.js"; import { formatConfigIssueLines } from "../../config/issue-format.js"; import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materialize.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { GATEWAY_SERVICE_KIND, GATEWAY_SERVICE_MARKER } from "../../daemon/constants.js"; import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js"; import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js"; @@ -1117,12 +1119,49 @@ async function persistRequestedUpdateChannel(params: { channel: params.requestedChannel, }, }; + try { + await replaceConfigFile({ + nextConfig: next, + baseHash: params.configSnapshot.hash, + }); + return createUpdatedChannelSnapshot(params.configSnapshot, next); + } catch (error) { + if (!(error instanceof ConfigMutationConflictError)) { + throw error; + } + } + + const refreshed = await readConfigFileSnapshot(); + if (!refreshed.valid) { + return refreshed; + } + const refreshedChannel = normalizeUpdateChannel(refreshed.config.update?.channel); + if (refreshedChannel === params.requestedChannel) { + return refreshed; + } + const refreshedNext = { + ...refreshed.sourceConfig, + update: { + ...refreshed.sourceConfig.update, + channel: params.requestedChannel, + }, + }; await replaceConfigFile({ - nextConfig: next, - baseHash: params.configSnapshot.hash, + nextConfig: refreshedNext, + baseHash: refreshed.hash, }); + return createUpdatedChannelSnapshot(refreshed, refreshedNext); +} + +function createUpdatedChannelSnapshot( + snapshot: Awaited>, + next: OpenClawConfig, +): Awaited> { + if (!snapshot.valid) { + return snapshot; + } return { - ...params.configSnapshot, + ...snapshot, hash: undefined, parsed: next, sourceConfig: asResolvedSourceConfig(next),