fix: retry update channel persistence

This commit is contained in:
Peter Steinberger
2026-05-01 09:30:03 +01:00
parent 61985cb1d2
commit 067375cee3
2 changed files with 125 additions and 4 deletions

View File

@@ -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<ReturnType<typeof replaceConfigFile>>);
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(
{

View File

@@ -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<ReturnType<typeof readConfigFileSnapshot>>,
next: OpenClawConfig,
): Awaited<ReturnType<typeof readConfigFileSnapshot>> {
if (!snapshot.valid) {
return snapshot;
}
return {
...params.configSnapshot,
...snapshot,
hash: undefined,
parsed: next,
sourceConfig: asResolvedSourceConfig(next),